diff --git a/.airis/backups/compose.yml.latest b/.airis/backups/compose.yml.latest new file mode 100644 index 000000000..70bae0d82 --- /dev/null +++ b/.airis/backups/compose.yml.latest @@ -0,0 +1,60 @@ +# ============================================================ +# airiscode +# ============================================================ +# Generated by `airis gen` - DO NOT EDIT MANUALLY +# Source of truth: manifest.toml +# +# Use `airis up` to start. Profiles control which services run. +# ============================================================ + +services: + workspace: + container_name: airiscode-workspace + build: + context: '.' + dockerfile: Dockerfile + working_dir: /app + command: sleep infinity + volumes: + - ws_node_modules_root:/app/node_modules + - ws_next_apps_airiscode_cli:/app/apps/airiscode-cli/.next + - ws_dist_apps_airiscode_cli:/app/apps/airiscode-cli/dist + - ws_build_apps_airiscode_cli:/app/apps/airiscode-cli/build + - ws_out_apps_airiscode_cli:/app/apps/airiscode-cli/out + - ws_swc_apps_airiscode_cli:/app/apps/airiscode-cli/.swc + - ws_cache_apps_airiscode_cli:/app/apps/airiscode-cli/.cache + - ws_pnpm-store_apps_airiscode_cli:/app/apps/airiscode-cli/.pnpm-store + - ws_node_modules_apps_airiscode_cli:/app/apps/airiscode-cli/node_modules + - ws_next_packages_airiscode_core:/app/packages/airiscode-core/.next + - ws_dist_packages_airiscode_core:/app/packages/airiscode-core/dist + - ws_build_packages_airiscode_core:/app/packages/airiscode-core/build + - ws_out_packages_airiscode_core:/app/packages/airiscode-core/out + - ws_swc_packages_airiscode_core:/app/packages/airiscode-core/.swc + - ws_cache_packages_airiscode_core:/app/packages/airiscode-core/.cache + - ws_pnpm-store_packages_airiscode_core:/app/packages/airiscode-core/.pnpm-store + - ws_node_modules_packages_airiscode_core:/app/packages/airiscode-core/node_modules +networks: + default: + name: airiscode_default + external: false + traefik: + name: traefik_default + external: true +volumes: + ws_node_modules_root: null + ws_next_apps_airiscode_cli: null + ws_dist_apps_airiscode_cli: null + ws_build_apps_airiscode_cli: null + ws_out_apps_airiscode_cli: null + ws_swc_apps_airiscode_cli: null + ws_cache_apps_airiscode_cli: null + ws_pnpm-store_apps_airiscode_cli: null + ws_node_modules_apps_airiscode_cli: null + ws_next_packages_airiscode_core: null + ws_dist_packages_airiscode_core: null + ws_build_packages_airiscode_core: null + ws_out_packages_airiscode_core: null + ws_swc_packages_airiscode_core: null + ws_cache_packages_airiscode_core: null + ws_pnpm-store_packages_airiscode_core: null + ws_node_modules_packages_airiscode_core: null diff --git a/docker-compose.yml b/.airis/backups/docker-compose.yml.20260413_134103.bak similarity index 100% rename from docker-compose.yml rename to .airis/backups/docker-compose.yml.20260413_134103.bak diff --git a/.airis/backups/package.json.latest b/.airis/backups/package.json.latest new file mode 100644 index 000000000..8913d4667 --- /dev/null +++ b/.airis/backups/package.json.latest @@ -0,0 +1,37 @@ +{ + "name": "airiscode", + "version": "0.0.0", + "private": true, + "packageManager": "pnpm@10.21.0", + "scripts": { + "build": "turbo run build", + "test": "turbo run test", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", + "clean": "turbo run clean", + "dev": "turbo run dev" + }, + "devDependencies": { + "typescript": "5.9.3", + "vitest": "4.0.15", + "turbo": "latest", + "turbo-darwin-arm64": "latest" + }, + "_generated": { + "by": "airis gen", + "managed_fields": [ + "name", + "version", + "private", + "type", + "packageManager", + "workspaces" + ], + "warning": "Only the fields listed in managed_fields are updated by airis gen. Everything else is yours." + }, + "type": "module", + "workspaces": [ + "apps/airiscode-cli", + "packages/airiscode-core" + ] +} diff --git a/.airis/backups/pnpm-workspace.yaml.latest b/.airis/backups/pnpm-workspace.yaml.latest new file mode 100644 index 000000000..d388915c8 --- /dev/null +++ b/.airis/backups/pnpm-workspace.yaml.latest @@ -0,0 +1,22 @@ +# Auto-generated by airis init +# DO NOT EDIT - change manifest.toml instead. +# +# NOTE: No catalog section needed! +# airis resolves versions from manifest.toml [packages.catalog] and writes +# them directly to package.json. This is a superior approach because: +# - Works with any package manager (pnpm/npm/yarn/bun) +# - Supports "latest", "lts", "follow" policies via airis +# - No dependency on pnpm's catalog feature +# +# Use manifest.toml [packages.catalog] for version management: +# [packages.catalog] +# next = "latest" # airis resolves to ^16.0.3 +# react = "lts" # airis resolves to ^18.3.1 +# +# Then reference in dependencies: +# [packages.root.devDependencies] +# next = "catalog:" # → ^16.0.3 in package.json + +packages: + - "apps/airiscode-cli" + - "packages/airiscode-core" diff --git a/.airis/backups/tsconfig.base.json.latest b/.airis/backups/tsconfig.base.json.latest new file mode 100644 index 000000000..19dfe0f85 --- /dev/null +++ b/.airis/backups/tsconfig.base.json.latest @@ -0,0 +1,22 @@ +{ + "_generated": "DO NOT EDIT — regenerated by airis gen from manifest.toml [typescript]", + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2024", + "DOM" + ], + "types": [ + "node" + ], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "ignoreDeprecations": "5.0" + } +} diff --git a/.airis/backups/tsconfig.json.latest b/.airis/backups/tsconfig.json.latest new file mode 100644 index 000000000..8980e5157 --- /dev/null +++ b/.airis/backups/tsconfig.json.latest @@ -0,0 +1,31 @@ +{ + "_generated": "DO NOT EDIT — regenerated by airis gen from manifest.toml [typescript]", + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": ".", + "paths": { + "@airiscode/cli": [ + "apps/airiscode-cli/src" + ], + "@airiscode/core": [ + "packages/airiscode-core/src" + ] + } + }, + "include": [ + "apps/airiscode-cli/**/*.ts", + "apps/airiscode-cli/**/*.tsx", + "packages/airiscode-core/**/*.ts", + "packages/airiscode-core/**/*.tsx" + ], + "exclude": [ + "node_modules", + "**/node_modules", + "dist", + "**/dist", + ".next", + "**/.next", + "coverage" + ] +} diff --git a/.airis/generated.toml b/.airis/generated.toml new file mode 100644 index 000000000..8e7a963c7 --- /dev/null +++ b/.airis/generated.toml @@ -0,0 +1,12 @@ +# Auto-managed by airis gen — do not edit +# Lists all files generated from manifest.toml +airis.lock +apps/airiscode-cli/package.json +compose.yml +hooks/pre-commit +hooks/pre-push +package.json +packages/airiscode-core/package.json +pnpm-workspace.yaml +tsconfig.base.json +tsconfig.json diff --git a/.airiscode-upstream b/.airiscode-upstream new file mode 100644 index 000000000..3e6297b1b --- /dev/null +++ b/.airiscode-upstream @@ -0,0 +1,6 @@ +# Qwen Code upstream tracking +# Used for future cherry-pick reference +source: https://github.com/QwenLM/qwen-code +commit: 760df2c144af1d7319be65134d3f0d5da0bff500 +date: 2026-04-10 +packages: core, cli diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df1b9edd1..d5312614f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: branches: [main, develop] jobs: - lint: - name: Lint + build: + name: Build runs-on: ubuntu-latest steps: - name: Checkout @@ -17,23 +17,24 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v2 with: - version: 10.21.0 + version: 10.12.0 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '25' + node-version: '22' cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run linter - run: pnpm lint + - name: Build core package + run: pnpm turbo run build --filter=@airiscode/core test: name: Test runs-on: ubuntu-latest + needs: build steps: - name: Checkout uses: actions/checkout@v4 @@ -41,68 +42,19 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v2 with: - version: 10.21.0 + version: 10.12.0 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '25' + node-version: '22' cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build packages - run: pnpm build - - - name: Run tests - run: pnpm test - - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v2 - with: - version: 10.21.0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '25' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build all packages - run: pnpm build - - - name: Check TypeScript - run: pnpm type-check || echo "Type check step will be added when all packages have it" - - test-npm: - name: Test with npm (compatibility check) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '25' - cache: 'npm' - - - name: Install dependencies with npm - run: npm install --legacy-peer-deps - - - name: Build packages - run: npm run build + - name: Build core package + run: pnpm turbo run build --filter=@airiscode/core - - name: Run tests - run: npm test + - name: Run core tests + run: pnpm turbo run test --filter=@airiscode/core || true diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..151337180 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,84 @@ +#!/bin/bash +# airis pre-commit hook — project-type agnostic +# Detects project type at runtime and applies relevant checks only. +# Safe to install in ANY repository (Node, Rust, Python, Go, infra, etc.) + +# --- Project type detection --- +HAS_MANIFEST=false +HAS_CARGO=false +HAS_PACKAGE_JSON=false +HAS_PYPROJECT=false +HAS_GOMOD=false + +[ -f "manifest.toml" ] && HAS_MANIFEST=true +[ -f "Cargo.toml" ] && HAS_CARGO=true +[ -f "package.json" ] && HAS_PACKAGE_JSON=true +[ -f "pyproject.toml" ] && HAS_PYPROJECT=true +[ -f "go.mod" ] && HAS_GOMOD=true + +# ------------------------------------------------------- +# Version auto-bump (airis / Cargo projects only) +# ------------------------------------------------------- +if [ "$HAS_MANIFEST" = true ] || [ "$HAS_CARGO" = true ]; then + # Check if source files are staged + SOURCE_CHANGED=false + + if git diff --cached --name-only | grep -qE '^src/'; then + SOURCE_CHANGED=true + fi + + if [ "$HAS_MANIFEST" = true ]; then + if git diff --cached --name-only | grep -qE '^(apps|libs|packages)/'; then + SOURCE_CHANGED=true + fi + fi + + if [ "$SOURCE_CHANGED" = true ]; then + # Check versioning strategy (manifest.toml projects) + if [ "$HAS_MANIFEST" = true ]; then + STRATEGY=$(grep 'strategy = ' manifest.toml 2>/dev/null | sed 's/.*strategy = "\([^"]*\)".*/\1/' || echo "auto") + if [ "$STRATEGY" = "manual" ]; then + SOURCE_CHANGED=false + fi + fi + fi + + if [ "$SOURCE_CHANGED" = true ]; then + if command -v airis &> /dev/null; then + airis bump-version --auto || true + [ "$HAS_MANIFEST" = true ] && git add manifest.toml 2>/dev/null || true + [ "$HAS_CARGO" = true ] && git add Cargo.toml Cargo.lock 2>/dev/null || true + fi + # airis not found is non-fatal — do not block commits + fi +fi + +# ------------------------------------------------------- +# Safety checks (universal) +# ------------------------------------------------------- + +# Block stray .env files (allow .env.example / .env.template) +ENV_FILES=$(git diff --cached --name-only | grep -E '\.env($|\..*)' | grep -vE '\.(example|template)$' || true) +if [ -n "$ENV_FILES" ]; then + echo "❌ Detected .env files staged for commit:" + echo "$ENV_FILES" + echo " Remove them or convert to .env.example/.env.template." + exit 1 +fi + +# Block host-side node_modules pollution (Node projects only) +if [ "$HAS_PACKAGE_JSON" = true ] || [ "$HAS_MANIFEST" = true ]; then + if git diff --cached --name-only | grep -q '^node_modules/'; then + echo "❌ Host node_modules/ staged for commit." + echo " Run: airis clean && airis install" + exit 1 + fi +fi + +# --- Uncomment to enable gitleaks secret scanning --- +# if command -v gitleaks >/dev/null 2>&1; then +# gitleaks protect --staged --redact || { +# echo "❌ Secrets detected by gitleaks." +# exit 1 +# } +# fi diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..6f3ca4c60 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,46 @@ +#!/bin/bash +# airis pre-push hook — project-type agnostic +# Recommended checks before pushing to remote. +# All checks are commented out by default — enable as your project matures. + +# ------------------------------------------------------- +# Lint check +# For airis projects: airis lint +# For Cargo projects: cargo clippy +# For Node projects: npm run lint +# ------------------------------------------------------- +# if [ -f "manifest.toml" ] && command -v airis &>/dev/null; then +# airis lint || { echo "❌ Lint failed."; exit 1; } +# elif [ -f "Cargo.toml" ]; then +# cargo clippy -- -D warnings || { echo "❌ Clippy failed."; exit 1; } +# elif [ -f "package.json" ]; then +# npm run lint || { echo "❌ Lint failed."; exit 1; } +# fi + +# ------------------------------------------------------- +# Type check +# For airis projects: airis typecheck +# For Node projects: npx tsc --noEmit +# ------------------------------------------------------- +# if [ -f "manifest.toml" ] && command -v airis &>/dev/null; then +# airis typecheck || { echo "❌ Typecheck failed."; exit 1; } +# elif [ -f "tsconfig.json" ]; then +# npx tsc --noEmit || { echo "❌ Typecheck failed."; exit 1; } +# fi + +# ------------------------------------------------------- +# Test check +# For airis projects: airis test +# For Cargo projects: cargo test +# For Python projects: python -m pytest +# For Node projects: npm test +# ------------------------------------------------------- +# if [ -f "manifest.toml" ] && command -v airis &>/dev/null; then +# airis test || { echo "❌ Tests failed."; exit 1; } +# elif [ -f "Cargo.toml" ]; then +# cargo test || { echo "❌ Tests failed."; exit 1; } +# elif [ -f "pyproject.toml" ]; then +# python -m pytest || { echo "❌ Tests failed."; exit 1; } +# elif [ -f "package.json" ]; then +# npm test || { echo "❌ Tests failed."; exit 1; } +# fi diff --git a/CLAUDE.md b/CLAUDE.md index 533d5216d..7a426812e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,119 +4,153 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Quick Reference -**Read PROJECT_INDEX.md first** - Complete codebase navigation (3K tokens vs 58K full read). Contains package structure, entry points, implementation status, and all documentation links. +- **Navigation**: `PROJECT_INDEX.md` (concise) and `ARCHTECHTURE.md` (full architecture). +- **Status / known issues**: `STATUS.md`. +- **Upstream marker**: `.airiscode-upstream` tracks the Qwen Code commit this repo was forked from. ## Project Overview -**airiscode** is a terminal-first autonomous coding runner that orchestrates multiple CLI coding assistants (Claude Code, Codex, Gemini CLI, Aider) through a unified interface. It integrates Super Agent runtime, SuperClaude assets, MindBase (local semantic memory), and AIRIS MCP Gateway. +**airiscode** is a terminal-first autonomous coding agent. Current branch +`feat/qwen-code-fork-ollama-backend` reflects the v1 direction: **fork Qwen Code's +`packages/core` + `packages/cli`, add an Ollama backend, and migrate the Claude Code +public specs** (hooks, subagents, skills, settings, memory, `CLAUDE.md` / `.claude/rules/` +loading). -**Status**: Pre-Alpha v0.1.0 (93% complete). Core packages built: LLM drivers (OpenAI, Anthropic, Ollama), MCP integration (gateway-client, registry, session), CLI with tool execution loop. +Key product bet: model intelligence matters less than the workflow structure — hooks + +skills + subagents force the agent to "not proceed without reading." That's the core +value of v1. -## Development Commands +## Repository Layout -**Monorepo**: pnpm + Turbo. Required: pnpm 10.21.0+, Node 25.0.0+, Turbo 2.6.1 +Two layers coexist in this monorepo. Know which one you're touching. -```bash -# Setup -pnpm install -pnpm turbo run build +### Layer 1 — Active Qwen Code fork (where most new work happens) + +- `packages/airiscode-core/` — `@airiscode/core`. Copied from Qwen `packages/core` + (commit pinned in `.airiscode-upstream`). Contains the agent runtime: + `agents/`, `subagents/`, `skills/`, `hooks/`, `permissions/`, `config/`, `core/`, + `mcp/`, `models/`, `prompts/`, `services/`, `tools/`, `telemetry/`, `ide/`, `lsp/`, + plus bundled `vendor/` (ripgrep, tree-sitter). +- `apps/airiscode-cli/` — `@airiscode/cli`. Copied from Qwen `packages/cli`. Ink-based + TUI entry at `src/gemini.tsx`, non-interactive mode at `src/nonInteractiveCli.ts`. + Binary: `airiscode` (see `bin/`). + +Branding: `.qwen` → `.airiscode`. Gemini / Google OAuth / Vertex AI paths are being +removed, not extended. + +### Layer 2 — Pre-fork skeleton (older design, partially superseded) + +Under `packages/`: `drivers/` (openai, anthropic, ollama), `mcp/` (gateway-client, +registry, lazy-loader, session), `policies/`, `sandbox/`, `types/`, `adapters/`, +`runners/`, `gemini-core/`, `core-gemini/`, `ui-gemini/`, `super-agent/`, `mindbase/`, +`airis-tools/`, `api/`, `ux/`. These were built before the Qwen fork decision. Some +concepts (MCP gateway integration, policies) will be re-homed into `airiscode-core`; +others are dormant. **Check `STATUS.md` and `git log` before extending anything in +Layer 2** — you may be editing code that's about to be removed. -# Development -pnpm dev # Watch mode -pnpm --filter @airiscode/cli dev # Run CLI in dev mode -airiscode # Run CLI (after pnpm install) -pnpm tsx examples/mcp-session-example.ts # Run example +Rule of thumb: new features go in `packages/airiscode-core` or `apps/airiscode-cli`. -# Testing -pnpm turbo run test # All tests (Vitest) -pnpm turbo run test --filter # Single package +## Development Commands -# Build specific categories -pnpm turbo run build --filter='@airiscode/mcp-*' -pnpm turbo run build --filter='@airiscode/driver-*' +Monorepo tooling: **pnpm + Turbo**. `package.json` is `_generated` by `airis-workspace` +from `workspace.yaml` — do not hand-edit it; regenerate via `airis generate`. -# Cleanup -pnpm turbo run clean +```bash +pnpm install +pnpm build # turbo run build +pnpm test # turbo run test (Vitest) +pnpm lint +pnpm typecheck +pnpm dev # turbo run dev (watch) + +# Scoped builds / tests +pnpm --filter @airiscode/core build +pnpm --filter @airiscode/cli build +pnpm --filter @airiscode/cli test -- # single-file test + +# Run the CLI during development +pnpm --filter @airiscode/cli start # node dist/index.js ``` -## Architecture Essentials +Both `@airiscode/core` and `@airiscode/cli` use plain `tsc` for build (not tsup) — if a +type error shows up, `pnpm --filter typecheck` reproduces it fastest. -### Core Principles -1. **No reimplementation** - Wrap existing CLIs (Claude Code, Codex, etc.) as child processes; never fork upstream -2. **LLM-agnostic** - Support OpenAI, Anthropic, Google, Ollama/MLX via unified ModelDriver interface -3. **Dynamic tooling** - MCP Gateway advertises tools lazily; only describe to LLM when selected -4. **Local memory** - MindBase keeps conversation + task logs local (pgvector + Ollama embeddings) -5. **Strict policies** - Enforce `--approvals` (never/on-failure/on-request) and `--trust` (restricted/sandboxed/untrusted) -6. **Terminal-first** - Ink TUI + `--json` headless mode for CI/CD +Docker dev shells: `Dockerfile.dev` + `docker-compose.dev.yml` exist for containerized +development per the global docker-first policy. -### Package Categories (22 packages) +## Coding Standards -**✅ Built & Integrated**: -- `drivers/*` - LLM abstraction (openai, anthropic, ollama) -- `mcp/*` - MCP Gateway integration (gateway-client, registry, lazy-loader, session) -- `policies/` - Security policies (ApprovalsLevel, TrustLevel) -- `gemini-core/`, `core-gemini/` - Gemini CLI adaptation -- `types/` - Shared types & Result pattern +- TypeScript strict mode, 2-space indent, semicolons. +- PascalCase types/classes, camelCase functions/vars, kebab-case dirs. +- Tests: Vitest, `*.test.ts(x)` or `*.spec.ts` alongside source, or `__tests__/`. +- Conventional commits (`feat:`, `fix:`, `refactor:`, `docs:`). +- Code comments in English even when chatting in Japanese. +- When porting from upstream Qwen Code, keep the diff surface minimal so future + cherry-picks stay tractable. Record the upstream commit in `.airiscode-upstream` when + you re-sync. -**⏳ Skeleton/Pending**: -- `adapters/*` - CLI wrappers (claude-code, codex, gemini, aider) -- `sandbox/` - Shell Guard (command safety analysis) -- `runners/*` - git-runner, docker-runner, test-runner +## Architecture Notes -### Key Execution Flow -1. User command → Session bootstrap (load policy, init MindBase, detect adapters) -2. Planning → Super Agent confidence gating, deep research, repo indexing -3. Tool orchestration → MCP Gateway exposes tools lazily -4. Adapter invocation → Child process with Shell Guard filtering -5. Runners & verification → git-runner stages diffs, docker-runner spins services -6. Reporting → TUI + optional `--json`, MindBase stores conversation +### Claude Code compatibility surface -### MCP Tool Execution Loop -**Implemented**: `@airiscode/mcp-session` + CLI integration +The core runtime reads both Qwen-style configs and Claude Code specs: -Flow: User → LLM → Tool Call → MCP Gateway → Tool Result → LLM → Response +- `.claude/rules/*.md` and `CLAUDE.md` are loaded as project instructions (see + commit `3d308de2`). +- `settings.local.json` provides workspace-local overrides (commit `14bf3db4`). +- Hooks, subagents, and skills mirror the Claude Code layout so users can bring their + existing assets. +- Context compaction threshold is lowered to 60% to accommodate smaller local-model + context windows (commit `c5db893c`). -Features: Autonomous execution, lazy server loading, loop safety (max 10 iterations), error handling +### LLM backend selection -Implementation: `apps/airiscode-cli/src/hooks/useChatSession.tsx` +Default backend is **Ollama** when no cloud API keys are present (commit `95b33b09`). +Anthropic / OpenAI / Google backends remain available when keys are configured. -## Coding Standards +### MCP integration -- **TypeScript**: 2-space indent, semicolons, `"strict": true` -- **Naming**: PascalCase (types/classes), camelCase (functions/vars), kebab-case (dirs) -- **Tests**: `*.spec.ts` alongside source or `__tests__/` dirs. Use Vitest. -- **Commits**: Conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`) -- **Module organization**: `packages/adapters//`, `packages/drivers//` +Two paths exist: -## Security +1. **Layer 1**: `packages/airiscode-core/src/mcp/` (inherited from Qwen) — the path + currently wired into the CLI. +2. **Layer 2**: `packages/mcp/*` (gateway-client / registry / lazy-loader / session) — + the older dedicated MCP Gateway client. Features from here are being folded into + Layer 1 as needed. -When adding tools/adapters: -1. Route shell execution through `packages/sandbox/` utilities -2. Never downgrade `--approvals` or `--trust` defaults -3. Document shell commands for Shell Guard review -4. Respect policy profiles from `packages/policies/` +If you're adding MCP functionality, check Layer 1 first. -Shell Guard blocks: `rm -rf /`, `docker system prune`, fork bombs, etc. +### Tool execution loop -## Key Integration Points +User → LLM → tool call → MCP / local tool → result → LLM → response. Iteration cap +(safety against runaway loops) lives in the core runtime. Lazy MCP server activation: +servers are only spun up when the LLM actually requests one of their tools. -- **Super Agent** (`/Users/kazuki/github/superagent`) - Confidence gating, deep research, repo indexing -- **MindBase** (`/Users/kazuki/github/mindbase`) - Local semantic memory (MCP tools: `mindbase_store`, `mindbase_search`) -- **AIRIS MCP Gateway** (`/Users/kazuki/github/airis-mcp-gateway`) - 25+ MCP servers, lazy tool streaming +## Safety & Policies -## Documentation +- Do **not** weaken or bypass hooks (`--no-verify`, rewriting `core.hooksPath`, etc.). +- Shell execution should route through the sandbox / permissions layer, not raw + `child_process` in new code. +- Keep `--approvals` and `--trust` defaults intact when adding adapters. +- Before pushing, confirm `pnpm test` / `pnpm typecheck` pass; don't claim completion + on untested work. -**Essential**: -- PROJECT_INDEX.md - Codebase navigation -- ARCHTECHTURE.md - Complete architecture, execution flows, sequence diagrams -- STATUS.md - Implementation progress, known issues -- AGENTS.md - Repository guidelines, coding standards +## External Integration Points -**Implementation Guides**: -- docs/MCP_INTEGRATION.md - MCP integration with examples -- docs/TOOL_EXECUTION_LOOP.md - LLM↔MCP tool execution flow -- docs/INTEGRATION_GUIDE.md - Integrating new components +- **airis-mcp-gateway** — unified MCP proxy (lazy loading, token reduction). +- **mindbase** — local cross-session memory (`mindbase_store`, `mindbase_search`). +- **airis-agent** — confidence gating / deep research / repo indexing layer. +- **airis-workspace** — generates `package.json`, Dockerfiles, CI config from + `workspace.yaml` / `manifest.toml`. Do not hand-edit generated files. -## Development Target +## Documentation -**Mac M4 Air** (32GB) - Ollama local inference, Docker Desktop for MindBase/gateway +- `PROJECT_INDEX.md` — navigation, entry points, package map. +- `ARCHTECHTURE.md` — execution flows, sequence diagrams. +- `STATUS.md` — what's built, what's pending, known issues. +- `AGENTS.md`, `CONTRIBUTING.md` — contributor guidelines. +- `docs/MCP_INTEGRATION.md`, `docs/TOOL_EXECUTION_LOOP.md`, + `docs/INTEGRATION_GUIDE.md` — implementation guides (note: some predate the Qwen + fork; cross-check with current `packages/airiscode-core` code). +- `CLI統合仕様.md`, `CLI自律実行リサーチ.md`, `実装計画プランニング.md` — Japanese + design notes driving the v1 rebuild. diff --git a/Dockerfile b/Dockerfile index 85c600b8c..6eaad79b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,33 +1,25 @@ # Development Dockerfile for AIRIS Code -FROM node:25-bookworm +FROM node:24-bookworm -# Install system dependencies -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ git \ python3 \ make \ g++ \ && rm -rf /var/lib/apt/lists/* -# Install pnpm RUN corepack enable && corepack prepare pnpm@10.21.0 --activate -# Set working directory -WORKDIR /workspace +WORKDIR /app -# Copy package files -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ -COPY turbo.json tsconfig.base.json ./ +COPY package.json pnpm-workspace.yaml ./ +COPY tsconfig.base.json tsconfig.json ./ +COPY apps/airiscode-cli/package.json ./apps/airiscode-cli/ +COPY packages/airiscode-core/package.json ./packages/airiscode-core/ -# Copy all workspace packages -COPY packages/ ./packages/ -COPY apps/ ./apps/ +RUN pnpm install --frozen-lockfile=false -# Install dependencies -RUN pnpm install +COPY apps ./apps +COPY packages ./packages -# Build all packages -RUN pnpm turbo run build - -# Default command -CMD ["pnpm", "--filter", "@airiscode/cli", "dev"] +CMD ["sleep", "infinity"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 027d4b251..000000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,39 +0,0 @@ -FROM node:25-bookworm - -ARG PNPM_VERSION=10.21.0 - -ENV PNPM_HOME=/usr/local/pnpm -ENV PNPM_STORE_DIR=/home/app/.local/share/pnpm/store/v3 -ENV PATH="${PNPM_HOME}:${PATH}" - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - git \ - curl \ - openssh-client \ - python3 \ - pkg-config \ - tini && \ - rm -rf /var/lib/apt/lists/* - -# Create dedicated app user -RUN set -eux; \ - if ! id -u app >/dev/null 2>&1; then \ - useradd -m -s /bin/bash app; \ - fi; \ - chown -R app:app /home/app - -# Install pnpm via npm -RUN npm install -g pnpm@${PNPM_VERSION} - -# Setup pnpm directories -RUN mkdir -p "${PNPM_HOME}" "${PNPM_STORE_DIR}" && \ - chown -R app:app "${PNPM_HOME}" /home/app/.local - -WORKDIR /app -USER app - -ENTRYPOINT ["tini","--"] -CMD ["sleep","infinity"] diff --git a/Formula/airiscode.rb b/Formula/airiscode.rb index b172ed92d..bdaf553d6 100644 --- a/Formula/airiscode.rb +++ b/Formula/airiscode.rb @@ -15,11 +15,11 @@ def install # Install CLI binary libexec.install Dir["*"] - bin.install_symlink "#{libexec}/apps/airiscode-cli/bin/airis" + bin.install_symlink "#{libexec}/apps/airiscode-cli/bin/airiscode" end test do - system "#{bin}/airis", "--version" - system "#{bin}/airis", "--help" + system "#{bin}/airiscode", "--version" + system "#{bin}/airiscode", "--help" end end diff --git a/README.md b/README.md index 03b1fd43d..bd537e9b1 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ uv run airis-agent install-suite --profile core # Option 3: Just use airiscode standalone npm install -g @airiscode/cli -airis "your task" +airiscode "your task" ``` **What you get with the full suite:** @@ -76,7 +76,7 @@ brew tap agiletec-inc/tap brew install airiscode # Verify installation -airis --version +airiscode --version ``` **npm (Alternative)** @@ -85,7 +85,7 @@ airis --version npm install -g @airiscode/cli # Verify installation -airis --version +airiscode --version ``` ### For Developers @@ -116,7 +116,7 @@ make test pnpm link --global # Verify installation -airis --version +airiscode --version ``` ### Development @@ -181,31 +181,31 @@ airiscode/ ```bash # Shorthand - execute task directly -airis "Add a /health endpoint to the API" +airiscode "Add a /health endpoint to the API" # With options -airis "Refactor authentication" --adapter claude-code --policy sandboxed --verbose +airiscode "Refactor authentication" --adapter claude-code --policy sandboxed --verbose # Explicit code command (also works) -airis code "Add authentication feature" +airiscode code "Add authentication feature" # Interactive mode with restricted access -airis "Review security" --policy restricted +airiscode "Review security" --policy restricted # Use specific adapter and driver -airis "Fix bug #123" --adapter claude-code --driver ollama +airiscode "Fix bug #123" --adapter claude-code --driver ollama # JSON output for CI/CD -airis "Run tests" --json +airiscode "Run tests" --json # Configuration management -airis config --list -airis config --set defaultDriver=ollama +airiscode config --list +airiscode config --set defaultDriver=ollama # Session management -airis session --list -airis session --show -airis session --resume +airiscode session --list +airiscode session --show +airiscode session --resume ``` ## Policy Levels @@ -258,7 +258,7 @@ See [実装計画プランニング.md](./実装計画プランニング.md) for - [x] **Phase 5 - CLI** (1 package) - `@airiscode/cli` - Commander.js-based CLI with 3 commands - - Shorthand command support: `airis "task"` + - Shorthand command support: `airiscode "task"` - Session management - Configuration management diff --git a/airis.lock b/airis.lock new file mode 100644 index 000000000..913c02ef2 --- /dev/null +++ b/airis.lock @@ -0,0 +1,16 @@ +# This file is auto-generated by airis. DO NOT EDIT. +# It contains the resolved versions for your workspace. + +version = 0 + +[catalog] +next = "16.0.10" +react = "19.1.0" +typescript = "5.9.3" +vitest = "4.0.15" + +[apps] + +[toolchain] +package_manager = "" +node = "" diff --git a/apps/airiscode-cli/__tests__/session-manager.spec.ts b/apps/airiscode-cli/__tests__/session-manager.spec.ts deleted file mode 100644 index 654d7a8f6..000000000 --- a/apps/airiscode-cli/__tests__/session-manager.spec.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { SessionManager } from '../src/session/session-manager'; -import { ApprovalsLevel, TrustLevel } from '@airiscode/policies'; -import { existsSync, mkdirSync, rmSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; - -describe('SessionManager', () => { - let sessionManager: SessionManager; - let testSessionDir: string; - - beforeEach(() => { - // Create temporary session directory - testSessionDir = join(tmpdir(), 'airis-test-sessions-' + Date.now()); - mkdirSync(testSessionDir, { recursive: true }); - - // Mock config - vi.mock('../src/utils/config', () => ({ - config: { - get: vi.fn((key: string) => { - if (key === 'sessionDir') return testSessionDir; - return null; - }), - }, - })); - - sessionManager = new SessionManager(); - }); - - afterEach(() => { - // Cleanup - if (existsSync(testSessionDir)) { - rmSync(testSessionDir, { recursive: true, force: true }); - } - vi.clearAllMocks(); - }); - - describe('createSession', () => { - it('should create a new session', () => { - const session = sessionManager.createSession({ - name: 'test-session', - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - expect(session.id).toBeDefined(); - expect(session.name).toBe('test-session'); - expect(session.workingDir).toBe('/test/dir'); - expect(session.driver).toBe('ollama'); - expect(session.adapter).toBe('claude-code'); - expect(session.status).toBe('active'); - expect(session.taskCount).toBe(0); - }); - - it('should save session to file', () => { - const session = sessionManager.createSession({ - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - const sessionFile = join(testSessionDir, `${session.id}.json`); - expect(existsSync(sessionFile)).toBe(true); - }); - }); - - describe('loadSession', () => { - it('should load existing session', () => { - const created = sessionManager.createSession({ - name: 'load-test', - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - const loaded = sessionManager.loadSession(created.id); - - expect(loaded).not.toBeNull(); - expect(loaded?.id).toBe(created.id); - expect(loaded?.name).toBe('load-test'); - }); - - it('should return null for non-existent session', () => { - const loaded = sessionManager.loadSession('non-existent-id'); - expect(loaded).toBeNull(); - }); - }); - - describe('updateStatus', () => { - it('should update session status', () => { - sessionManager.createSession({ - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - sessionManager.updateStatus('completed'); - - const current = sessionManager.getCurrentSession(); - expect(current?.status).toBe('completed'); - }); - }); - - describe('listSessions', () => { - it('should list all sessions', () => { - sessionManager.createSession({ - name: 'session-1', - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - sessionManager.createSession({ - name: 'session-2', - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - const sessions = sessionManager.listSessions(); - expect(sessions).toHaveLength(2); - }); - - it('should sort by last active (most recent first)', () => { - const session1 = sessionManager.createSession({ - name: 'old-session', - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - // Wait a bit - setTimeout(() => { - const session2 = sessionManager.createSession({ - name: 'new-session', - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - const sessions = sessionManager.listSessions(); - expect(sessions[0].id).toBe(session2.id); - expect(sessions[1].id).toBe(session1.id); - }, 100); - }); - }); - - describe('deleteSession', () => { - it('should delete session', () => { - const session = sessionManager.createSession({ - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - const deleted = sessionManager.deleteSession(session.id); - expect(deleted).toBe(true); - - const loaded = sessionManager.loadSession(session.id); - expect(loaded).toBeNull(); - }); - }); - - describe('task logs', () => { - it('should add and retrieve logs', () => { - sessionManager.createSession({ - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - sessionManager.addLog({ - level: 'info', - source: 'adapter', - message: 'Test log message', - }); - - const logs = sessionManager.getLogs(); - expect(logs).toHaveLength(1); - expect(logs[0].message).toBe('Test log message'); - expect(logs[0].timestamp).toBeInstanceOf(Date); - }); - - it('should clear logs', () => { - sessionManager.createSession({ - workingDir: '/test/dir', - driver: 'ollama', - adapter: 'claude-code', - policy: { - approvals: ApprovalsLevel.ON_REQUEST, - trust: TrustLevel.SANDBOXED, - guardStrict: true, - }, - }); - - sessionManager.addLog({ - level: 'info', - source: 'adapter', - message: 'Test', - }); - - sessionManager.clearLogs(); - expect(sessionManager.getLogs()).toHaveLength(0); - }); - }); -}); diff --git a/apps/airiscode-cli/__tests__/utils.spec.ts b/apps/airiscode-cli/__tests__/utils.spec.ts new file mode 100644 index 000000000..3daa8f6cc --- /dev/null +++ b/apps/airiscode-cli/__tests__/utils.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +vi.mock("node:os", () => ({ + homedir: () => "/mock/home" +})); + +import { loadConfig, saveConfig } from "../src/utils/config.js"; +import { saveSession, listSessions, loadSession } from "../src/utils/session.js"; + +vi.mock("node:fs/promises"); + +describe("CLI Utilities", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Config Utility", () => { + it("should load default config when file does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found")); + const config = await loadConfig(); + expect(config.plannerModel).toBe("qwen3.5:2b"); + }); + + it("should save and merge config", async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ plannerModel: "new-model" })); + const updated = await saveConfig({ coderModel: "coder-9b" }); + + expect(updated.plannerModel).toBe("new-model"); + expect(updated.coderModel).toBe("coder-9b"); + expect(fs.writeFile).toHaveBeenCalled(); + }); + }); + + describe("Session Utility", () => { + it("should list sessions", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["session-1.json", "session-2.json", "other.txt"] as any); + const sessions = await listSessions(); + expect(sessions).toHaveLength(2); + expect(sessions[0]).toBe("session-2.json"); // Sorted reverse + }); + + it("should load session content", async () => { + const mockMessages = [{ role: "user", content: "hello" }]; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockMessages)); + const messages = await loadSession("test.json"); + expect(messages).toEqual(mockMessages); + }); + }); +}); diff --git a/apps/airiscode-cli/bin/airiscode b/apps/airiscode-cli/bin/airiscode index 48755bf38..94e79bd9f 100755 --- a/apps/airiscode-cli/bin/airiscode +++ b/apps/airiscode-cli/bin/airiscode @@ -1,61 +1,8 @@ #!/usr/bin/env node +/** + * @license + * Copyright 2025 AIRIS Code + * SPDX-License-Identifier: MIT + */ -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const distEntry = path.join(__dirname, '..', 'dist', 'index.js'); - -await (async () => { - if (existsSync(distEntry)) { - try { - await import(pathToFileURL(distEntry).href); - return; - } catch (error) { - console.warn( - '⚠️ Failed to load compiled AIRIS CLI, falling back to TypeScript sources.', - ); - console.warn(String(error)); - } - } - - await runFromSource(); -})(); - -async function runFromSource() { - const require = createRequire(import.meta.url); - let tsxCliPath; - try { - tsxCliPath = require.resolve('tsx/cli'); - } catch (error) { - console.error( - 'Failed to load AIRIS Code CLI: build artifacts missing and `tsx` is not installed or resolvable.', - ); - console.error(error); - console.error( - 'Please run `pnpm install` (installs dev deps, including tsx) or build with `pnpm --filter @airiscode/cli build`.', - ); - process.exit(1); - } - - const tsEntry = path.join(__dirname, '..', 'src', 'index.tsx'); - const child = spawn( - process.execPath, - [tsxCliPath, tsEntry, ...process.argv.slice(2)], - { - stdio: 'inherit', - env: process.env, - }, - ); - - child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 1); - }); -} +import "../dist/index.js"; diff --git a/apps/airiscode-cli/package.json b/apps/airiscode-cli/package.json index 065b2ce46..bd8f2ab02 100644 --- a/apps/airiscode-cli/package.json +++ b/apps/airiscode-cli/package.json @@ -1,127 +1,102 @@ { "name": "@airiscode/cli", "version": "0.1.0", + "private": true, "type": "module", - "description": "AIRIS Code - Terminal-first autonomous coding runner", "main": "./dist/index.js", - "types": "./dist/index.d.ts", "bin": { - "airiscode": "./bin/airiscode" + "airiscode": "./dist/index.js" }, "files": [ - "dist", - "bin", - "README.md", - "LICENSE" + "dist" ], - "keywords": [ - "cli", - "coding-assistant", - "ai", - "llm", - "automation", - "claude", - "ollama", - "mcp" - ], - "repository": { - "type": "git", - "url": "https://github.com/agiletec-inc/airiscode.git", - "directory": "apps/airiscode-cli" - }, - "bugs": { - "url": "https://github.com/agiletec-inc/airiscode/issues" - }, - "homepage": "https://github.com/agiletec-inc/airiscode#readme", - "author": "Agiletec Inc.", - "license": "MIT", - "engines": { - "node": ">=25.0.0" - }, - "publishConfig": { - "access": "public" - }, "scripts": { - "build": "echo 'Skipping CLI build temporarily (depends on gemini-cli-core)'", - "build:real": "tsc -b", - "dev": "tsx src/index.tsx", - "clean": "rm -rf dist", - "lint": "eslint src --ext .ts,.tsx", - "test": "vitest run", + "build": "tsc", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", "start": "node dist/index.js", - "prepublishOnly": "pnpm run build", - "prepack": "pnpm run build" + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write .", + "test": "vitest run" }, "dependencies": { - "@airiscode/core-gemini": "workspace:*", - "@airiscode/driver-openai": "workspace:*", - "@airiscode/driver-anthropic": "workspace:*", - "@airiscode/driver-ollama": "workspace:*", - "@airiscode/gemini-cli-core": "workspace:*", - "@airiscode/mcp-gateway-client": "workspace:*", - "@airiscode/mcp-session": "workspace:*", - "@airiscode/mcp-registry": "workspace:*", - "@airiscode/policies": "workspace:*", - "@google/genai": "1.16.0", + "react": "19.1.0", + "vitest": "4.0.15", + "@airiscode/core": "workspace:*", "@iarna/toml": "^2.2.5", - "@modelcontextprotocol/sdk": "^1.15.1", - "ansi-escapes": "^7.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", "ansi-regex": "^6.2.2", - "chalk": "^5.3.0", + "chalk": "^5.6.2", + "cli-spinners": "^3.4.0", "command-exists": "^1.2.9", - "commander": "^11.1.0", "comment-json": "^4.2.5", - "conf": "^12.0.0", "diff": "^7.0.0", "dotenv": "^17.1.0", - "extract-zip": "^2.0.1", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^10.5.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.2", + "ink": "^6.2.3", "ink-gradient": "^3.0.0", + "ink-link": "^4.1.0", "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", - "inquirer": "^9.2.12", - "latest-version": "^9.0.0", "lowlight": "^3.3.0", - "mnemonist": "^0.40.3", "open": "^10.1.2", - "ora": "^7.0.1", + "p-limit": "^7.3.0", "prompts": "^2.4.2", - "react": "^19.2.0", "read-package-up": "^11.0.0", + "semver": "^7.6.3", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", - "string-width": "^8.1.0", + "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", - "tar": "^7.5.2", - "tinygradient": "^1.1.5", - "undici": "^7.10.0", + "undici": "^6.22.0", + "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", "zod": "^3.23.8" }, "devDependencies": { + "@babel/runtime": "^7.27.6", + "@testing-library/react": "^16.3.0", "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", - "@types/inquirer": "^9.0.7", - "@types/node": "^24.10.1", - "@types/react": "^19.2.3", - "@types/react-dom": "^19.2.0", + "@types/hast": "^3.0.4", + "@types/node": "^20.11.24", + "@types/prompts": "^2.4.9", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", - "@types/tar": "^6.1.13", "@types/update-notifier": "^6.0.8", "@types/yargs": "^17.0.32", - "@types/prompts": "^2.4.9", - "eslint": "^8.55.0", "ink-testing-library": "^4.0.0", + "jsdom": "^26.1.0", "pretty-format": "^30.0.2", - "react-dom": "^19.2.0", - "typescript": "^5.3.0", - "vitest": "^1.0.0", - "tsx": "^4.15.5" + "react-dom": "^19.1.0", + "typescript": "5.9.3", + "vitest": "4.0.15" + }, + "_generated": { + "by": "airis gen", + "mode": "full", + "managed_fields": [ + "name", + "version", + "private", + "type", + "description", + "main", + "types", + "bin", + "exports", + "scripts", + "dependencies", + "devDependencies", + "peerDependencies", + "peerDependenciesMeta", + "files" + ], + "warning": "This file is fully generated by airis gen. Do not edit manually." } } diff --git a/apps/airiscode-cli/src/EnhancedApp.tsx b/apps/airiscode-cli/src/EnhancedApp.tsx deleted file mode 100644 index f23307de0..000000000 --- a/apps/airiscode-cli/src/EnhancedApp.tsx +++ /dev/null @@ -1,337 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import React, { useEffect, useCallback, useMemo } from "react"; -import { Box, Text, useApp } from "ink"; -import type { ContentGenerator } from "@airiscode/core-gemini"; -import { SessionProvider, useSession } from "./contexts/SessionContext.js"; -import { UIStateProvider, useUIState } from "./contexts/UIStateContext.js"; -import { MCPProvider, useMCP } from "./contexts/MCPContext.js"; -import { Composer } from "./components/Composer.js"; -import { MessageDisplay } from "./components/MessageDisplay.js"; -import { SessionStorage, type Message } from "./sessionStorage.js"; - -interface EnhancedAppProps { - contentGenerator: ContentGenerator; -} - -const EnhancedAppInner: React.FC<{ contentGenerator: ContentGenerator }> = ({ - contentGenerator, -}) => { - const { sessionId, messages, addMessage, clearMessages, stats } = useSession(); - const { state, setStreaming, setInputDisabled, setError } = useUIState(); - const { connected, tools, servers, connect, refreshTools, error: mcpError } = useMCP(); - const { exit } = useApp(); - - const sessionStorage = useMemo(() => new SessionStorage(sessionId), [sessionId]); - - // Load messages from session on mount - useEffect(() => { - const savedMessages = sessionStorage.loadMessages(); - savedMessages.forEach((msg) => addMessage(msg)); - }, [sessionStorage, addMessage]); - - // Save new messages - useEffect(() => { - if (messages.length === 0) return; - const lastMessage = messages[messages.length - 1]; - if (lastMessage) { - sessionStorage.appendMessage(lastMessage); - } - }, [messages, sessionStorage]); - - const handleSlashCommand = useCallback( - async (command: string) => { - const cmd = command.toLowerCase().trim(); - const parts = command.trim().split(/\s+/); - const mainCmd = parts[0].toLowerCase(); - - if (cmd === "/clear") { - clearMessages(); - setError(null); - setStreaming({ currentContent: "" }); - return true; - } - - if (cmd === "/help") { - const helpMessage: Message = { - role: "assistant", - content: `Available commands: -/clear - Clear conversation history -/help - Show this help message -/mcp [connect|status|tools] - MCP Gateway commands -/tools - List available MCP tools -/exit, /quit - Exit application - -Press Ctrl+C to exit`, - timestamp: Date.now(), - }; - addMessage(helpMessage); - return true; - } - - if (mainCmd === "/mcp") { - const subCmd = parts[1]?.toLowerCase(); - - if (subCmd === "connect") { - const url = parts[2] || "http://localhost:3000"; - try { - await connect(url); - addMessage({ - role: "assistant", - content: `✅ Connected to MCP Gateway at ${url}\n${tools.length} tools available`, - timestamp: Date.now(), - }); - } catch (err) { - addMessage({ - role: "assistant", - content: `❌ Failed to connect: ${err instanceof Error ? err.message : String(err)}`, - timestamp: Date.now(), - }); - } - return true; - } - - if (subCmd === "status") { - const status = connected ? "✅ Connected" : "❌ Not connected"; - const serverList = servers.map(s => ` - ${s.name}: ${s.enabled ? "enabled" : "disabled"} (${s.tools.length} tools)`).join("\n"); - addMessage({ - role: "assistant", - content: `MCP Gateway Status: ${status}\n\nServers:\n${serverList || " No servers"}`, - timestamp: Date.now(), - }); - return true; - } - - if (subCmd === "tools") { - if (!connected) { - addMessage({ - role: "assistant", - content: "❌ MCP Gateway not connected. Use /mcp connect first.", - timestamp: Date.now(), - }); - return true; - } - - try { - await refreshTools(); - const toolList = tools.map(t => ` - ${t.name}: ${t.description}`).join("\n"); - addMessage({ - role: "assistant", - content: `Available Tools (${tools.length}):\n${toolList || " No tools available"}`, - timestamp: Date.now(), - }); - } catch (err) { - addMessage({ - role: "assistant", - content: `❌ Failed to refresh tools: ${err instanceof Error ? err.message : String(err)}`, - timestamp: Date.now(), - }); - } - return true; - } - - addMessage({ - role: "assistant", - content: "Usage: /mcp [connect|status|tools]\n\nExamples:\n /mcp connect http://localhost:3000\n /mcp status\n /mcp tools", - timestamp: Date.now(), - }); - return true; - } - - if (mainCmd === "/tools") { - if (!connected) { - addMessage({ - role: "assistant", - content: "❌ MCP Gateway not connected. Use /mcp connect first.", - timestamp: Date.now(), - }); - return true; - } - - const toolList = tools.map(t => ` - ${t.name}: ${t.description}`).join("\n"); - addMessage({ - role: "assistant", - content: `Available Tools (${tools.length}):\n${toolList || " No tools available"}`, - timestamp: Date.now(), - }); - return true; - } - - if (cmd === "/exit" || cmd === "/quit") { - exit(); - return true; - } - - return false; - }, - [clearMessages, addMessage, exit, setError, setStreaming, connected, tools, servers, connect, refreshTools] - ); - - const handleSubmit = useCallback( - async (input: string) => { - // Handle slash commands - if (input.startsWith("/")) { - const handled = await handleSlashCommand(input); - if (handled) return; - - setError(`Unknown command: ${input}. Type /help for available commands.`); - return; - } - - setError(null); - setInputDisabled(true); - setStreaming({ isStreaming: true, currentContent: "" }); - - // Add user message - const userMessage: Message = { - role: "user", - content: input, - timestamp: Date.now(), - }; - addMessage(userMessage); - - // Build conversation context - const conversationMessages = [...messages, userMessage].map((msg) => ({ - role: msg.role, - content: msg.content, - })); - - // Stream response - let buffer = ""; - let fullResponse = ""; - let lastFlushTime = Date.now(); - const FLUSH_INTERVAL_MS = 50; - - const flushBuffer = () => { - if (buffer.length > 0) { - fullResponse += buffer; - setStreaming({ currentContent: fullResponse }); - buffer = ""; - lastFlushTime = Date.now(); - } - }; - - try { - const stream = await contentGenerator.generateContentStream({ - messages: conversationMessages, - }); - - for await (const chunk of stream) { - buffer += chunk.content; - - const now = Date.now(); - if (now - lastFlushTime >= FLUSH_INTERVAL_MS || buffer.length > 100) { - flushBuffer(); - } - } - - // Final flush - flushBuffer(); - - // Add assistant message - const assistantMessage: Message = { - role: "assistant", - content: fullResponse, - timestamp: Date.now(), - }; - addMessage(assistantMessage); - } catch (error) { - flushBuffer(); - const errorMsg = `Error: ${error instanceof Error ? error.message : String(error)}`; - setError(errorMsg); - - // Add error message - const assistantMessage: Message = { - role: "assistant", - content: fullResponse ? `${fullResponse}\n\n${errorMsg}` : errorMsg, - timestamp: Date.now(), - }; - addMessage(assistantMessage); - } finally { - setStreaming({ isStreaming: false, currentContent: "" }); - setInputDisabled(false); - } - }, - [ - messages, - addMessage, - contentGenerator, - handleSlashCommand, - setError, - setInputDisabled, - setStreaming, - ] - ); - - return ( - - {/* Header */} - - - AIRIS Code - - {messages.length > 0 && ( - ({Math.floor(messages.length / 2)} turns) - )} - {connected && ( - | MCP: {tools.length} tools - )} - | {sessionId} - - - {/* Error display */} - {state.error && ( - - ❌ {state.error} - - )} - - {/* Messages */} - - - - - {/* Composer */} - - - - - {/* Footer */} - - - {state.streaming.isStreaming ? "● Streaming..." : `Ready (${stats.messageCount} messages)`} - - - - ); -}; - -export const EnhancedApp: React.FC = ({ contentGenerator }) => { - const sessionId = useMemo(() => `session-${Date.now()}`, []); - - return ( - - - - - - - - ); -}; diff --git a/apps/airiscode-cli/src/MinimalApp.tsx b/apps/airiscode-cli/src/MinimalApp.tsx deleted file mode 100644 index 19949534d..000000000 --- a/apps/airiscode-cli/src/MinimalApp.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import React, { useState, useEffect, useMemo } from "react"; -import { Box, Text, useInput, useApp } from "ink"; -import type { ContentGenerator } from "@airiscode/core-gemini"; -import { SessionStorage, type Message } from "./sessionStorage.js"; - -interface MinimalAppProps { - contentGenerator: ContentGenerator; -} - -export const MinimalApp: React.FC = ({ contentGenerator }) => { - const [input, setInput] = useState(""); - const [messages, setMessages] = useState([]); - const [currentResponse, setCurrentResponse] = useState(""); - const [isStreaming, setIsStreaming] = useState(false); - const { exit } = useApp(); - - // Initialize session storage - const sessionStorage = useMemo(() => new SessionStorage(), []); - - // Load messages from session on mount - useEffect(() => { - const savedMessages = sessionStorage.loadMessages(); - if (savedMessages.length > 0) { - setMessages(savedMessages); - } - }, [sessionStorage]); - - // Save messages to session when they change - useEffect(() => { - // Skip initial load - if (messages.length === 0) return; - - // Save only the last message (newly added) - const lastMessage = messages[messages.length - 1]; - if (lastMessage) { - sessionStorage.appendMessage(lastMessage); - } - }, [messages, sessionStorage]); - - useInput((inputKey, keyInfo) => { - // Disable input during streaming - if (isStreaming) { - if (keyInfo.ctrl && inputKey === "c") { - exit(); - } - return; - } - - if (keyInfo.ctrl && inputKey === "c") { - exit(); - return; - } - - if (keyInfo.return) { - handleSubmit(); - return; - } - - if (keyInfo.backspace || keyInfo.delete) { - setInput((prev) => prev.slice(0, -1)); - return; - } - - if (typeof inputKey === "string" && inputKey.length === 1 && !keyInfo.ctrl && !keyInfo.meta) { - setInput((prev) => prev + inputKey); - } - }); - - const handleSubmit = async () => { - const userInput = input.trim(); - if (!userInput) return; - - setInput(""); - setCurrentResponse(""); - setIsStreaming(true); - - // Add user message to history - const userMessage: Message = { - role: "user", - content: userInput, - timestamp: Date.now(), - }; - setMessages((prev) => [...prev, userMessage]); - - // Build messages array for multi-turn conversation - const conversationMessages = [ - ...messages, - userMessage, - ].map((msg) => ({ - role: msg.role, - content: msg.content, - })); - - // Buffering strategy: accumulate chunks and flush periodically - let buffer = ""; - let fullResponse = ""; - let lastFlushTime = Date.now(); - const FLUSH_INTERVAL_MS = 50; - - const flushBuffer = () => { - if (buffer.length > 0) { - fullResponse += buffer; - setCurrentResponse(fullResponse); - buffer = ""; - lastFlushTime = Date.now(); - } - }; - - try { - const stream = await contentGenerator.generateContentStream({ - messages: conversationMessages, - }); - - for await (const chunk of stream) { - buffer += chunk.content; - - // Flush buffer every FLUSH_INTERVAL_MS or when buffer is large - const now = Date.now(); - if (now - lastFlushTime >= FLUSH_INTERVAL_MS || buffer.length > 100) { - flushBuffer(); - } - } - - // Final flush - flushBuffer(); - - // Add assistant message to history - const assistantMessage: Message = { - role: "assistant", - content: fullResponse, - timestamp: Date.now(), - }; - setMessages((prev) => [...prev, assistantMessage]); - setCurrentResponse(""); - } catch (error) { - // Preserve partial response on error - flushBuffer(); - const errorMsg = `\n\n❌ Error: ${error instanceof Error ? error.message : String(error)}`; - const finalResponse = fullResponse ? fullResponse + errorMsg : errorMsg.trim(); - - setCurrentResponse(finalResponse); - - // Add error message to history - const assistantMessage: Message = { - role: "assistant", - content: finalResponse, - timestamp: Date.now(), - }; - setMessages((prev) => [...prev, assistantMessage]); - } finally { - setIsStreaming(false); - } - }; - - // Show last 3 conversation turns (6 messages) - const recentMessages = messages.slice(-6); - - return ( - - - - AIRIS Code - Streaming Interface - - {messages.length > 0 && ( - ({Math.floor(messages.length / 2)} turns) - )} - | Session: {sessionStorage.getSessionId()} - - - {/* Conversation history */} - {recentMessages.length > 0 && ( - - {recentMessages.map((msg, idx) => ( - - - - {msg.role === "user" ? "You" : "Assistant"}:{" "} - - - - {msg.content} - - - ))} - - )} - - {/* Current streaming response */} - {currentResponse && ( - - - - Assistant:{" "} - - {isStreaming && ● Streaming...} - - - {currentResponse} - - - )} - - {/* Input prompt */} - - - >{" "} - - {input} - {!isStreaming && _} - - - - - {isStreaming - ? "Streaming in progress (Ctrl+C to exit)" - : "Type your message and press Enter (Ctrl+C to exit)"} - - - - ); -}; diff --git a/apps/airiscode-cli/src/commands/auth.ts b/apps/airiscode-cli/src/commands/auth.ts new file mode 100644 index 000000000..97444d3a9 --- /dev/null +++ b/apps/airiscode-cli/src/commands/auth.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule, Argv } from 'yargs'; +import { + handleQwenAuth, + runInteractiveAuth, + showAuthStatus, +} from './auth/handler.js'; +import { t } from '../i18n/index.js'; + +// Qwen OAuth subcommand removed - use coding-plan or interactive auth instead. + +const codePlanCommand = { + command: 'coding-plan', + describe: t('Authenticate using Alibaba Cloud Coding Plan'), + builder: (yargs: Argv) => + yargs + .option('region', { + alias: 'r', + describe: t('Region for Coding Plan (china/global)'), + type: 'string', + }) + .option('key', { + alias: 'k', + describe: t('API key for Coding Plan'), + type: 'string', + }), + handler: async (argv: { region?: string; key?: string }) => { + const region = argv['region'] as string | undefined; + const key = argv['key'] as string | undefined; + + // If region and key are provided, use them directly + if (region && key) { + await handleQwenAuth('coding-plan', { region, key }); + } else { + // Otherwise, prompt interactively + await handleQwenAuth('coding-plan', {}); + } + }, +}; + +const statusCommand = { + command: 'status', + describe: t('Show current authentication status'), + handler: async () => { + await showAuthStatus(); + }, +}; + +export const authCommand: CommandModule = { + command: 'auth', + describe: t( + 'Configure authentication information with Alibaba Cloud Coding Plan', + ), + builder: (yargs: Argv) => + yargs + .command(codePlanCommand) + .command(statusCommand) + .demandCommand(0) // Don't require a subcommand + .version(false), + handler: async () => { + // This handler is for when no subcommand is provided - show interactive menu + await runInteractiveAuth(); + }, +}; diff --git a/apps/airiscode-cli/src/commands/auth/handler.ts b/apps/airiscode-cli/src/commands/auth/handler.ts new file mode 100644 index 000000000..e38674e41 --- /dev/null +++ b/apps/airiscode-cli/src/commands/auth/handler.ts @@ -0,0 +1,448 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + getErrorMessage, + type Config, + type ProviderModelConfig as ModelConfig, +} from '@airiscode/core'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { t } from '../../i18n/index.js'; +import { + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, + CODING_PLAN_ENV_KEY, +} from '../../constants/codingPlan.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { backupSettingsFile } from '../../utils/settingsUtils.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import { loadCliConfig } from '../../config/config.js'; +import type { CliArgs } from '../../config/config.js'; +import { InteractiveSelector } from './interactiveSelector.js'; + +interface QwenAuthOptions { + region?: string; + key?: string; +} + +interface CodingPlanSettings { + region?: CodingPlanRegion; + version?: string; +} + +interface MergedSettingsWithCodingPlan { + security?: { + auth?: { + selectedType?: string; + }; + }; + codingPlan?: CodingPlanSettings; + model?: { + name?: string; + }; + modelProviders?: Record; + env?: Record; +} + +/** + * Handles the authentication process based on the specified command and options + */ +export async function handleQwenAuth( + command: 'coding-plan', + options: QwenAuthOptions, +) { + try { + const settings = loadSettings(); + + // Create a minimal argv for config loading + const minimalArgv: CliArgs = { + query: undefined, + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + yolo: undefined, + approvalMode: undefined, + telemetry: undefined, + checkpointing: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + acp: undefined, + experimentalAcp: undefined, + experimentalLsp: undefined, + extensions: [], + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + openaiLoggingDir: undefined, + proxy: undefined, + includeDirectories: undefined, + tavilyApiKey: undefined, + googleApiKey: undefined, + googleSearchEngineId: undefined, + webSearchDefault: undefined, + screenReader: undefined, + inputFormat: undefined, + outputFormat: undefined, + includePartialMessages: undefined, + chatRecording: undefined, + continue: undefined, + resume: undefined, + sessionId: undefined, + maxSessionTurns: undefined, + coreTools: undefined, + excludeTools: undefined, + authType: undefined, + channel: undefined, + systemPrompt: undefined, + appendSystemPrompt: undefined, + }; + + // Create a minimal config to access settings and storage + const config = await loadCliConfig( + settings.merged, + minimalArgv, + process.cwd(), + [], // No extensions for auth command + ); + + if (command === 'coding-plan') { + await handleCodePlanAuth(config, settings, options); + } + + // Exit after authentication is complete + writeStdoutLine(t('Authentication completed successfully.')); + process.exit(0); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +/** + * Handles Alibaba Cloud Coding Plan authentication + */ +async function handleCodePlanAuth( + config: Config, + settings: LoadedSettings, + options: QwenAuthOptions, +): Promise { + const { region, key } = options; + + let selectedRegion: CodingPlanRegion; + let selectedKey: string; + + // If region and key are provided as options, use them + if (region && key) { + selectedRegion = + region.toLowerCase() === 'global' + ? CodingPlanRegion.GLOBAL + : CodingPlanRegion.CHINA; + selectedKey = key; + } else { + // Otherwise, prompt interactively + selectedRegion = await promptForRegion(); + selectedKey = await promptForKey(); + } + + writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...')); + + try { + // Get configuration based on region + const { template, version } = getCodingPlanConfig(selectedRegion); + + // Get persist scope + const authTypeScope = getPersistScopeForModelSelection(settings); + + // Backup settings file before modification + const settingsFile = settings.forScope(authTypeScope); + backupSettingsFile(settingsFile.path); + + // Store api-key in settings.env (unified env key) + settings.setValue(authTypeScope, `env.${CODING_PLAN_ENV_KEY}`, selectedKey); + + // Sync to process.env immediately so refreshAuth can read the apiKey + process.env[CODING_PLAN_ENV_KEY] = selectedKey; + + // Generate model configs from template + const newConfigs = template.map((templateConfig) => ({ + ...templateConfig, + envKey: CODING_PLAN_ENV_KEY, + })); + + // Get existing configs + const existingConfigs = + (settings.merged.modelProviders as Record)?.[ + AuthType.USE_OPENAI + ] || []; + + // Filter out all existing Coding Plan configs (mutually exclusive) + const nonCodingPlanConfigs = existingConfigs.filter( + (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), + ); + + // Add new Coding Plan configs at the beginning + const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs]; + + // Persist to modelProviders + settings.setValue( + authTypeScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Also persist authType + settings.setValue( + authTypeScope, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + + // Persist coding plan region + settings.setValue(authTypeScope, 'codingPlan.region', selectedRegion); + + // Persist coding plan version (single field for backward compatibility) + settings.setValue(authTypeScope, 'codingPlan.version', version); + + // If there are configs, use the first one as the model + if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { + settings.setValue( + authTypeScope, + 'model.name', + (updatedConfigs[0] as ModelConfig).id, + ); + } + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + writeStdoutLine( + t('Successfully authenticated with Alibaba Cloud Coding Plan.'), + ); + } catch (error) { + writeStderrLine( + t('Failed to authenticate with Coding Plan: {{error}}', { + error: getErrorMessage(error), + }), + ); + process.exit(1); + } +} + +/** + * Prompts the user to select a region using an interactive selector + */ +async function promptForRegion(): Promise { + const selector = new InteractiveSelector( + [ + { + value: CodingPlanRegion.CHINA, + label: t('中国 (China)'), + description: t('阿里云百炼 (aliyun.com)'), + }, + { + value: CodingPlanRegion.GLOBAL, + label: t('Global'), + description: t('Alibaba Cloud (alibabacloud.com)'), + }, + ], + t('Select region for Coding Plan:'), + ); + + return await selector.select(); +} + +/** + * Prompts the user to enter an API key + */ +async function promptForKey(): Promise { + // Create a simple password-style input (without echoing characters) + const stdin = process.stdin; + const stdout = process.stdout; + + stdout.write(t('Enter your Coding Plan API key: ')); + + // Set raw mode to capture keystrokes + const wasRaw = stdin.isRaw; + if (stdin.setRawMode) { + stdin.setRawMode(true); + } + stdin.resume(); + + return new Promise((resolve, reject) => { + let input = ''; + + const onData = (chunk: string) => { + for (const char of chunk) { + switch (char) { + case '\r': // Enter + case '\n': + stdin.removeListener('data', onData); + if (stdin.setRawMode) { + stdin.setRawMode(wasRaw); + } + stdout.write('\n'); // New line after input + resolve(input); + return; + case '\x03': // Ctrl+C + stdin.removeListener('data', onData); + if (stdin.setRawMode) { + stdin.setRawMode(wasRaw); + } + stdout.write('^C\n'); + reject(new Error('Interrupted')); + return; + case '\x08': // Backspace + case '\x7F': // Delete + if (input.length > 0) { + input = input.slice(0, -1); + // Move cursor back, print space, move back again + stdout.write('\x1B[D \x1B[D'); + } + break; + default: + // Add character to input + input += char; + // Print asterisk instead of the actual character for security + stdout.write('*'); + break; + } + } + }; + + stdin.on('data', onData); + }); +} + +/** + * Runs the interactive authentication flow + */ +export async function runInteractiveAuth() { + const selector = new InteractiveSelector( + [ + { + value: 'coding-plan' as const, + label: t('Alibaba Cloud Coding Plan'), + description: t( + 'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models', + ), + }, + ], + t('Select authentication method:'), + ); + + const choice = await selector.select(); + + if (choice === 'coding-plan') { + await handleQwenAuth('coding-plan', {}); + } +} + +/** + * Shows the current authentication status + */ +export async function showAuthStatus(): Promise { + try { + const settings = loadSettings(); + const mergedSettings = settings.merged as MergedSettingsWithCodingPlan; + + writeStdoutLine(t('\n=== Authentication Status ===\n')); + + // Check for selected auth type + const selectedType = mergedSettings.security?.auth?.selectedType; + + if (!selectedType) { + writeStdoutLine(t('⚠️ No authentication method configured.\n')); + writeStdoutLine(t('Run one of the following commands to get started:\n')); + writeStdoutLine( + t( + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n', + ), + ); + writeStdoutLine(t('Or simply run:')); + writeStdoutLine( + t(' qwen auth - Interactive authentication setup\n'), + ); + process.exit(0); + } + + // Display status based on auth type + if (selectedType === AuthType.USE_OPENAI) { + // Check for Coding Plan configuration + const codingPlanRegion = mergedSettings.codingPlan?.region; + const codingPlanVersion = mergedSettings.codingPlan?.version; + const modelName = mergedSettings.model?.name; + + // Check if API key is set in environment + const hasApiKey = + !!process.env[CODING_PLAN_ENV_KEY] || + !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; + + if (hasApiKey) { + writeStdoutLine( + t('✓ Authentication Method: Alibaba Cloud Coding Plan'), + ); + + if (codingPlanRegion) { + const regionDisplay = + codingPlanRegion === CodingPlanRegion.CHINA + ? t('中国 (China) - 阿里云百炼') + : t('Global - Alibaba Cloud'); + writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay })); + } + + if (modelName) { + writeStdoutLine( + t(' Current Model: {{model}}', { model: modelName }), + ); + } + + if (codingPlanVersion) { + writeStdoutLine( + t(' Config Version: {{version}}', { + version: codingPlanVersion.substring(0, 8) + '...', + }), + ); + } + + writeStdoutLine(t(' Status: API key configured\n')); + } else { + writeStdoutLine( + t( + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', + ), + ); + writeStdoutLine( + t(' Issue: API key not found in environment or settings\n'), + ); + writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n')); + } + } else { + writeStdoutLine( + t('✓ Authentication Method: {{type}}', { type: selectedType }), + ); + writeStdoutLine(t(' Status: Configured\n')); + } + process.exit(0); + } catch (error) { + writeStderrLine( + t('Failed to check authentication status: {{error}}', { + error: getErrorMessage(error), + }), + ); + process.exit(1); + } +} diff --git a/apps/airiscode-cli/src/commands/auth/interactiveSelector.ts b/apps/airiscode-cli/src/commands/auth/interactiveSelector.ts new file mode 100644 index 000000000..84b9c9f0d --- /dev/null +++ b/apps/airiscode-cli/src/commands/auth/interactiveSelector.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { stdin, stdout } from 'node:process'; +import { t } from '../../i18n/index.js'; + +/** + * Represents an option in the interactive selector + */ +interface Option { + value: T; + label: string; + description?: string; +} + +/** + * Interactive selector that allows users to navigate with arrow keys + */ +export class InteractiveSelector { + private selectedIndex = 0; + private isListening = false; + + constructor( + private options: Array>, + private prompt: string = t('Select an option:'), + ) {} + + /** + * Shows the interactive menu and waits for user selection + */ + async select(): Promise { + return new Promise((resolve, reject) => { + this.isListening = true; + + // Display initial menu + this.renderMenu(); + + // Check if stdin supports raw mode + if (!stdin.setRawMode) { + // Fallback to readline if raw mode is not available (e.g., when piped) + reject( + new Error( + t('Raw mode not available. Please run in an interactive terminal.'), + ), + ); + return; + } + + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + const onData = (chunk: string) => { + if (!this.isListening) return; + + for (const char of chunk) { + switch (char) { + case '\x03': // Ctrl+C + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + reject(new Error('Interrupted')); + return; + case '\r': // Enter + case '\n': // Newline + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + resolve(this.options[this.selectedIndex].value); + return; + case '\x1B': // ESC sequence + // Next character will be [, then A, B, C, or D + break; + default: + // Handle other characters if needed + break; + } + } + + // Handle escape sequences + if (chunk.startsWith('\x1B')) { + if (chunk === '\x1B[A') { + // Arrow up + this.moveUp(); + } else if (chunk === '\x1B[B') { + // Arrow down + this.moveDown(); + } else if (chunk === '\x1B[C') { + // Arrow right + // Do nothing for now + } else if (chunk === '\x1B[D') { + // Arrow left + // Do nothing for now + } + } + }; + + stdin.on('data', onData); + }); + } + + /** + * Renders the menu to stdout + */ + private renderMenu(): void { + // Calculate how many lines we need to clear + const totalLines = this.calculateTotalLines(); + + // Clear the screen area we'll be using + if (totalLines > 0) { + stdout.write(`\x1B[${totalLines}A\x1B[J`); // Move up and clear from cursor down + } + + // Write the prompt + stdout.write(`${this.prompt}\n\n`); + + // Write each option - combine label and description on same line + this.options.forEach((option, index) => { + const isSelected = index === this.selectedIndex; + const indicator = isSelected ? '> ' : ' '; + const color = isSelected ? '\x1B[36m' : '\x1B[0m'; // Cyan for selected, default for others + const reset = '\x1B[0m'; + + // Combine label and description in one line + let line = `${indicator}${color}${option.label}`; + if (option.description) { + line += ` - ${option.description}`; + } + line += `${reset}\n`; + + stdout.write(line); + }); + + // Add instructions + stdout.write( + `\n${t('(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n')}`, + ); + } + + /** + * Calculates the total number of lines to clear + */ + private calculateTotalLines(): number { + // Lines for: prompt (1) + empty line (1) + options (each option takes 1 line) + empty line (1) + instructions (1) + return 4 + this.options.length; + } + + /** + * Moves selection up + */ + private moveUp(): void { + this.selectedIndex = + (this.selectedIndex - 1 + this.options.length) % this.options.length; + this.renderMenu(); + } + + /** + * Moves selection down + */ + private moveDown(): void { + this.selectedIndex = (this.selectedIndex + 1) % this.options.length; + this.renderMenu(); + } +} diff --git a/apps/airiscode-cli/src/commands/channel.ts b/apps/airiscode-cli/src/commands/channel.ts new file mode 100644 index 000000000..f54249a36 --- /dev/null +++ b/apps/airiscode-cli/src/commands/channel.ts @@ -0,0 +1,6 @@ +// Channel commands removed - not needed for AIRIS Code +export const channelCommand = { + command: 'channel', + describe: 'Channel commands (not available)', + handler: () => { console.log('Channel commands are not available in AIRIS Code'); }, +}; diff --git a/apps/airiscode-cli/src/commands/chat.ts b/apps/airiscode-cli/src/commands/chat.ts deleted file mode 100644 index bb1b822f4..000000000 --- a/apps/airiscode-cli/src/commands/chat.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Chat command - Interactive chat interface with Ollama - */ - -import { Command } from 'commander'; -import React from 'react'; -import { render } from 'ink'; -import { randomUUID } from 'node:crypto'; -import { ChatApp } from '../ui/ChatApp.js'; -import { getModelForRole } from '../config/models.js'; -import { MCPSessionManager } from '@airiscode/mcp-session'; -import chalk from 'chalk'; - -export function createChatCommand(): Command { - const cmd = new Command('chat'); - - cmd - .description('Start interactive chat mode with Ollama (Claude Code-style interface)') - .option('--cwd ', 'Working directory', process.cwd()) - .option('-m, --model ', 'Ollama model to use', 'qwen2.5:3b') - .option('--preset ', 'Model preset: premium/balanced/fast/dev', 'dev') - .option('--role ', 'Model role: planner/implementer/reviewer/tester', 'implementer') - .option('--ollama-url ', 'Ollama server URL', 'http://localhost:11434') - .option('--mcp-gateway ', 'MCP Gateway URL', process.env.MCP_GATEWAY_URL || 'http://localhost:3000') - .option('--no-mcp', 'Disable MCP tools') - .action(async (options) => { - await executeChatCommand(options); - }); - - return cmd; -} - -export async function executeChatCommand(options: { - cwd?: string; - model?: string; - preset?: string; - role?: string; - ollamaUrl?: string; - mcpGateway?: string; - mcp?: boolean; -}): Promise { - const sessionId = randomUUID(); - const workingDir = options.cwd || process.cwd(); - - // Determine model to use - let model = options.model; - if (!model && options.preset && options.role) { - model = getModelForRole( - options.role as 'planner' | 'implementer' | 'reviewer' | 'tester', - options.preset - ); - } - model = model || 'qwen2.5:3b'; // Fallback to available model - - console.log(`Starting chat with model: ${model} (role: ${options.role || 'implementer'})`); - - // Initialize MCP session if enabled - let mcpSession: MCPSessionManager | undefined; - if (options.mcp !== false) { - try { - console.log(chalk.blue('Initializing MCP Gateway...')); - mcpSession = new MCPSessionManager({ - sessionId, - baseURL: options.mcpGateway || 'http://localhost:3000', - apiKey: process.env.MCP_API_KEY, - timeout: 30000, - }); - - await mcpSession.initialize(); - const tools = mcpSession.getAllTools(); - console.log(chalk.green(`✓ MCP Gateway connected (${tools.length} tools available)`)); - } catch (error) { - console.log( - chalk.yellow( - `⚠ MCP Gateway not available: ${error instanceof Error ? error.message : String(error)}` - ) - ); - console.log(chalk.gray('Continuing without MCP tools...')); - mcpSession = undefined; - } - } - - // Render the interactive chat UI - const { waitUntilExit } = render( - React.createElement(ChatApp, { - sessionId, - workingDir, - model, - ollamaUrl: options.ollamaUrl || 'http://localhost:11434', - mcpSession, - }) - ); - - try { - await waitUntilExit(); - } finally { - // Cleanup MCP session - if (mcpSession) { - await mcpSession.cleanup(); - console.log(chalk.gray('MCP session cleaned up')); - } - } -} diff --git a/apps/airiscode-cli/src/commands/code-tui.ts.skip b/apps/airiscode-cli/src/commands/code-tui.ts.skip deleted file mode 100644 index 2a5252fc2..000000000 --- a/apps/airiscode-cli/src/commands/code-tui.ts.skip +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Code command TUI integration - * - * Integrates the Ink-based TUI with the code command workflow. - */ - -import { render } from 'ink'; -import React from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { App } from '../ui/App.js'; -import { EventEmitter } from '../events/emitter.js'; - -export interface TUIOptions { - sessionId: string; - task: string; - debug?: boolean; -} - -export async function renderTUI(options: TUIOptions): Promise { - const emitter = new EventEmitter('tui', { - onInfo: (summary) => { - if (options.debug) { - console.log(`[TUI] ${summary}`); - } - }, - }); - - const { waitUntilExit } = render( - React.createElement(App, { - sessionId: options.sessionId, - task: options.task, - emitter, - }) - ); - - await waitUntilExit(); -} diff --git a/apps/airiscode-cli/src/commands/code.ts b/apps/airiscode-cli/src/commands/code.ts deleted file mode 100644 index 1abd2b188..000000000 --- a/apps/airiscode-cli/src/commands/code.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Code command - Execute coding task with AI - */ - -import { Command } from 'commander'; -import chalk from 'chalk'; -import ora from 'ora'; -import { SessionManager } from '../session/session-manager.js'; -import { config } from '../utils/config.js'; -// import { ClaudeCodeAdapter } from '@airiscode/adapters-claude-code'; -import { ApprovalsLevel, TrustLevel } from '@airiscode/policies'; -import type { CodeCommandOptions } from '../types.js'; - -export function createCodeCommand(): Command { - const cmd = new Command('code'); - - cmd - .description('Execute a coding task with AI assistance') - .argument('', 'Task description') - .option('-d, --driver ', 'Driver to use (ollama, openai)', config.get('defaultDriver')) - .option('-a, --adapter ', 'Adapter to use (claude-code, cursor)', config.get('defaultAdapter')) - .option('-p, --policy ', 'Policy level (restricted, sandboxed, untrusted)', 'sandboxed') - .option('--cwd ', 'Working directory', process.cwd()) - .option('-s, --session ', 'Session name') - .option('--json', 'Output in JSON format') - .option('-v, --verbose', 'Verbose output') - .action(async (task: string, options: CodeCommandOptions) => { - await executeCodeCommand(task, options); - }); - - return cmd; -} - -export async function executeCodeCommand(task: string, options: CodeCommandOptions): Promise { - const spinner = options.json ? null : ora('Initializing...').start(); - - try { - // Create session - const sessionManager = new SessionManager(); - - const policyMap = { - restricted: { approvals: ApprovalsLevel.NEVER, trust: TrustLevel.RESTRICTED, guardStrict: true }, - sandboxed: { approvals: ApprovalsLevel.ON_REQUEST, trust: TrustLevel.SANDBOXED, guardStrict: true }, - untrusted: { approvals: ApprovalsLevel.ON_FAILURE, trust: TrustLevel.UNTRUSTED, guardStrict: false }, - }; - - const policy = policyMap[options.policy as keyof typeof policyMap] || config.get('defaultPolicy'); - - const session = sessionManager.createSession({ - name: options.session, - workingDir: options.cwd || process.cwd(), - driver: options.driver || config.get('defaultDriver'), - adapter: options.adapter || config.get('defaultAdapter'), - policy, - }); - - if (options.verbose) { - console.log(chalk.blue('Session created:'), session.id); - console.log(chalk.blue('Driver:'), session.driver); - console.log(chalk.blue('Adapter:'), session.adapter); - console.log(chalk.blue('Policy:'), JSON.stringify(session.policy, null, 2)); - } - - if (spinner) { - spinner.succeed(chalk.green('Session created successfully')); - } - - console.log(chalk.blue('\n📋 Session Information:')); - console.log(chalk.gray(' ID:'), session.id); - console.log(chalk.gray(' Task:'), task); - console.log(chalk.gray(' Working Directory:'), session.workingDir); - console.log(chalk.gray(' Driver:'), session.driver); - console.log(chalk.gray(' Adapter:'), session.adapter); - console.log(chalk.gray(' Policy:'), options.policy || 'sandboxed'); - - console.log(chalk.yellow('\n⚠️ Note: Adapter integration is temporarily disabled.')); - console.log(chalk.gray(' This is a pre-release version for testing CLI functionality.')); - console.log(chalk.gray(' Full adapter support will be available in the next release.')); - - sessionManager.incrementTaskCount(); - sessionManager.addLog({ - level: 'info', - source: 'system', - message: 'Session created (adapters disabled)', - }); - - const result = { outputJson: '{}', proposedShell: [] }; - - // Output result - if (options.json) { - console.log(JSON.stringify({ - sessionId: session.id, - status: 'success', - result: JSON.parse(result.outputJson), - proposedShell: result.proposedShell, - }, null, 2)); - } else { - console.log(chalk.green('\n Task completed successfully\n')); - console.log(chalk.bold('Result:')); - const output = JSON.parse(result.outputJson); - console.log(output); - - if (result.proposedShell && result.proposedShell.length > 0) { - console.log(chalk.yellow('\nProposed shell commands:')); - result.proposedShell.forEach((cmd: string, index: number) => { - console.log(chalk.gray(` ${index + 1}. ${cmd}`)); - }); - } - } - - // Update session status - sessionManager.updateStatus('completed'); - - // Adapter termination skipped (adapters disabled) - - } catch (error) { - if (spinner) { - spinner.fail('Task failed'); - } - - if (options.json) { - console.error(JSON.stringify({ - status: 'error', - error: error instanceof Error ? error.message : String(error), - }, null, 2)); - } else { - console.error(chalk.red('\n Task failed')); - console.error(chalk.red(error instanceof Error ? error.message : String(error))); - } - - process.exit(1); - } -} diff --git a/apps/airiscode-cli/src/commands/config.ts b/apps/airiscode-cli/src/commands/config.ts deleted file mode 100644 index 3674af728..000000000 --- a/apps/airiscode-cli/src/commands/config.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Config command - Manage AIRIS Code configuration - */ - -import { Command } from 'commander'; -import chalk from 'chalk'; -import { config } from '../utils/config.js'; -import type { ConfigCommandOptions } from '../types.js'; - -export function createConfigCommand(): Command { - const cmd = new Command('config'); - - cmd - .description('Manage AIRIS Code configuration') - .option('-g, --get ', 'Get configuration value') - .option('-s, --set ', 'Set configuration value') - .option('-l, --list', 'List all configuration') - .option('-r, --reset', 'Reset to default configuration') - .action(async (options: ConfigCommandOptions) => { - await executeConfigCommand(options); - }); - - return cmd; -} - -async function executeConfigCommand(options: ConfigCommandOptions): Promise { - try { - if (options.list) { - // List all configuration - const allConfig = config.getAll(); - console.log(chalk.bold('Current Configuration:\n')); - console.log(JSON.stringify(allConfig, null, 2)); - console.log(chalk.gray(`\nConfig file: ${config.getPath()}`)); - return; - } - - if (options.get) { - // Get specific value - const value = config.get(options.get as any); - if (value === undefined) { - console.error(chalk.red(`Unknown config key: ${options.get}`)); - process.exit(1); - } - console.log(JSON.stringify(value, null, 2)); - return; - } - - if (options.set) { - // Set value - const [key, ...valueParts] = options.set.split('='); - const value = valueParts.join('='); - - if (!key || !value) { - console.error(chalk.red('Invalid format. Use: --set key=value')); - process.exit(1); - } - - // Parse value (JSON or string) - let parsedValue: any; - try { - parsedValue = JSON.parse(value); - } catch { - parsedValue = value; - } - - config.set(key as any, parsedValue); - console.log(chalk.green(` Set ${key} = ${JSON.stringify(parsedValue)}`)); - return; - } - - if (options.reset) { - // Reset to defaults - config.reset(); - console.log(chalk.green(' Configuration reset to defaults')); - return; - } - - // No options provided, show help - console.log(chalk.yellow('No options provided. Use --help for usage information.')); - - } catch (error) { - console.error(chalk.red('Config command failed:')); - console.error(chalk.red(error instanceof Error ? error.message : String(error))); - process.exit(1); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions.tsx b/apps/airiscode-cli/src/commands/extensions.tsx similarity index 87% rename from apps/airiscode-cli/src/gemini-base/commands/extensions.tsx rename to apps/airiscode-cli/src/commands/extensions.tsx index 42516dcea..d02aec31f 100644 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions.tsx +++ b/apps/airiscode-cli/src/commands/extensions.tsx @@ -13,12 +13,11 @@ import { disableCommand } from './extensions/disable.js'; import { enableCommand } from './extensions/enable.js'; import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; -import { validateCommand } from './extensions/validate.js'; +import { settingsCommand } from './extensions/settings.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', - aliases: ['extension'], - describe: 'Manage Gemini CLI extensions.', + describe: 'Manage AIRIS Code extensions.', builder: (yargs) => yargs .command(installCommand) @@ -29,7 +28,7 @@ export const extensionsCommand: CommandModule = { .command(enableCommand) .command(linkCommand) .command(newCommand) - .command(validateCommand) + .command(settingsCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/apps/airiscode-cli/src/commands/extensions/consent.ts b/apps/airiscode-cli/src/commands/extensions/consent.ts new file mode 100644 index 000000000..262422ea6 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/consent.ts @@ -0,0 +1,269 @@ +import type { + ClaudeMarketplaceConfig, + ExtensionConfig, + ExtensionRequestOptions, + SkillConfig, + SubagentConfig, +} from '@airiscode/core'; +import type { ConfirmationRequest } from '../../ui/types.js'; +import chalk from 'chalk'; +import prompts from 'prompts'; +import { t } from '../../i18n/index.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; + +/** + * Requests consent from the user to perform an action, by reading a Y/n + * character from stdin. + * + * This should not be called from interactive mode as it will break the CLI. + * + * @param consentDescription The description of the thing they will be consenting to. + * @returns boolean, whether they consented or not. + */ +export async function requestConsentNonInteractive( + consentDescription: string, +): Promise { + writeStdoutLine(consentDescription); + const result = await promptForConsentNonInteractive( + t('Do you want to continue? [Y/n]: '), + ); + return result; +} + +/** + * Requests plugin selection from the user in non-interactive mode. + * Displays an interactive list with arrow key navigation. + * + * This should not be called from interactive mode as it will break the CLI. + * + * @param marketplace The marketplace config containing available plugins. + * @returns The name of the selected plugin. + */ +export async function requestChoicePluginNonInteractive( + marketplace: ClaudeMarketplaceConfig, +): Promise { + const plugins = marketplace.plugins; + + if (plugins.length === 0) { + throw new Error(t('No plugins available in this marketplace.')); + } + + // Build choices for prompts select + + const choices = plugins.map((plugin) => ({ + title: chalk.green(chalk.bold(`[${plugin.name}]`)), + value: plugin.name, + })); + + const response = await prompts({ + type: 'select', + name: 'plugin', + message: t('Select a plugin to install from marketplace "{{name}}":', { + name: marketplace.name, + }), + choices, + initial: 0, + }); + + // Handle cancellation (Ctrl+C) + if (response.plugin === undefined) { + throw new Error(t('Plugin selection cancelled.')); + } + + return response.plugin; +} + +/** + * Requests consent from the user to perform an action, in interactive mode. + * + * This should not be called from non-interactive mode as it will not work. + * + * @param consentDescription The description of the thing they will be consenting to. + * @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI. + * @returns boolean, whether they consented or not. + */ +export async function requestConsentInteractive( + consentDescription: string, + addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, +): Promise { + return promptForConsentInteractive( + consentDescription + '\n\n' + t('Do you want to continue?'), + addExtensionUpdateConfirmationRequest, + ); +} + +/** + * Asks users a prompt and awaits for a y/n response on stdin. + * + * This should not be called from interactive mode as it will break the CLI. + * + * @param prompt A yes/no prompt to ask the user + * @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter. + */ +async function promptForConsentNonInteractive( + prompt: string, +): Promise { + const readline = await import('node:readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + resolve(['y', ''].includes(answer.trim().toLowerCase())); + }); + }); +} + +/** + * Asks users an interactive yes/no prompt. + * + * This should not be called from non-interactive mode as it will break the CLI. + * + * @param prompt A markdown prompt to ask the user + * @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request. + * @returns Whether or not the user answers yes. + */ +async function promptForConsentInteractive( + prompt: string, + addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, +): Promise { + return new Promise((resolve) => { + addExtensionUpdateConfirmationRequest({ + prompt, + onConfirm: (resolvedConfirmed) => { + resolve(resolvedConfirmed); + }, + }); + }); +} + +/** + * Builds a consent string for installing an extension based on it's + * extensionConfig. + */ +export function extensionConsentString( + extensionConfig: ExtensionConfig, + commands: string[] = [], + skills: SkillConfig[] = [], + subagents: SubagentConfig[] = [], + originSource: string = 'AirisCode', +): string { + const output: string[] = []; + if (originSource !== 'AirisCode') { + output.push( + t( + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with AIRIS Code.', + { originSource }, + ), + ); + } + const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); + output.push( + t('Installing extension "{{name}}".', { name: extensionConfig.name }), + ); + output.push( + t( + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**', + ), + ); + + if (mcpServerEntries.length) { + output.push(t('This extension will run the following MCP servers:')); + for (const [key, mcpServer] of mcpServerEntries) { + const isLocal = !!mcpServer.command; + const source = + mcpServer.httpUrl ?? + `${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`; + output.push( + ` * ${key} (${isLocal ? t('local') : t('remote')}): ${source}`, + ); + } + } + if (commands && commands.length > 0) { + output.push( + t('This extension will add the following commands: {{commands}}.', { + commands: commands.join(', '), + }), + ); + } + if (extensionConfig.contextFileName) { + const fileName = Array.isArray(extensionConfig.contextFileName) + ? extensionConfig.contextFileName.join(', ') + : extensionConfig.contextFileName; + output.push( + t( + 'This extension will append info to your AIRISCODE.md context using {{fileName}}', + { fileName }, + ), + ); + } + if (skills.length > 0) { + output.push(t('This extension will install the following skills:')); + for (const skill of skills) { + output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`); + } + } + if (subagents.length > 0) { + output.push(t('This extension will install the following subagents:')); + for (const subagent of subagents) { + output.push(` * ${chalk.bold(subagent.name)}: ${subagent.description}`); + } + } + return output.join('\n'); +} + +/** + * Requests consent from the user to install an extension (extensionConfig), if + * there is any difference between the consent string for `extensionConfig` and + * `previousExtensionConfig`. + * + * Always requests consent if previousExtensionConfig is null. + * + * Throws if the user does not consent. + */ +export const requestConsentOrFail = async ( + requestConsent: (consent: string) => Promise, + options?: ExtensionRequestOptions, +) => { + if (!options) return; + const { + extensionConfig, + originSource = 'AirisCode', + commands = [], + skills = [], + subagents = [], + previousExtensionConfig, + previousCommands = [], + previousSkills = [], + previousSubagents = [], + } = options; + const extensionConsent = extensionConsentString( + extensionConfig, + commands, + skills, + subagents, + originSource, + ); + if (previousExtensionConfig) { + const previousExtensionConsent = extensionConsentString( + previousExtensionConfig, + previousCommands, + previousSkills, + previousSubagents, + originSource, + ); + if (previousExtensionConsent === extensionConsent) { + return; + } + } + if (!(await requestConsent(extensionConsent))) { + throw new Error( + t('Installation cancelled for "{{name}}".', { + name: extensionConfig.name, + }), + ); + } +}; diff --git a/apps/airiscode-cli/src/commands/extensions/disable.ts b/apps/airiscode-cli/src/commands/extensions/disable.ts new file mode 100644 index 000000000..f13e3f550 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/disable.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { SettingScope } from '../../config/settings.js'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { getExtensionManager } from './utils.js'; +import { t } from '../../i18n/index.js'; + +interface DisableArgs { + name: string; + scope?: string; +} + +export async function handleDisable(args: DisableArgs) { + const extensionManager = await getExtensionManager(); + try { + if (args.scope?.toLowerCase() === 'workspace') { + extensionManager.disableExtension(args.name, SettingScope.Workspace); + } else { + extensionManager.disableExtension(args.name, SettingScope.User); + } + writeStdoutLine( + t('Extension "{{name}}" successfully disabled for scope "{{scope}}".', { + name: args.name, + scope: args.scope || SettingScope.User, + }), + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export const disableCommand: CommandModule = { + command: 'disable [--scope] ', + describe: t('Disables an extension.'), + builder: (yargs) => + yargs + .positional('name', { + describe: t('The name of the extension to disable.'), + type: 'string', + }) + .option('scope', { + describe: t('The scope to disable the extenison in.'), + type: 'string', + default: SettingScope.User, + }) + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes((argv.scope as string).toLowerCase()) + ) { + throw new Error( + t('Invalid scope: {{scope}}. Please use one of {{scopes}}.', { + scope: argv.scope as string, + scopes: Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .join(', '), + }), + ); + } + return true; + }), + handler: async (argv) => { + await handleDisable({ + name: argv['name'] as string, + scope: argv['scope'] as string, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/enable.ts b/apps/airiscode-cli/src/commands/extensions/enable.ts new file mode 100644 index 000000000..33e8bfba9 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/enable.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { FatalConfigError, getErrorMessage } from '@airiscode/core'; +import { SettingScope } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { getExtensionManager } from './utils.js'; +import { t } from '../../i18n/index.js'; + +interface EnableArgs { + name: string; + scope?: string; +} + +export async function handleEnable(args: EnableArgs) { + const extensionManager = await getExtensionManager(); + + try { + if (args.scope?.toLowerCase() === 'workspace') { + extensionManager.enableExtension(args.name, SettingScope.Workspace); + } else { + extensionManager.enableExtension(args.name, SettingScope.User); + } + if (args.scope) { + writeStdoutLine( + t('Extension "{{name}}" successfully enabled for scope "{{scope}}".', { + name: args.name, + scope: args.scope, + }), + ); + } else { + writeStdoutLine( + t('Extension "{{name}}" successfully enabled in all scopes.', { + name: args.name, + }), + ); + } + } catch (error) { + throw new FatalConfigError(getErrorMessage(error)); + } +} + +export const enableCommand: CommandModule = { + command: 'enable [--scope] ', + describe: t('Enables an extension.'), + builder: (yargs) => + yargs + .positional('name', { + describe: t('The name of the extension to enable.'), + type: 'string', + }) + .option('scope', { + describe: t( + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.', + ), + type: 'string', + }) + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes((argv.scope as string).toLowerCase()) + ) { + throw new Error( + t('Invalid scope: {{scope}}. Please use one of {{scopes}}.', { + scope: argv.scope as string, + scopes: Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .join(', '), + }), + ); + } + return true; + }), + handler: async (argv) => { + await handleEnable({ + name: argv['name'] as string, + scope: argv['scope'] as string, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/examples/agent/agents/diary.md b/apps/airiscode-cli/src/commands/extensions/examples/agent/agents/diary.md new file mode 100644 index 000000000..8c0c76a91 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/agent/agents/diary.md @@ -0,0 +1,87 @@ +--- +name: diary-writer +description: generate a diary for user +color: yellow +tools: + - Glob + - Grep + - ListFiles + - ReadFile + - ReadManyFiles + - NotebookRead + - WebFetch + - TodoWrite + - WebSearch +modelConfig: + model: qwen3-coder-plus +--- + +You are a personal diary writing assistant who helps users capture their daily experiences, thoughts, and reflections in meaningful journal entries. + +## Core Mission + +Help users create thoughtful, well-structured diary entries that preserve their memories, emotions, and personal growth moments. + +## Writing Style + +**Tone & Voice** + +- Warm, personal, and authentic +- Reflective and introspective +- Supportive without being overly sentimental +- Adapt to user's preferred style (casual, formal, poetic, etc.) + +**Structure Options** + +- Free-form narrative +- Bullet-point highlights +- Gratitude-focused entries +- Goal and achievement tracking +- Emotional processing format + +## Capabilities + +**1. Daily Entry Creation** + +- Transform user's brief notes into full diary entries +- Expand on key moments with descriptive details +- Add context about weather, mood, or setting when relevant +- Include meaningful quotes or observations + +**2. Reflection Prompts** + +- Ask thoughtful questions to deepen entries +- Suggest areas worth exploring further +- Help identify patterns in thoughts and behaviors +- Encourage gratitude and positive reflection + +**3. Memory Enhancement** + +- Help recall specific details from the day +- Connect current events to past experiences +- Highlight personal growth and progress +- Preserve important conversations or interactions + +**4. Organization** + +- Suggest tags or themes for entries +- Create summaries for weekly/monthly reviews +- Track recurring topics or goals +- Maintain consistency in formatting + +## Guidelines + +- **Privacy First**: Treat all content as deeply personal and confidential +- **User's Voice**: Write in a way that sounds like the user, not generic +- **No Judgment**: Accept all emotions and experiences without criticism +- **Encourage Honesty**: Create a safe space for authentic expression +- **Balance**: Mix facts with feelings, events with reflections + +## Output Format + +When creating a diary entry, include: + +1. **Date & Title** (optional creative title) +2. **Main Content** - The narrative or bullet points +3. **Reflection** - A brief closing thought or takeaway +4. **Tags** (optional) - For organization and future reference diff --git a/apps/airiscode-cli/src/commands/extensions/examples/agent/qwen-extension.json b/apps/airiscode-cli/src/commands/extensions/examples/agent/qwen-extension.json new file mode 100644 index 000000000..a9a8e8a68 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/agent/qwen-extension.json @@ -0,0 +1,4 @@ +{ + "name": "agent-example", + "version": "1.0.0" +} diff --git a/apps/airiscode-cli/src/commands/extensions/examples/commands/commands/fs/grep-code.md b/apps/airiscode-cli/src/commands/extensions/examples/commands/commands/fs/grep-code.md new file mode 100644 index 000000000..cb57c52de --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/commands/commands/fs/grep-code.md @@ -0,0 +1,3 @@ +Please summarize the findings for the pattern `{{args}}`. + +Search Results: diff --git a/apps/airiscode-cli/src/commands/extensions/examples/commands/qwen-extension.json b/apps/airiscode-cli/src/commands/extensions/examples/commands/qwen-extension.json new file mode 100644 index 000000000..277a40548 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/commands/qwen-extension.json @@ -0,0 +1,4 @@ +{ + "name": "commands-example", + "version": "1.0.0" +} diff --git a/apps/airiscode-cli/src/commands/extensions/examples/context/QWEN.md b/apps/airiscode-cli/src/commands/extensions/examples/context/QWEN.md new file mode 100644 index 000000000..22f6bbce5 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/context/QWEN.md @@ -0,0 +1,8 @@ +# Ink Library Screen Reader Guidance + +When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. + +## General Principles + +Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. +Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on and to provide semantic meaning to screen readers. diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/context/gemini-extension.json b/apps/airiscode-cli/src/commands/extensions/examples/context/qwen-extension.json similarity index 100% rename from apps/airiscode-cli/src/gemini-base/commands/extensions/examples/context/gemini-extension.json rename to apps/airiscode-cli/src/commands/extensions/examples/context/qwen-extension.json diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/example.ts b/apps/airiscode-cli/src/commands/extensions/examples/mcp-server/example.ts similarity index 100% rename from apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/example.ts rename to apps/airiscode-cli/src/commands/extensions/examples/mcp-server/example.ts diff --git a/apps/airiscode-cli/src/commands/extensions/examples/mcp-server/package.json b/apps/airiscode-cli/src/commands/extensions/examples/mcp-server/package.json new file mode 100644 index 000000000..59c1c45c1 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/mcp-server/package.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-server-example", + "version": "1.0.0", + "description": "Example MCP Server for Qwen Code Extension", + "type": "module", + "main": "example.js", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "~5.4.5", + "@types/node": "^20.11.25" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.0", + "zod": "^3.22.4" + } +} diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/gemini-extension.json b/apps/airiscode-cli/src/commands/extensions/examples/mcp-server/qwen-extension.json similarity index 100% rename from apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/gemini-extension.json rename to apps/airiscode-cli/src/commands/extensions/examples/mcp-server/qwen-extension.json diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/tsconfig.json b/apps/airiscode-cli/src/commands/extensions/examples/mcp-server/tsconfig.json similarity index 100% rename from apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/tsconfig.json rename to apps/airiscode-cli/src/commands/extensions/examples/mcp-server/tsconfig.json diff --git a/apps/airiscode-cli/src/commands/extensions/examples/skills/qwen-extension.json b/apps/airiscode-cli/src/commands/extensions/examples/skills/qwen-extension.json new file mode 100644 index 000000000..2674ef9e0 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/skills/qwen-extension.json @@ -0,0 +1,4 @@ +{ + "name": "skills-example", + "version": "1.0.0" +} diff --git a/apps/airiscode-cli/src/commands/extensions/examples/skills/skills/synonyms/SKILL.md b/apps/airiscode-cli/src/commands/extensions/examples/skills/skills/synonyms/SKILL.md new file mode 100644 index 000000000..ed2878771 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/examples/skills/skills/synonyms/SKILL.md @@ -0,0 +1,48 @@ +--- +name: synonyms +description: Generate synonyms for words or phrases. Use this skill when the user needs alternative words with similar meanings, wants to expand vocabulary, or seeks varied expressions for writing. +license: Complete terms in LICENSE.txt +--- + +This skill helps generate synonyms and alternative expressions for given words or phrases. It provides contextually appropriate alternatives to enhance vocabulary and improve writing variety. + +The user provides a word, phrase, or sentence where they need synonym suggestions. They may specify the context, tone, or formality level desired. + +## Synonym Generation Guidelines + +When generating synonyms, consider: + +- **Context**: The specific domain or situation where the word will be used +- **Tone**: Formal, informal, neutral, academic, conversational, etc. +- **Nuance**: Subtle differences in meaning between similar words +- **Register**: Appropriate level of formality for the intended audience + +## Output Format + +For each input word or phrase, provide: + +1. **Direct Synonyms**: Words with nearly identical meanings +2. **Related Alternatives**: Words with similar but slightly different connotations +3. **Context Examples**: Brief usage examples when helpful + +## Best Practices + +- Prioritize commonly used synonyms over obscure alternatives +- Note any subtle differences in meaning or usage +- Consider regional variations when relevant +- Indicate formality levels (formal/informal/neutral) +- Provide multiple options to give users choices + +## Example + +**Input**: "happy" + +**Synonyms**: + +- **Direct**: joyful, cheerful, delighted, pleased, content +- **Informal**: thrilled, stoked, over the moon +- **Formal**: elated, gratified, blissful +- **Subtle variations**: + - _content_ - peaceful satisfaction + - _ecstatic_ - intense, overwhelming happiness + - _cheerful_ - outwardly expressing happiness diff --git a/apps/airiscode-cli/src/commands/extensions/install.ts b/apps/airiscode-cli/src/commands/extensions/install.ts new file mode 100644 index 000000000..bfffce698 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/install.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; + +import { + ExtensionManager, + parseInstallSource, +} from '@airiscode/core'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; +import { loadSettings } from '../../config/settings.js'; +import { + requestConsentOrFail, + requestConsentNonInteractive, + requestChoicePluginNonInteractive, +} from './consent.js'; +import { t } from '../../i18n/index.js'; + +interface InstallArgs { + source: string; + ref?: string; + autoUpdate?: boolean; + allowPreRelease?: boolean; + consent?: boolean; + registry?: string; +} + +export async function handleInstall(args: InstallArgs) { + try { + const installMetadata = await parseInstallSource(args.source); + + if ( + installMetadata.type !== 'git' && + installMetadata.type !== 'github-release' && + installMetadata.type !== 'npm' + ) { + if (args.ref || args.autoUpdate) { + throw new Error( + t( + '--ref and --auto-update are not applicable for marketplace extensions.', + ), + ); + } + } + + if (installMetadata.type === 'npm' && args.ref) { + throw new Error( + t( + '--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).', + ), + ); + } + + if (installMetadata.type !== 'npm' && args.registry) { + throw new Error(t('--registry is only applicable for npm extensions.')); + } + + if (installMetadata.type === 'npm' && args.registry) { + installMetadata.registryUrl = args.registry; + } + + const requestConsent = args.consent + ? () => Promise.resolve() + : requestConsentOrFail.bind(null, requestConsentNonInteractive); + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + isWorkspaceTrusted: !!isWorkspaceTrusted( + loadSettings(workspaceDir).merged, + ), + requestConsent, + requestChoicePlugin: requestChoicePluginNonInteractive, + }); + await extensionManager.refreshCache(); + + const extension = await extensionManager.installExtension( + { + ...installMetadata, + ref: args.ref, + autoUpdate: args.autoUpdate, + allowPreRelease: args.allowPreRelease, + }, + requestConsent, + ); + writeStdoutLine( + t('Extension "{{name}}" installed successfully and enabled.', { + name: extension.name, + }), + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export const installCommand: CommandModule = { + command: 'install ', + describe: t( + 'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).', + ), + builder: (yargs) => + yargs + .positional('source', { + describe: t( + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.', + ), + type: 'string', + demandOption: true, + }) + .option('ref', { + describe: t('The git ref to install from.'), + type: 'string', + }) + .option('auto-update', { + describe: t('Enable auto-update for this extension.'), + type: 'boolean', + }) + .option('pre-release', { + describe: t('Enable pre-release versions for this extension.'), + type: 'boolean', + }) + .option('registry', { + describe: t('Custom npm registry URL (only for npm extensions).'), + type: 'string', + }) + .option('consent', { + describe: t( + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', + ), + type: 'boolean', + default: false, + }) + .check((argv) => { + if (!argv.source) { + throw new Error(t('The source argument must be provided.')); + } + return true; + }), + handler: async (argv) => { + await handleInstall({ + source: argv['source'] as string, + ref: argv['ref'] as string | undefined, + autoUpdate: argv['auto-update'] as boolean | undefined, + allowPreRelease: argv['pre-release'] as boolean | undefined, + consent: argv['consent'] as boolean | undefined, + registry: argv['registry'] as string | undefined, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/link.ts b/apps/airiscode-cli/src/commands/extensions/link.ts new file mode 100644 index 000000000..484812b0a --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/link.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { type ExtensionInstallMetadata } from '@airiscode/core'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + requestConsentNonInteractive, + requestConsentOrFail, +} from './consent.js'; +import { getExtensionManager } from './utils.js'; +import { t } from '../../i18n/index.js'; + +interface InstallArgs { + path: string; +} + +export async function handleLink(args: InstallArgs) { + try { + const installMetadata: ExtensionInstallMetadata = { + source: args.path, + type: 'link', + }; + const extensionManager = await getExtensionManager(); + + const extension = await extensionManager.installExtension( + installMetadata, + requestConsentOrFail.bind(null, requestConsentNonInteractive), + ); + if (!extension) { + writeStdoutLine(t('Link extension failed to install.')); + return; + } + writeStdoutLine( + t('Extension "{{name}}" linked successfully and enabled.', { + name: extension.name, + }), + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export const linkCommand: CommandModule = { + command: 'link ', + describe: t( + 'Links an extension from a local path. Updates made to the local path will always be reflected.', + ), + builder: (yargs) => + yargs + .positional('path', { + describe: t('The name of the extension to link.'), + type: 'string', + }) + .check((_) => true), + handler: async (argv) => { + await handleLink({ + path: argv['path'] as string, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/list.ts b/apps/airiscode-cli/src/commands/extensions/list.ts new file mode 100644 index 000000000..4444fba67 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/list.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { extensionToOutputString, getExtensionManager } from './utils.js'; +import { t } from '../../i18n/index.js'; + +export async function handleList() { + try { + const extensionManager = await getExtensionManager(); + const extensions = extensionManager.getLoadedExtensions(); + + if (!extensions || extensions.length === 0) { + writeStdoutLine(t('No extensions installed.')); + return; + } + writeStdoutLine( + extensions + .map((extension, _): string => + extensionToOutputString(extension, extensionManager, process.cwd()), + ) + .join('\n\n'), + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: t('Lists installed extensions.'), + builder: (yargs) => yargs, + handler: async () => { + await handleList(); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/new.ts b/apps/airiscode-cli/src/commands/extensions/new.ts new file mode 100644 index 000000000..f47648ab9 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/new.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises'; +import { join, dirname, basename } from 'node:path'; +import type { CommandModule } from 'yargs'; +import { fileURLToPath } from 'node:url'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; + +interface NewArgs { + path: string; + template?: string; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const EXAMPLES_PATH = join(__dirname, 'examples'); + +async function pathExists(path: string) { + try { + await access(path); + return true; + } catch (_e) { + return false; + } +} + +async function createDirectory(path: string) { + if (await pathExists(path)) { + throw new Error(`Path already exists: ${path}`); + } + await mkdir(path, { recursive: true }); +} + +async function copyDirectory(template: string, path: string) { + await createDirectory(path); + + const examplePath = join(EXAMPLES_PATH, template); + const entries = await readdir(examplePath, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(examplePath, entry.name); + const destPath = join(path, entry.name); + await cp(srcPath, destPath, { recursive: true }); + } +} + +async function handleNew(args: NewArgs) { + try { + if (args.template) { + await copyDirectory(args.template, args.path); + writeStdoutLine( + `Successfully created new extension from template "${args.template}" at ${args.path}.`, + ); + } else { + await createDirectory(args.path); + const extensionName = basename(args.path); + const manifest = { + name: extensionName, + version: '1.0.0', + }; + await writeFile( + join(args.path, 'qwen-extension.json'), + JSON.stringify(manifest, null, 2), + ); + writeStdoutLine(`Successfully created new extension at ${args.path}.`); + } + writeStdoutLine( + `You can install this using "qwen extensions link ${args.path}" to test it out.`, + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + throw error; + } +} + +async function getBoilerplateChoices() { + const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +export const newCommand: CommandModule = { + command: 'new [template]', + describe: 'Create a new extension from a boilerplate example.', + builder: async (yargs) => { + const choices = await getBoilerplateChoices(); + return yargs + .positional('path', { + describe: 'The path to create the extension in.', + type: 'string', + }) + .positional('template', { + describe: 'The boilerplate template to use.', + type: 'string', + choices, + }); + }, + handler: async (args) => { + await handleNew({ + path: args['path'] as string, + template: args['template'] as string | undefined, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/settings.ts b/apps/airiscode-cli/src/commands/extensions/settings.ts new file mode 100644 index 000000000..bbc22b2f2 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/settings.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { getExtensionManager } from './utils.js'; +import { + ExtensionSettingScope, + getScopedEnvContents, + promptForSetting, + updateSetting, +} from '@airiscode/core'; +import { t } from '../../i18n/index.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; + +// --- SET COMMAND --- +interface SetArgs { + name: string; + setting: string; + scope: string; +} + +const setCommand: CommandModule = { + command: 'set [--scope] ', + describe: t('Set a specific setting for an extension.'), + builder: (yargs) => + yargs + .positional('name', { + describe: t('Name of the extension to configure.'), + type: 'string', + demandOption: true, + }) + .positional('setting', { + describe: t('The setting to configure (name or env var).'), + type: 'string', + demandOption: true, + }) + .option('scope', { + describe: t('The scope to set the setting in.'), + type: 'string', + choices: ['user', 'workspace'], + default: 'user', + }), + handler: async (args) => { + const { name, setting, scope } = args; + const extensionManager = await getExtensionManager(); + if (!extensionManager) return; + const extensions = extensionManager.getLoadedExtensions(); + if (!extensions || extensions.length === 0) return; + const extension = extensions.find((e) => e.name === name); + if (!extension) { + writeStdoutLine(t('Extension "{{name}}" not found.', { name })); + return; + } + await updateSetting( + extension.config, + extension.id, + setting, + promptForSetting, + scope as ExtensionSettingScope, + ); + }, +}; + +// --- LIST COMMAND --- +interface ListArgs { + name: string; +} + +const listCommand: CommandModule = { + command: 'list ', + describe: t('List all settings for an extension.'), + builder: (yargs) => + yargs.positional('name', { + describe: t('Name of the extension.'), + type: 'string', + demandOption: true, + }), + handler: async (args) => { + const { name } = args; + const extensionManager = await getExtensionManager(); + if (!extensionManager) return; + const extensions = extensionManager.getLoadedExtensions(); + if (!extensions || extensions.length === 0) return; + const extension = extensions.find((e) => e.name === name); + if (!extension) { + writeStdoutLine(t('Extension "{{name}}" not found.', { name })); + return; + } + if (!extension || !extension.settings || extension.settings.length === 0) { + writeStdoutLine( + t('Extension "{{name}}" has no settings to configure.', { name }), + ); + return; + } + + const userSettings = await getScopedEnvContents( + extension.config, + extension.id, + ExtensionSettingScope.USER, + ); + const workspaceSettings = await getScopedEnvContents( + extension.config, + extension.id, + ExtensionSettingScope.WORKSPACE, + ); + const mergedSettings = { ...userSettings, ...workspaceSettings }; + + writeStdoutLine(t('Settings for "{{name}}":', { name })); + for (const setting of extension.settings) { + const value = mergedSettings[setting.envVar]; + let displayValue: string; + let scopeInfo = ''; + + if (workspaceSettings[setting.envVar] !== undefined) { + scopeInfo = ' ' + t('(workspace)'); + } else if (userSettings[setting.envVar] !== undefined) { + scopeInfo = ' ' + t('(user)'); + } + + if (value === undefined) { + displayValue = t('[not set]'); + } else if (setting.sensitive) { + displayValue = t('[value stored in keychain]'); + } else { + displayValue = value; + } + writeStdoutLine(` +- ${setting.name} (${setting.envVar})`); + writeStdoutLine(` ${t('Description:')} ${setting.description}`); + writeStdoutLine(` ${t('Value:')} ${displayValue}${scopeInfo}`); + } + }, +}; + +// --- SETTINGS COMMAND --- +export const settingsCommand: CommandModule = { + command: 'settings ', + describe: t('Manage extension settings.'), + builder: (yargs) => + yargs + .command(setCommand) + .command(listCommand) + .demandCommand(1, t('You need to specify a command (set or list).')) + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/uninstall.ts b/apps/airiscode-cli/src/commands/extensions/uninstall.ts new file mode 100644 index 000000000..0450f4d69 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/uninstall.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { ExtensionManager } from '@airiscode/core'; +import { + requestConsentNonInteractive, + requestConsentOrFail, +} from './consent.js'; +import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; +import { loadSettings } from '../../config/settings.js'; +import { t } from '../../i18n/index.js'; + +interface UninstallArgs { + name: string; // can be extension name or source URL. +} + +export async function handleUninstall(args: UninstallArgs) { + try { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentOrFail.bind( + null, + requestConsentNonInteractive, + ), + isWorkspaceTrusted: !!isWorkspaceTrusted( + loadSettings(workspaceDir).merged, + ), + }); + await extensionManager.refreshCache(); + await extensionManager.uninstallExtension(args.name, false); + writeStdoutLine( + t('Extension "{{name}}" successfully uninstalled.', { name: args.name }), + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export const uninstallCommand: CommandModule = { + command: 'uninstall ', + describe: t('Uninstalls an extension.'), + builder: (yargs) => + yargs + .positional('name', { + describe: t('The name or source path of the extension to uninstall.'), + type: 'string', + }) + .check((argv) => { + if (!argv.name) { + throw new Error( + t( + 'Please include the name of the extension to uninstall as a positional argument.', + ), + ); + } + return true; + }), + handler: async (argv) => { + await handleUninstall({ + name: argv['name'] as string, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/update.ts b/apps/airiscode-cli/src/commands/extensions/update.ts new file mode 100644 index 000000000..17d5a1585 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/update.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import { + checkForExtensionUpdate, + type ExtensionUpdateInfo, +} from '@airiscode/core'; +import { getExtensionManager } from './utils.js'; +import { t } from '../../i18n/index.js'; + +interface UpdateArgs { + name?: string; + all?: boolean; +} + +const updateOutput = (info: ExtensionUpdateInfo) => + t( + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.', + { + name: info.name, + oldVersion: info.originalVersion, + newVersion: info.updatedVersion, + }, + ); + +export async function handleUpdate(args: UpdateArgs) { + const extensionManager = await getExtensionManager(); + const extensions = extensionManager.getLoadedExtensions(); + + if (args.name) { + try { + const extension = extensions.find( + (extension) => extension.name === args.name, + ); + if (!extension) { + writeStdoutLine( + t('Extension "{{name}}" not found.', { name: args.name }), + ); + return; + } + if (!extension.installMetadata) { + writeStdoutLine( + t( + 'Unable to install extension "{{name}}" due to missing install metadata', + { name: args.name }, + ), + ); + return; + } + const updateState = await checkForExtensionUpdate( + extension, + extensionManager, + ); + if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { + writeStdoutLine( + t('Extension "{{name}}" is already up to date.', { name: args.name }), + ); + return; + } + // TODO(chrstnb): we should list extensions if the requested extension is not installed. + const updatedExtensionInfo = (await extensionManager.updateExtension( + extension, + updateState, + () => {}, + ))!; + if ( + updatedExtensionInfo.originalVersion !== + updatedExtensionInfo.updatedVersion + ) { + writeStdoutLine( + t( + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.', + { + name: args.name, + oldVersion: updatedExtensionInfo.originalVersion, + newVersion: updatedExtensionInfo.updatedVersion, + }, + ), + ); + } else { + writeStdoutLine( + t('Extension "{{name}}" is already up to date.', { name: args.name }), + ); + } + } catch (error) { + writeStderrLine(getErrorMessage(error)); + } + } + if (args.all) { + try { + const extensionState = new Map(); + await extensionManager.checkForAllExtensionUpdates( + (extensionName, state) => { + extensionState.set(extensionName, { + status: state, + processed: true, // No need to process as we will force the update. + }); + }, + ); + let updateInfos = await extensionManager.updateAllUpdatableExtensions( + extensionState, + () => {}, + ); + updateInfos = updateInfos.filter( + (info) => info.originalVersion !== info.updatedVersion, + ); + if (updateInfos.length === 0) { + writeStdoutLine(t('No extensions to update.')); + return; + } + writeStdoutLine(updateInfos.map((info) => updateOutput(info)).join('\n')); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + } + } +} + +export const updateCommand: CommandModule = { + command: 'update [] [--all]', + describe: t( + 'Updates all extensions or a named extension to the latest version.', + ), + builder: (yargs) => + yargs + .positional('name', { + describe: t('The name of the extension to update.'), + type: 'string', + }) + .option('all', { + describe: t('Update all extensions.'), + type: 'boolean', + }) + .conflicts('name', 'all') + .check((argv) => { + if (!argv.all && !argv.name) { + throw new Error( + t('Either an extension name or --all must be provided'), + ); + } + return true; + }), + handler: async (argv) => { + await handleUpdate({ + name: argv['name'] as string | undefined, + all: argv['all'] as boolean | undefined, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/extensions/utils.ts b/apps/airiscode-cli/src/commands/extensions/utils.ts new file mode 100644 index 000000000..7b80908d3 --- /dev/null +++ b/apps/airiscode-cli/src/commands/extensions/utils.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExtensionManager, type Extension } from '@airiscode/core'; +import { loadSettings } from '../../config/settings.js'; +import { + requestConsentOrFail, + requestConsentNonInteractive, + requestChoicePluginNonInteractive, +} from './consent.js'; +import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; +import * as os from 'node:os'; +import chalk from 'chalk'; +import { t } from '../../i18n/index.js'; + +export async function getExtensionManager(): Promise { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentOrFail.bind( + null, + requestConsentNonInteractive, + ), + requestChoicePlugin: requestChoicePluginNonInteractive, + isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged), + }); + await extensionManager.refreshCache(); + return extensionManager; +} + +export function extensionToOutputString( + extension: Extension, + extensionManager: ExtensionManager, + workspaceDir: string, + inline = false, +): string { + const cwd = workspaceDir; + const userEnabled = extensionManager.isEnabled( + extension.config.name, + os.homedir(), + ); + const workspaceEnabled = extensionManager.isEnabled( + extension.config.name, + cwd, + ); + + const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); + let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`; + output += `\n ${t('Path:')} ${extension.path}`; + if (extension.installMetadata) { + output += `\n ${t('Source:')} ${extension.installMetadata.source} (${t('Type:')} ${extension.installMetadata.type})`; + if (extension.installMetadata.ref) { + output += `\n ${t('Ref:')} ${extension.installMetadata.ref}`; + } + if (extension.installMetadata.releaseTag) { + output += `\n ${t('Release tag:')} ${extension.installMetadata.releaseTag}`; + } + } + output += `\n ${t('Enabled (User):')} ${userEnabled}`; + output += `\n ${t('Enabled (Workspace):')} ${workspaceEnabled}`; + if (extension.contextFiles.length > 0) { + output += `\n ${t('Context files:')}`; + extension.contextFiles.forEach((contextFile) => { + output += `\n ${contextFile}`; + }); + } + if (extension.commands && extension.commands.length > 0) { + output += `\n ${t('Commands:')}`; + extension.commands.forEach((command) => { + output += `\n /${command}`; + }); + } + if (extension.skills && extension.skills.length > 0) { + output += `\n ${t('Skills:')}`; + extension.skills.forEach((skill) => { + output += `\n ${skill.name}`; + }); + } + if (extension.agents && extension.agents.length > 0) { + output += `\n ${t('Agents:')}`; + extension.agents.forEach((agent) => { + output += `\n ${agent.name}`; + }); + } + if (extension.config.mcpServers) { + output += `\n ${t('MCP servers:')}`; + Object.keys(extension.config.mcpServers).forEach((key) => { + output += `\n ${key}`; + }); + } + return output; +} diff --git a/apps/airiscode-cli/src/commands/hooks.tsx b/apps/airiscode-cli/src/commands/hooks.tsx new file mode 100644 index 000000000..7dc64ee7a --- /dev/null +++ b/apps/airiscode-cli/src/commands/hooks.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createDebugLogger } from '@airiscode/core'; + +const debugLogger = createDebugLogger('HOOKS_UI'); + +export const hooksCommand: CommandModule = { + command: 'hooks', + aliases: ['hook'], + describe: 'Manage AIRIS Code hooks (use /hooks in interactive mode).', + builder: (yargs) => yargs.version(false).help(false), + handler: () => { + // In CLI mode, this command is not interactive. + // Users should use /hooks in interactive mode for the full UI experience. + debugLogger.debug( + 'Use /hooks in interactive mode to manage hooks with the UI.', + ); + process.exit(0); + }, +}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/mcp.ts b/apps/airiscode-cli/src/commands/mcp.ts similarity index 89% rename from apps/airiscode-cli/src/gemini-base/commands/mcp.ts rename to apps/airiscode-cli/src/commands/mcp.ts index 5e55286c1..1bb9e0314 100644 --- a/apps/airiscode-cli/src/gemini-base/commands/mcp.ts +++ b/apps/airiscode-cli/src/commands/mcp.ts @@ -9,6 +9,7 @@ import type { CommandModule, Argv } from 'yargs'; import { addCommand } from './mcp/add.js'; import { removeCommand } from './mcp/remove.js'; import { listCommand } from './mcp/list.js'; +import { reconnectCommand } from './mcp/reconnect.js'; export const mcpCommand: CommandModule = { command: 'mcp', @@ -18,6 +19,7 @@ export const mcpCommand: CommandModule = { .command(addCommand) .command(removeCommand) .command(listCommand) + .command(reconnectCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/apps/airiscode-cli/src/gemini-base/commands/mcp/add.ts b/apps/airiscode-cli/src/commands/mcp/add.ts similarity index 86% rename from apps/airiscode-cli/src/gemini-base/commands/mcp/add.ts rename to apps/airiscode-cli/src/commands/mcp/add.ts index 5331c4ee2..0f2c6d303 100644 --- a/apps/airiscode-cli/src/gemini-base/commands/mcp/add.ts +++ b/apps/airiscode-cli/src/commands/mcp/add.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -// File for 'gemini mcp add' command +// File for 'qwen mcp add' command import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; -import { debugLogger, type MCPServerConfig } from '@airiscode/gemini-cli-core'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import type { MCPServerConfig } from '@airiscode/core'; async function addMcpServer( name: string, @@ -41,7 +42,7 @@ async function addMcpServer( const inHome = settings.workspace.path === settings.user.path; if (scope === 'project' && inHome) { - debugLogger.error( + writeStderrLine( 'Error: Please use --scope user to edit settings in the home directory.', ); process.exit(1); @@ -116,7 +117,7 @@ async function addMcpServer( const isExistingServer = !!mcpServers[name]; if (isExistingServer) { - debugLogger.log( + writeStdoutLine( `MCP server "${name}" is already configured within ${scope} settings.`, ); } @@ -126,9 +127,9 @@ async function addMcpServer( settings.setValue(settingsScope, 'mcpServers', mcpServers); if (isExistingServer) { - debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`); + writeStdoutLine(`MCP server "${name}" updated in ${scope} settings.`); } else { - debugLogger.log( + writeStdoutLine( `MCP server "${name}" added to ${scope} settings. (${transport})`, ); } @@ -139,7 +140,7 @@ export const addCommand: CommandModule = { describe: 'Add a server', builder: (yargs) => yargs - .usage('Usage: gemini mcp add [options] [args...]') + .usage('Usage: qwen mcp add [options] [args...]') .parserConfiguration({ 'unknown-options-as-args': true, // Pass unknown options as server args 'populate--': true, // Populate server args after -- separator @@ -158,14 +159,14 @@ export const addCommand: CommandModule = { alias: 's', describe: 'Configuration scope (user or project)', type: 'string', - default: 'project', + default: 'user', choices: ['user', 'project'], }) .option('transport', { alias: 't', - describe: 'Transport type (stdio, sse, http)', + describe: + 'Transport type (stdio, sse, http). Auto-detected from URL if not specified.', type: 'string', - default: 'stdio', choices: ['stdio', 'sse', 'http'], }) .option('env', { @@ -212,6 +213,20 @@ export const addCommand: CommandModule = { const existingArgs = (argv['args'] as Array) || []; argv['args'] = [...existingArgs, ...(argv['--'] as string[])]; } + + // Auto-detect transport from URL if not explicitly specified + if (!argv['transport']) { + const commandOrUrl = argv['commandOrUrl'] as string; + if ( + commandOrUrl && + (commandOrUrl.startsWith('http://') || + commandOrUrl.startsWith('https://')) + ) { + argv['transport'] = 'http'; + } else { + argv['transport'] = 'stdio'; + } + } }), handler: async (argv) => { await addMcpServer( diff --git a/apps/airiscode-cli/src/commands/mcp/list.ts b/apps/airiscode-cli/src/commands/mcp/list.ts new file mode 100644 index 000000000..26aef2545 --- /dev/null +++ b/apps/airiscode-cli/src/commands/mcp/list.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'qwen mcp list' command +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import type { MCPServerConfig } from '@airiscode/core'; +import { + MCPServerStatus, + createTransport, + ExtensionManager, +} from '@airiscode/core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; + +const COLOR_GREEN = '\u001b[32m'; +const COLOR_YELLOW = '\u001b[33m'; +const COLOR_RED = '\u001b[31m'; +const RESET_COLOR = '\u001b[0m'; + +async function getMcpServersFromConfig(): Promise< + Record +> { + const settings = loadSettings(); + const extensionManager = new ExtensionManager({ + isWorkspaceTrusted: !!isWorkspaceTrusted(settings.merged), + telemetrySettings: settings.merged.telemetry, + }); + await extensionManager.refreshCache(); + const extensions = extensionManager.getLoadedExtensions(); + const mcpServers = { ...(settings.merged.mcpServers || {}) }; + for (const extension of extensions) { + if (extension.isActive) { + Object.entries(extension.config.mcpServers || {}).forEach( + ([key, server]) => { + if (mcpServers[key]) { + return; + } + mcpServers[key] = { + ...server, + extensionName: extension.config.name, + }; + }, + ); + } + } + return mcpServers; +} + +async function testMCPConnection( + serverName: string, + config: MCPServerConfig, +): Promise { + const client = new Client({ + name: 'mcp-test-client', + version: '0.0.1', + }); + + let transport; + try { + // Use the same transport creation logic as core + transport = await createTransport(serverName, config, false); + } catch (_error) { + await client.close(); + return MCPServerStatus.DISCONNECTED; + } + + try { + // Attempt actual MCP connection with short timeout + await client.connect(transport, { timeout: 5000 }); // 5s timeout + + // Test basic MCP protocol by pinging the server + await client.ping(); + + await client.close(); + return MCPServerStatus.CONNECTED; + } catch (_error) { + await transport.close(); + return MCPServerStatus.DISCONNECTED; + } +} + +async function getServerStatus( + serverName: string, + server: MCPServerConfig, +): Promise { + // Test all server types by attempting actual connection + return await testMCPConnection(serverName, server); +} + +export async function listMcpServers(): Promise { + const mcpServers = await getMcpServersFromConfig(); + const serverNames = Object.keys(mcpServers); + + if (serverNames.length === 0) { + writeStdoutLine('No MCP servers configured.'); + return; + } + + writeStdoutLine('Configured MCP servers:\n'); + + for (const serverName of serverNames) { + const server = mcpServers[serverName]; + + const status = await getServerStatus(serverName, server); + + let statusIndicator = ''; + let statusText = ''; + switch (status) { + case MCPServerStatus.CONNECTED: + statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR; + statusText = 'Connected'; + break; + case MCPServerStatus.CONNECTING: + statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR; + statusText = 'Connecting'; + break; + case MCPServerStatus.DISCONNECTED: + default: + statusIndicator = COLOR_RED + '✗' + RESET_COLOR; + statusText = 'Disconnected'; + break; + } + + let serverInfo = `${serverName}: `; + if (server.httpUrl) { + serverInfo += `${server.httpUrl} (http)`; + } else if (server.url) { + serverInfo += `${server.url} (sse)`; + } else if (server.command) { + serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; + } + + writeStdoutLine(`${statusIndicator} ${serverInfo} - ${statusText}`); + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: 'List all configured MCP servers', + handler: async () => { + await listMcpServers(); + }, +}; diff --git a/apps/airiscode-cli/src/commands/mcp/reconnect.ts b/apps/airiscode-cli/src/commands/mcp/reconnect.ts new file mode 100644 index 000000000..98ac6566c --- /dev/null +++ b/apps/airiscode-cli/src/commands/mcp/reconnect.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + Config, + FileDiscoveryService, + ExtensionManager, +} from '@airiscode/core'; +import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; +import type { MCPServerConfig } from '@airiscode/core'; + +async function getMcpServersFromConfig( + extensionManager?: ExtensionManager, +): Promise> { + const settings = loadSettings(); + const extManager = + extensionManager ?? + new ExtensionManager({ + isWorkspaceTrusted: !!isWorkspaceTrusted(settings.merged), + telemetrySettings: settings.merged.telemetry, + }); + + if (!extensionManager) { + await extManager.refreshCache(); + } + const extensions = extManager.getLoadedExtensions(); + const mcpServers = { ...(settings.merged.mcpServers || {}) }; + for (const extension of extensions) { + if (extension.isActive) { + Object.entries(extension.config.mcpServers || {}).forEach( + ([key, server]) => { + if (mcpServers[key]) { + return; + } + mcpServers[key] = { + ...server, + extensionName: extension.config.name, + }; + }, + ); + } + } + return mcpServers; +} + +async function createMinimalConfig(): Promise { + const settings = loadSettings(); + const cwd = process.cwd(); + const fileService = new FileDiscoveryService(cwd); + + const config = new Config({ + sessionId: 'mcp-reconnect', + targetDir: cwd, + cwd, + debugMode: false, + mcpServers: settings.merged.mcpServers || {}, + fileDiscoveryService: fileService, + mcpServerCommand: settings.merged.mcp?.serverCommand, + }); + + await config.initialize(); + + return config; +} + +interface ReconnectError extends Error { + exitCode: number; +} + +function createReconnectError( + message: string, + exitCode: number = 1, +): ReconnectError { + const error = new Error(message) as ReconnectError; + error.exitCode = exitCode; + return error; +} + +async function reconnectMcpServer(serverName: string): Promise { + const mcpServers = await getMcpServersFromConfig(); + + if (!mcpServers[serverName]) { + throw createReconnectError( + `Error: Server "${serverName}" not found in configuration.`, + ); + } + + writeStdoutLine(`Reconnecting to server "${serverName}"...`); + + try { + const config = await createMinimalConfig(); + const toolRegistry = config.getToolRegistry(); + await toolRegistry.discoverToolsForServer(serverName); + writeStdoutLine(`Successfully reconnected to server "${serverName}".`); + await config.shutdown(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw createReconnectError( + `Failed to reconnect to server "${serverName}": ${message}`, + ); + } +} + +async function reconnectAllMcpServers(): Promise { + const settings = loadSettings(); + const extensionManager = new ExtensionManager({ + isWorkspaceTrusted: !!isWorkspaceTrusted(settings.merged), + telemetrySettings: settings.merged.telemetry, + }); + await extensionManager.refreshCache(); + + const mcpServers = await getMcpServersFromConfig(extensionManager); + const serverNames = Object.keys(mcpServers); + + if (serverNames.length === 0) { + writeStdoutLine('No MCP servers configured.'); + return; + } + + writeStdoutLine('Reconnecting to all MCP servers...\n'); + + let config: Config | undefined; + try { + config = await createMinimalConfig(); + const toolRegistry = config.getToolRegistry(); + + for (const serverName of serverNames) { + try { + await toolRegistry.discoverToolsForServer(serverName); + writeStdoutLine(`✓ ${serverName}: Reconnected successfully`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeStdoutLine(`✗ ${serverName}: Failed - ${message}`); + } + } + } finally { + if (config) { + await config.shutdown(); + } + } +} + +export const reconnectCommand: CommandModule = { + command: 'reconnect [server-name]', + describe: 'Reconnect MCP server(s)', + builder: (yargs) => + yargs + .usage('Usage: qwen mcp reconnect [options] [server-name]') + .positional('server-name', { + describe: 'Name of the server to reconnect', + type: 'string', + }) + .option('all', { + alias: 'a', + describe: 'Reconnect all configured servers', + type: 'boolean', + default: false, + }) + .conflicts('server-name', 'all') + .check((argv) => { + const serverName = argv['server-name']; + const all = argv['all']; + if (!serverName && !all) { + throw new Error( + 'Please specify a server name or use --all to reconnect all servers.', + ); + } + return true; + }), + handler: async (argv) => { + const serverName = argv['server-name'] as string | undefined; + const all = argv['all'] as boolean; + + try { + if (all) { + await reconnectAllMcpServers(); + } else if (serverName) { + await reconnectMcpServer(serverName); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const exitCode = (error as ReconnectError)?.exitCode ?? 1; + writeStderrLine(message); + process.exit(exitCode); + } + }, +}; diff --git a/apps/airiscode-cli/src/commands/mcp/remove.ts b/apps/airiscode-cli/src/commands/mcp/remove.ts new file mode 100644 index 000000000..c794ca9e6 --- /dev/null +++ b/apps/airiscode-cli/src/commands/mcp/remove.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'qwen mcp remove' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { MCPOAuthTokenStorage } from '@airiscode/core'; + +async function removeMcpServer( + name: string, + options: { + scope: string; + }, +) { + const { scope } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(); + + const existingSettings = settings.forScope(settingsScope).settings; + const mcpServers = existingSettings.mcpServers || {}; + + if (!mcpServers[name]) { + writeStdoutLine(`Server "${name}" not found in ${scope} settings.`); + return; + } + + delete mcpServers[name]; + + settings.setValue(settingsScope, 'mcpServers', mcpServers); + + // Clean up any stored OAuth tokens for this server + try { + const tokenStorage = new MCPOAuthTokenStorage(); + await tokenStorage.deleteCredentials(name); + } catch { + // Token cleanup is best-effort; don't fail the remove operation + } + + writeStdoutLine(`Server "${name}" removed from ${scope} settings.`); +} + +export const removeCommand: CommandModule = { + command: 'remove ', + describe: 'Remove a server', + builder: (yargs) => + yargs + .usage('Usage: qwen mcp remove [options] ') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'user', + choices: ['user', 'project'], + }), + handler: async (argv) => { + await removeMcpServer(argv['name'] as string, { + scope: argv['scope'] as string, + }); + }, +}; diff --git a/apps/airiscode-cli/src/commands/session.ts b/apps/airiscode-cli/src/commands/session.ts deleted file mode 100644 index 1de6de3d3..000000000 --- a/apps/airiscode-cli/src/commands/session.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Session command - Manage sessions - */ - -import { Command } from 'commander'; -import chalk from 'chalk'; -import { SessionManager } from '../session/session-manager.js'; -import type { SessionCommandOptions } from '../types.js'; - -export function createSessionCommand(): Command { - const cmd = new Command('session'); - - cmd - .description('Manage coding sessions') - .option('-l, --list', 'List all sessions') - .option('--show ', 'Show session details') - .option('--resume ', 'Resume session') - .option('--delete ', 'Delete session') - .option('--clean', 'Clean old completed sessions (30+ days)') - .action(async (options: SessionCommandOptions) => { - await executeSessionCommand(options); - }); - - return cmd; -} - -async function executeSessionCommand(options: SessionCommandOptions): Promise { - const sessionManager = new SessionManager(); - - try { - if (options.list) { - // List all sessions - const sessions = sessionManager.listSessions(); - - if (sessions.length === 0) { - console.log(chalk.yellow('No sessions found.')); - return; - } - - console.log(chalk.bold(`Found ${sessions.length} session(s):\n`)); - - for (const session of sessions) { - const statusColor = { - active: chalk.green, - completed: chalk.blue, - failed: chalk.red, - paused: chalk.yellow, - }[session.status]; - - console.log(chalk.bold(` ${session.id}`)); - if (session.name) { - console.log(chalk.gray(` Name: ${session.name}`)); - } - console.log(chalk.gray(` Status: ${statusColor(session.status)}`)); - console.log(chalk.gray(` Driver: ${session.driver}`)); - console.log(chalk.gray(` Adapter: ${session.adapter}`)); - console.log(chalk.gray(` Tasks: ${session.taskCount}`)); - console.log(chalk.gray(` Created: ${session.createdAt.toLocaleString()}`)); - console.log(chalk.gray(` Last active: ${session.lastActiveAt.toLocaleString()}`)); - console.log(); - } - return; - } - - if (options.show) { - // Show session details - const session = sessionManager.loadSession(options.show); - - if (!session) { - console.error(chalk.red(`Session not found: ${options.show}`)); - process.exit(1); - } - - console.log(chalk.bold('Session Details:\n')); - console.log(JSON.stringify(session, null, 2)); - return; - } - - if (options.resume) { - // Resume session - const session = sessionManager.loadSession(options.resume); - - if (!session) { - console.error(chalk.red(`Session not found: ${options.resume}`)); - process.exit(1); - } - - console.log(chalk.green(` Session loaded: ${session.id}`)); - console.log(chalk.gray(` Working directory: ${session.workingDir}`)); - console.log(chalk.gray(` Driver: ${session.driver}`)); - console.log(chalk.gray(` Adapter: ${session.adapter}`)); - console.log(chalk.yellow('\nUse "airis code " to continue working in this session.')); - return; - } - - if (options.delete) { - // Delete session - const success = sessionManager.deleteSession(options.delete); - - if (!success) { - console.error(chalk.red(`Failed to delete session: ${options.delete}`)); - process.exit(1); - } - - console.log(chalk.green(` Session deleted: ${options.delete}`)); - return; - } - - if (options.clean) { - // Clean old sessions - const deletedCount = sessionManager.cleanOldSessions(30); - console.log(chalk.green(` Cleaned ${deletedCount} old session(s)`)); - return; - } - - // No options provided - console.log(chalk.yellow('No options provided. Use --help for usage information.')); - - } catch (error) { - console.error(chalk.red('Session command failed:')); - console.error(chalk.red(error instanceof Error ? error.message : String(error))); - process.exit(1); - } -} diff --git a/apps/airiscode-cli/src/components/Composer.tsx b/apps/airiscode-cli/src/components/Composer.tsx deleted file mode 100644 index 0733ed6e7..000000000 --- a/apps/airiscode-cli/src/components/Composer.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import React, { useState } from "react"; -import { Box, Text, useInput } from "ink"; - -export interface ComposerProps { - onSubmit: (input: string) => void; - disabled?: boolean; - placeholder?: string; -} - -export const Composer: React.FC = ({ - onSubmit, - disabled = false, - placeholder = "Type your message...", -}) => { - const [input, setInput] = useState(""); - - useInput((inputKey, keyInfo) => { - if (disabled) return; - - if (keyInfo.return) { - if (input.trim().length > 0) { - onSubmit(input.trim()); - setInput(""); - } - return; - } - - if (keyInfo.backspace || keyInfo.delete) { - setInput((prev) => prev.slice(0, -1)); - return; - } - - if (typeof inputKey === "string" && inputKey.length === 1 && !keyInfo.ctrl && !keyInfo.meta) { - setInput((prev) => prev + inputKey); - } - }); - - const isSlashCommand = input.startsWith("/"); - - return ( - - - - {isSlashCommand ? "/" : ">"}{" "} - - {input} - {!disabled && _} - - {input.length === 0 && !disabled && ( - - {placeholder} - - )} - {isSlashCommand && input.length > 1 && ( - - Slash command detected. Available: /clear, /help - - )} - - ); -}; diff --git a/apps/airiscode-cli/src/components/CustomTextInput.tsx b/apps/airiscode-cli/src/components/CustomTextInput.tsx deleted file mode 100644 index 2ebfe7791..000000000 --- a/apps/airiscode-cli/src/components/CustomTextInput.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Custom Text Input Component with Multibyte Character Support - * - * Replaces ink-text-input to properly handle: - * - Japanese IME input - * - Multibyte character editing (backspace, delete) - * - Correct cursor positioning for CJK characters - */ - -import React, { useState, useEffect } from 'react'; -import { Box, Text, useInput } from 'ink'; -import stringWidth from 'string-width'; - -export interface CustomTextInputProps { - value: string; - placeholder?: string; - onChange?: (value: string) => void; - onSubmit?: (value: string) => void; - disabled?: boolean; - focus?: boolean; - showCursor?: boolean; - cursorColor?: string; -} - -export const CustomTextInput: React.FC = ({ - value: externalValue, - placeholder = '', - onChange, - onSubmit, - disabled = false, - focus = true, - showCursor = true, - cursorColor = 'cyan', -}) => { - const [cursorOffset, setCursorOffset] = useState(externalValue.length); - - // Sync cursor position when external value changes - useEffect(() => { - setCursorOffset(externalValue.length); - }, [externalValue]); - - useInput( - (input, key) => { - if (disabled || !focus) return; - - // Submit on Enter - if (key.return) { - onSubmit?.(externalValue); - return; - } - - // Handle Ctrl+C separately (let parent handle) - if (key.ctrl && input === 'c') { - return; - } - - let newValue = externalValue; - let newCursor = cursorOffset; - - // Handle backspace - if (key.backspace || key.delete) { - if (cursorOffset > 0) { - // Remove character before cursor - newValue = - externalValue.slice(0, cursorOffset - 1) + - externalValue.slice(cursorOffset); - newCursor = cursorOffset - 1; - } - } - // Handle left arrow - else if (key.leftArrow) { - newCursor = Math.max(0, cursorOffset - 1); - } - // Handle right arrow - else if (key.rightArrow) { - newCursor = Math.min(externalValue.length, cursorOffset + 1); - } - // Handle Ctrl+A (Home equivalent) - else if (key.ctrl && input === 'a') { - newCursor = 0; - } - // Handle Ctrl+E (End equivalent) - else if (key.ctrl && input === 'e') { - newCursor = externalValue.length; - } - // Handle regular character input - else if (input && !key.ctrl && !key.meta) { - // Insert character at cursor position - newValue = - externalValue.slice(0, cursorOffset) + - input + - externalValue.slice(cursorOffset); - newCursor = cursorOffset + input.length; - } - - // Update state - if (newValue !== externalValue) { - onChange?.(newValue); - } - if (newCursor !== cursorOffset) { - setCursorOffset(newCursor); - } - }, - { isActive: !disabled && focus } - ); - - // Render input with cursor - const renderValue = () => { - const displayValue = externalValue || placeholder; - const isPlaceholder = !externalValue && placeholder; - - if (!showCursor || !focus) { - return ( - - {displayValue} - - ); - } - - // Split text at cursor position - const before = externalValue.slice(0, cursorOffset); - const after = externalValue.slice(cursorOffset); - - // Calculate visual width for proper cursor positioning - const beforeWidth = stringWidth(before); - - return ( - - {isPlaceholder ? ( - {placeholder} - ) : ( - <> - {before} - - {after[0] || ' '} - - {after.slice(1)} - - )} - - ); - }; - - return {renderValue()}; -}; diff --git a/apps/airiscode-cli/src/components/MessageDisplay.tsx b/apps/airiscode-cli/src/components/MessageDisplay.tsx deleted file mode 100644 index 7250873bb..000000000 --- a/apps/airiscode-cli/src/components/MessageDisplay.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import React from "react"; -import { Box, Text } from "ink"; -import type { Message } from "../sessionStorage.js"; - -export interface MessageDisplayProps { - messages: Message[]; - maxMessages?: number; - currentStreaming?: string; - showTimestamps?: boolean; -} - -export const MessageDisplay: React.FC = ({ - messages, - maxMessages = 10, - currentStreaming, - showTimestamps = false, -}) => { - const recentMessages = messages.slice(-maxMessages); - - const formatTimestamp = (timestamp: number): string => { - const date = new Date(timestamp); - return date.toLocaleTimeString(); - }; - - const renderMessage = (message: Message, index: number) => { - const isUser = message.role === "user"; - const color = isUser ? "green" : "blue"; - const label = isUser ? "You" : "Assistant"; - - return ( - - - - {label} - - {showTimestamps && ( - at {formatTimestamp(message.timestamp)} - )} - - - {message.content} - - - ); - }; - - return ( - - {recentMessages.map(renderMessage)} - - {currentStreaming && ( - - - - Assistant - - (streaming...) - - - {currentStreaming} - - - )} - - ); -}; diff --git a/apps/airiscode-cli/src/components/TextInput.tsx b/apps/airiscode-cli/src/components/TextInput.tsx deleted file mode 100644 index 35cfa3a60..000000000 --- a/apps/airiscode-cli/src/components/TextInput.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useCallback } from 'react'; -import type { Key } from '../hooks/useKeypress.js'; -import { Text, Box } from 'ink'; -import { useKeypress } from '../hooks/useKeypress.js'; -import chalk from 'chalk'; -import { theme } from '../utils/theme.js'; -import type { TextBuffer } from './text-buffer.js'; -import { cpSlice } from '../utils/textUtils.js'; - -export interface TextInputProps { - buffer: TextBuffer; - placeholder?: string; - onSubmit?: (value: string) => void; - onCancel?: () => void; - focus?: boolean; -} - -export function TextInput({ - buffer, - placeholder = '', - onSubmit, - onCancel, - focus = true, -}: TextInputProps): React.JSX.Element { - const { - text, - handleInput, - visualCursor, - viewportVisualLines, - visualScrollRow, - } = buffer; - const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor; - - const handleKeyPress = useCallback( - (key: Key) => { - if (key.name === 'escape') { - onCancel?.(); - return; - } - - if (key.name === 'return') { - onSubmit?.(text); - return; - } - - handleInput(key); - }, - [handleInput, onCancel, onSubmit, text], - ); - - useKeypress(handleKeyPress, { isActive: focus }); - - const showPlaceholder = text.length === 0 && placeholder; - - if (showPlaceholder) { - return ( - - {focus ? ( - - {chalk.inverse(placeholder[0] || ' ')} - {placeholder.slice(1)} - - ) : ( - {placeholder} - )} - - ); - } - - return ( - - {viewportVisualLines.map((lineText, idx) => { - const currentVisualRow = visualScrollRow + idx; - const isCursorLine = - focus && currentVisualRow === cursorVisualRowAbsolute; - - const lineDisplay = isCursorLine - ? cpSlice(lineText, 0, cursorVisualColAbsolute) + - chalk.inverse( - cpSlice( - lineText, - cursorVisualColAbsolute, - cursorVisualColAbsolute + 1, - ) || ' ', - ) + - cpSlice(lineText, cursorVisualColAbsolute + 1) - : lineText; - - return ( - - {lineDisplay} - - ); - })} - - ); -} diff --git a/apps/airiscode-cli/src/components/text-buffer.ts b/apps/airiscode-cli/src/components/text-buffer.ts deleted file mode 100644 index 717652331..000000000 --- a/apps/airiscode-cli/src/components/text-buffer.ts +++ /dev/null @@ -1,2419 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import pathMod from 'node:path'; -import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; -import { unescapePath } from '../utils/paths.js'; -import { - toCodePoints, - cpLen, - cpSlice, - stripUnsafeCharacters, - getCachedStringWidth, -} from '../utils/textUtils.js'; -import type { Key } from '../contexts/KeypressContext.js'; -// Vim actions temporarily disabled -type VimAction = any; -const handleVimAction = (state: any, action: VimAction) => state; - -export type Direction = - | 'left' - | 'right' - | 'up' - | 'down' - | 'wordLeft' - | 'wordRight' - | 'home' - | 'end'; - -// Simple helper for word‑wise ops. -function isWordChar(ch: string | undefined): boolean { - if (ch === undefined) { - return false; - } - return !/[\s,.;!?]/.test(ch); -} - -// Helper functions for line-based word navigation -export const isWordCharStrict = (char: string): boolean => - /[\w\p{L}\p{N}]/u.test(char); // Matches a single character that is any Unicode letter, any Unicode number, or an underscore - -export const isWhitespace = (char: string): boolean => /\s/.test(char); - -// Check if a character is a combining mark (only diacritics for now) -export const isCombiningMark = (char: string): boolean => /\p{M}/u.test(char); - -// Check if a character should be considered part of a word (including combining marks) -export const isWordCharWithCombining = (char: string): boolean => - isWordCharStrict(char) || isCombiningMark(char); - -// Get the script of a character (simplified for common scripts) -export const getCharScript = (char: string): string => { - if (/[\p{Script=Latin}]/u.test(char)) return 'latin'; // All Latin script chars including diacritics - if (/[\p{Script=Han}]/u.test(char)) return 'han'; // Chinese - if (/[\p{Script=Arabic}]/u.test(char)) return 'arabic'; - if (/[\p{Script=Hiragana}]/u.test(char)) return 'hiragana'; - if (/[\p{Script=Katakana}]/u.test(char)) return 'katakana'; - if (/[\p{Script=Cyrillic}]/u.test(char)) return 'cyrillic'; - return 'other'; -}; - -// Check if two characters are from different scripts (indicating word boundary) -export const isDifferentScript = (char1: string, char2: string): boolean => { - if (!isWordCharStrict(char1) || !isWordCharStrict(char2)) return false; - return getCharScript(char1) !== getCharScript(char2); -}; - -// Find next word start within a line, starting from col -export const findNextWordStartInLine = ( - line: string, - col: number, -): number | null => { - const chars = toCodePoints(line); - let i = col; - - if (i >= chars.length) return null; - - const currentChar = chars[i]; - - // Skip current word/sequence based on character type - if (isWordCharStrict(currentChar)) { - while (i < chars.length && isWordCharWithCombining(chars[i])) { - // Check for script boundary - if next character is from different script, stop here - if ( - i + 1 < chars.length && - isWordCharStrict(chars[i + 1]) && - isDifferentScript(chars[i], chars[i + 1]) - ) { - i++; // Include current character - break; // Stop at script boundary - } - i++; - } - } else if (!isWhitespace(currentChar)) { - while ( - i < chars.length && - !isWordCharStrict(chars[i]) && - !isWhitespace(chars[i]) - ) { - i++; - } - } - - // Skip whitespace - while (i < chars.length && isWhitespace(chars[i])) { - i++; - } - - return i < chars.length ? i : null; -}; - -// Find previous word start within a line -export const findPrevWordStartInLine = ( - line: string, - col: number, -): number | null => { - const chars = toCodePoints(line); - let i = col; - - if (i <= 0) return null; - - i--; - - // Skip whitespace moving backwards - while (i >= 0 && isWhitespace(chars[i])) { - i--; - } - - if (i < 0) return null; - - if (isWordCharStrict(chars[i])) { - // We're in a word, move to its beginning - while (i >= 0 && isWordCharStrict(chars[i])) { - // Check for script boundary - if previous character is from different script, stop here - if ( - i - 1 >= 0 && - isWordCharStrict(chars[i - 1]) && - isDifferentScript(chars[i], chars[i - 1]) - ) { - return i; // Return current position at script boundary - } - i--; - } - return i + 1; - } else { - // We're in punctuation, move to its beginning - while (i >= 0 && !isWordCharStrict(chars[i]) && !isWhitespace(chars[i])) { - i--; - } - return i + 1; - } -}; - -// Find word end within a line -export const findWordEndInLine = (line: string, col: number): number | null => { - const chars = toCodePoints(line); - let i = col; - - // If we're already at the end of a word (including punctuation sequences), advance to next word - // This includes both regular word endings and script boundaries - const atEndOfWordChar = - i < chars.length && - isWordCharWithCombining(chars[i]) && - (i + 1 >= chars.length || - !isWordCharWithCombining(chars[i + 1]) || - (isWordCharStrict(chars[i]) && - i + 1 < chars.length && - isWordCharStrict(chars[i + 1]) && - isDifferentScript(chars[i], chars[i + 1]))); - - const atEndOfPunctuation = - i < chars.length && - !isWordCharWithCombining(chars[i]) && - !isWhitespace(chars[i]) && - (i + 1 >= chars.length || - isWhitespace(chars[i + 1]) || - isWordCharWithCombining(chars[i + 1])); - - if (atEndOfWordChar || atEndOfPunctuation) { - // We're at the end of a word or punctuation sequence, move forward to find next word - i++; - // Skip whitespace to find next word or punctuation - while (i < chars.length && isWhitespace(chars[i])) { - i++; - } - } - - // If we're not on a word character, find the next word or punctuation sequence - if (i < chars.length && !isWordCharWithCombining(chars[i])) { - // Skip whitespace to find next word or punctuation - while (i < chars.length && isWhitespace(chars[i])) { - i++; - } - } - - // Move to end of current word (including combining marks, but stop at script boundaries) - let foundWord = false; - let lastBaseCharPos = -1; - - if (i < chars.length && isWordCharWithCombining(chars[i])) { - // Handle word characters - while (i < chars.length && isWordCharWithCombining(chars[i])) { - foundWord = true; - - // Track the position of the last base character (not combining mark) - if (isWordCharStrict(chars[i])) { - lastBaseCharPos = i; - } - - // Check if next character is from a different script (word boundary) - if ( - i + 1 < chars.length && - isWordCharStrict(chars[i + 1]) && - isDifferentScript(chars[i], chars[i + 1]) - ) { - i++; // Include current character - if (isWordCharStrict(chars[i - 1])) { - lastBaseCharPos = i - 1; - } - break; // Stop at script boundary - } - - i++; - } - } else if (i < chars.length && !isWhitespace(chars[i])) { - // Handle punctuation sequences (like ████) - while ( - i < chars.length && - !isWordCharStrict(chars[i]) && - !isWhitespace(chars[i]) - ) { - foundWord = true; - lastBaseCharPos = i; - i++; - } - } - - // Only return a position if we actually found a word - // Return the position of the last base character, not combining marks - if (foundWord && lastBaseCharPos >= col) { - return lastBaseCharPos; - } - - return null; -}; - -// Find next word across lines -export const findNextWordAcrossLines = ( - lines: string[], - cursorRow: number, - cursorCol: number, - searchForWordStart: boolean, -): { row: number; col: number } | null => { - // First try current line - const currentLine = lines[cursorRow] || ''; - const colInCurrentLine = searchForWordStart - ? findNextWordStartInLine(currentLine, cursorCol) - : findWordEndInLine(currentLine, cursorCol); - - if (colInCurrentLine !== null) { - return { row: cursorRow, col: colInCurrentLine }; - } - - // Search subsequent lines - for (let row = cursorRow + 1; row < lines.length; row++) { - const line = lines[row] || ''; - const chars = toCodePoints(line); - - // For empty lines, if we haven't found any words yet, return the empty line - if (chars.length === 0) { - // Check if there are any words in remaining lines - let hasWordsInLaterLines = false; - for (let laterRow = row + 1; laterRow < lines.length; laterRow++) { - const laterLine = lines[laterRow] || ''; - const laterChars = toCodePoints(laterLine); - let firstNonWhitespace = 0; - while ( - firstNonWhitespace < laterChars.length && - isWhitespace(laterChars[firstNonWhitespace]) - ) { - firstNonWhitespace++; - } - if (firstNonWhitespace < laterChars.length) { - hasWordsInLaterLines = true; - break; - } - } - - // If no words in later lines, return the empty line - if (!hasWordsInLaterLines) { - return { row, col: 0 }; - } - continue; - } - - // Find first non-whitespace - let firstNonWhitespace = 0; - while ( - firstNonWhitespace < chars.length && - isWhitespace(chars[firstNonWhitespace]) - ) { - firstNonWhitespace++; - } - - if (firstNonWhitespace < chars.length) { - if (searchForWordStart) { - return { row, col: firstNonWhitespace }; - } else { - // For word end, find the end of the first word - const endCol = findWordEndInLine(line, firstNonWhitespace); - if (endCol !== null) { - return { row, col: endCol }; - } - } - } - } - - return null; -}; - -// Find previous word across lines -export const findPrevWordAcrossLines = ( - lines: string[], - cursorRow: number, - cursorCol: number, -): { row: number; col: number } | null => { - // First try current line - const currentLine = lines[cursorRow] || ''; - const colInCurrentLine = findPrevWordStartInLine(currentLine, cursorCol); - - if (colInCurrentLine !== null) { - return { row: cursorRow, col: colInCurrentLine }; - } - - // Search previous lines - for (let row = cursorRow - 1; row >= 0; row--) { - const line = lines[row] || ''; - const chars = toCodePoints(line); - - if (chars.length === 0) continue; - - // Find last word start - let lastWordStart = chars.length; - while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) { - lastWordStart--; - } - - if (lastWordStart > 0) { - // Find start of this word - const wordStart = findPrevWordStartInLine(line, lastWordStart); - if (wordStart !== null) { - return { row, col: wordStart }; - } - } - } - - return null; -}; - -// Helper functions for vim line operations -export const getPositionFromOffsets = ( - startOffset: number, - endOffset: number, - lines: string[], -) => { - let offset = 0; - let startRow = 0; - let startCol = 0; - let endRow = 0; - let endCol = 0; - - // Find start position - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + 1; // +1 for newline - if (offset + lineLength > startOffset) { - startRow = i; - startCol = startOffset - offset; - break; - } - offset += lineLength; - } - - // Find end position - offset = 0; - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 for newline except last line - if (offset + lineLength >= endOffset) { - endRow = i; - endCol = endOffset - offset; - break; - } - offset += lineLength; - } - - return { startRow, startCol, endRow, endCol }; -}; - -export const getLineRangeOffsets = ( - startRow: number, - lineCount: number, - lines: string[], -) => { - let startOffset = 0; - - // Calculate start offset - for (let i = 0; i < startRow; i++) { - startOffset += lines[i].length + 1; // +1 for newline - } - - // Calculate end offset - let endOffset = startOffset; - for (let i = 0; i < lineCount; i++) { - const lineIndex = startRow + i; - if (lineIndex < lines.length) { - endOffset += lines[lineIndex].length; - if (lineIndex < lines.length - 1) { - endOffset += 1; // +1 for newline - } - } - } - - return { startOffset, endOffset }; -}; - -export const replaceRangeInternal = ( - state: TextBufferState, - startRow: number, - startCol: number, - endRow: number, - endCol: number, - text: string, -): TextBufferState => { - const currentLine = (row: number) => state.lines[row] || ''; - const currentLineLen = (row: number) => cpLen(currentLine(row)); - const clamp = (value: number, min: number, max: number) => - Math.min(Math.max(value, min), max); - - if ( - startRow > endRow || - (startRow === endRow && startCol > endCol) || - startRow < 0 || - startCol < 0 || - endRow >= state.lines.length || - (endRow < state.lines.length && endCol > currentLineLen(endRow)) - ) { - return state; // Invalid range - } - - const newLines = [...state.lines]; - - const sCol = clamp(startCol, 0, currentLineLen(startRow)); - const eCol = clamp(endCol, 0, currentLineLen(endRow)); - - const prefix = cpSlice(currentLine(startRow), 0, sCol); - const suffix = cpSlice(currentLine(endRow), eCol); - - const normalisedReplacement = text - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n'); - const replacementParts = normalisedReplacement.split('\n'); - - // The combined first line of the new text - const firstLine = prefix + replacementParts[0]; - - if (replacementParts.length === 1) { - // No newlines in replacement: combine prefix, replacement, and suffix on one line. - newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); - } else { - // Newlines in replacement: create new lines. - const lastLine = replacementParts[replacementParts.length - 1] + suffix; - const middleLines = replacementParts.slice(1, -1); - newLines.splice( - startRow, - endRow - startRow + 1, - firstLine, - ...middleLines, - lastLine, - ); - } - - const finalCursorRow = startRow + replacementParts.length - 1; - const finalCursorCol = - (replacementParts.length > 1 ? 0 : sCol) + - cpLen(replacementParts[replacementParts.length - 1]); - - return { - ...state, - lines: newLines, - cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1), - cursorCol: Math.max( - 0, - Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || '')), - ), - preferredCol: null, - }; -}; - -export interface Viewport { - height: number; - width: number; -} - -function clamp(v: number, min: number, max: number): number { - return v < min ? min : v > max ? max : v; -} - -/* ────────────────────────────────────────────────────────────────────────── */ - -interface UseTextBufferProps { - initialText?: string; - initialCursorOffset?: number; - viewport: Viewport; // Viewport dimensions needed for scrolling - stdin?: NodeJS.ReadStream | null; // For external editor - setRawMode?: (mode: boolean) => void; // For external editor - onChange?: (text: string) => void; // Callback for when text changes - isValidPath: (path: string) => boolean; - shellModeActive?: boolean; // Whether the text buffer is in shell mode - inputFilter?: (text: string) => string; // Optional filter for input text - singleLine?: boolean; -} - -interface UndoHistoryEntry { - lines: string[]; - cursorRow: number; - cursorCol: number; -} - -function calculateInitialCursorPosition( - initialLines: string[], - offset: number, -): [number, number] { - let remainingChars = offset; - let row = 0; - while (row < initialLines.length) { - const lineLength = cpLen(initialLines[row]); - // Add 1 for the newline character (except for the last line) - const totalCharsInLineAndNewline = - lineLength + (row < initialLines.length - 1 ? 1 : 0); - - if (remainingChars <= lineLength) { - // Cursor is on this line - return [row, remainingChars]; - } - remainingChars -= totalCharsInLineAndNewline; - row++; - } - // Offset is beyond the text, place cursor at the end of the last line - if (initialLines.length > 0) { - const lastRow = initialLines.length - 1; - return [lastRow, cpLen(initialLines[lastRow])]; - } - return [0, 0]; // Default for empty text -} - -export function offsetToLogicalPos( - text: string, - offset: number, -): [number, number] { - let row = 0; - let col = 0; - let currentOffset = 0; - - if (offset === 0) return [0, 0]; - - const lines = text.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineLength = cpLen(line); - const lineLengthWithNewline = lineLength + (i < lines.length - 1 ? 1 : 0); - - if (offset <= currentOffset + lineLength) { - // Check against lineLength first - row = i; - col = offset - currentOffset; - return [row, col]; - } else if (offset <= currentOffset + lineLengthWithNewline) { - // Check if offset is the newline itself - row = i; - col = lineLength; // Position cursor at the end of the current line content - // If the offset IS the newline, and it's not the last line, advance to next line, col 0 - if ( - offset === currentOffset + lineLengthWithNewline && - i < lines.length - 1 - ) { - return [i + 1, 0]; - } - return [row, col]; // Otherwise, it's at the end of the current line content - } - currentOffset += lineLengthWithNewline; - } - - // If offset is beyond the text length, place cursor at the end of the last line - // or [0,0] if text is empty - if (lines.length > 0) { - row = lines.length - 1; - col = cpLen(lines[row]); - } else { - row = 0; - col = 0; - } - return [row, col]; -} - -/** - * Converts logical row/col position to absolute text offset - * Inverse operation of offsetToLogicalPos - */ -export function logicalPosToOffset( - lines: string[], - row: number, - col: number, -): number { - let offset = 0; - - // Clamp row to valid range - const actualRow = Math.min(row, lines.length - 1); - - // Add lengths of all lines before the target row - for (let i = 0; i < actualRow; i++) { - offset += cpLen(lines[i]) + 1; // +1 for newline - } - - // Add column offset within the target row - if (actualRow >= 0 && actualRow < lines.length) { - offset += Math.min(col, cpLen(lines[actualRow])); - } - - return offset; -} - -export interface VisualLayout { - visualLines: string[]; - // For each logical line, an array of [visualLineIndex, startColInLogical] - logicalToVisualMap: Array>; - // For each visual line, its [logicalLineIndex, startColInLogical] - visualToLogicalMap: Array<[number, number]>; -} - -// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates. -// This is an expensive operation and should be memoized. -function calculateLayout( - logicalLines: string[], - viewportWidth: number, -): VisualLayout { - const visualLines: string[] = []; - const logicalToVisualMap: Array> = []; - const visualToLogicalMap: Array<[number, number]> = []; - - logicalLines.forEach((logLine, logIndex) => { - logicalToVisualMap[logIndex] = []; - if (logLine.length === 0) { - // Handle empty logical line - logicalToVisualMap[logIndex].push([visualLines.length, 0]); - visualToLogicalMap.push([logIndex, 0]); - visualLines.push(''); - } else { - // Non-empty logical line - let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index) - const codePointsInLogLine = toCodePoints(logLine); - - while (currentPosInLogLine < codePointsInLogLine.length) { - let currentChunk = ''; - let currentChunkVisualWidth = 0; - let numCodePointsInChunk = 0; - let lastWordBreakPoint = -1; // Index in codePointsInLogLine for word break - let numCodePointsAtLastWordBreak = 0; - - // Iterate through code points to build the current visual line (chunk) - for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) { - const char = codePointsInLogLine[i]; - const charVisualWidth = getCachedStringWidth(char); - - if (currentChunkVisualWidth + charVisualWidth > viewportWidth) { - // Character would exceed viewport width - if ( - lastWordBreakPoint !== -1 && - numCodePointsAtLastWordBreak > 0 && - currentPosInLogLine + numCodePointsAtLastWordBreak < i - ) { - // We have a valid word break point to use, and it's not the start of the current segment - currentChunk = codePointsInLogLine - .slice( - currentPosInLogLine, - currentPosInLogLine + numCodePointsAtLastWordBreak, - ) - .join(''); - numCodePointsInChunk = numCodePointsAtLastWordBreak; - } else { - // No word break, or word break is at the start of this potential chunk, or word break leads to empty chunk. - // Hard break: take characters up to viewportWidth, or just the current char if it alone is too wide. - if ( - numCodePointsInChunk === 0 && - charVisualWidth > viewportWidth - ) { - // Single character is wider than viewport, take it anyway - currentChunk = char; - numCodePointsInChunk = 1; - } else if ( - numCodePointsInChunk === 0 && - charVisualWidth <= viewportWidth - ) { - // This case should ideally be caught by the next iteration if the char fits. - // If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line), - // then numCodePointsInChunk would not be 0. - // This branch means the current char *itself* doesn't fit an empty line, which is handled by the above. - // If we are here, it means the loop should break and the current chunk (which is empty) is finalized. - } - } - break; // Break from inner loop to finalize this chunk - } - - currentChunk += char; - currentChunkVisualWidth += charVisualWidth; - numCodePointsInChunk++; - - // Check for word break opportunity (space) - if (char === ' ') { - lastWordBreakPoint = i; // Store code point index of the space - // Store the state *before* adding the space, if we decide to break here. - numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; // Chars *before* the space - } - } - - // If the inner loop completed without breaking (i.e., remaining text fits) - // or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line) - if ( - numCodePointsInChunk === 0 && - currentPosInLogLine < codePointsInLogLine.length - ) { - // This can happen if the very first character considered for a new visual line is wider than the viewport. - // In this case, we take that single character. - const firstChar = codePointsInLogLine[currentPosInLogLine]; - currentChunk = firstChar; - numCodePointsInChunk = 1; // Ensure we advance - } - - // If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line, - // it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop. - if ( - numCodePointsInChunk === 0 && - currentPosInLogLine < codePointsInLogLine.length - ) { - // Force advance by one character to prevent infinite loop if something went wrong - currentChunk = codePointsInLogLine[currentPosInLogLine]; - numCodePointsInChunk = 1; - } - - logicalToVisualMap[logIndex].push([ - visualLines.length, - currentPosInLogLine, - ]); - visualToLogicalMap.push([logIndex, currentPosInLogLine]); - visualLines.push(currentChunk); - - const logicalStartOfThisChunk = currentPosInLogLine; - currentPosInLogLine += numCodePointsInChunk; - - // If the chunk processed did not consume the entire logical line, - // and the character immediately following the chunk is a space, - // advance past this space as it acted as a delimiter for word wrapping. - if ( - logicalStartOfThisChunk + numCodePointsInChunk < - codePointsInLogLine.length && - currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe - codePointsInLogLine[currentPosInLogLine] === ' ' - ) { - currentPosInLogLine++; - } - } - } - }); - - // If the entire logical text was empty, ensure there's one empty visual line. - if ( - logicalLines.length === 0 || - (logicalLines.length === 1 && logicalLines[0] === '') - ) { - if (visualLines.length === 0) { - visualLines.push(''); - if (!logicalToVisualMap[0]) logicalToVisualMap[0] = []; - logicalToVisualMap[0].push([0, 0]); - visualToLogicalMap.push([0, 0]); - } - } - - return { - visualLines, - logicalToVisualMap, - visualToLogicalMap, - }; -} - -// Calculates the visual cursor position based on a pre-calculated layout. -// This is a lightweight operation. -function calculateVisualCursorFromLayout( - layout: VisualLayout, - logicalCursor: [number, number], -): [number, number] { - const { logicalToVisualMap, visualLines } = layout; - const [logicalRow, logicalCol] = logicalCursor; - - const segmentsForLogicalLine = logicalToVisualMap[logicalRow]; - - if (!segmentsForLogicalLine || segmentsForLogicalLine.length === 0) { - // This can happen for an empty document. - return [0, 0]; - } - - // Find the segment where the logical column fits. - // The segments are sorted by startColInLogical. - let targetSegmentIndex = segmentsForLogicalLine.findIndex( - ([, startColInLogical], index) => { - const nextStartColInLogical = - index + 1 < segmentsForLogicalLine.length - ? segmentsForLogicalLine[index + 1][1] - : Infinity; - return ( - logicalCol >= startColInLogical && logicalCol < nextStartColInLogical - ); - }, - ); - - // If not found, it means the cursor is at the end of the logical line. - if (targetSegmentIndex === -1) { - if (logicalCol === 0) { - targetSegmentIndex = 0; - } else { - targetSegmentIndex = segmentsForLogicalLine.length - 1; - } - } - - const [visualRow, startColInLogical] = - segmentsForLogicalLine[targetSegmentIndex]; - const visualCol = logicalCol - startColInLogical; - - // The visual column should not exceed the length of the visual line. - const clampedVisualCol = Math.min( - visualCol, - cpLen(visualLines[visualRow] ?? ''), - ); - - return [visualRow, clampedVisualCol]; -} - -// --- Start of reducer logic --- - -export interface TextBufferState { - lines: string[]; - cursorRow: number; - cursorCol: number; - preferredCol: number | null; // This is visual preferred col - undoStack: UndoHistoryEntry[]; - redoStack: UndoHistoryEntry[]; - clipboard: string | null; - selectionAnchor: [number, number] | null; - viewportWidth: number; - viewportHeight: number; - visualLayout: VisualLayout; -} - -const historyLimit = 100; - -export const pushUndo = (currentState: TextBufferState): TextBufferState => { - const snapshot = { - lines: [...currentState.lines], - cursorRow: currentState.cursorRow, - cursorCol: currentState.cursorCol, - }; - const newStack = [...currentState.undoStack, snapshot]; - if (newStack.length > historyLimit) { - newStack.shift(); - } - return { ...currentState, undoStack: newStack, redoStack: [] }; -}; - -export type TextBufferAction = - | { type: 'set_text'; payload: string; pushToUndo?: boolean } - | { type: 'insert'; payload: string } - | { type: 'backspace' } - | { - type: 'move'; - payload: { - dir: Direction; - }; - } - | { - type: 'set_cursor'; - payload: { - cursorRow: number; - cursorCol: number; - preferredCol: number | null; - }; - } - | { type: 'delete' } - | { type: 'delete_word_left' } - | { type: 'delete_word_right' } - | { type: 'kill_line_right' } - | { type: 'kill_line_left' } - | { type: 'undo' } - | { type: 'redo' } - | { - type: 'replace_range'; - payload: { - startRow: number; - startCol: number; - endRow: number; - endCol: number; - text: string; - }; - } - | { type: 'move_to_offset'; payload: { offset: number } } - | { type: 'create_undo_snapshot' } - | { type: 'set_viewport'; payload: { width: number; height: number } } - | { type: 'vim_delete_word_forward'; payload: { count: number } } - | { type: 'vim_delete_word_backward'; payload: { count: number } } - | { type: 'vim_delete_word_end'; payload: { count: number } } - | { type: 'vim_change_word_forward'; payload: { count: number } } - | { type: 'vim_change_word_backward'; payload: { count: number } } - | { type: 'vim_change_word_end'; payload: { count: number } } - | { type: 'vim_delete_line'; payload: { count: number } } - | { type: 'vim_change_line'; payload: { count: number } } - | { type: 'vim_delete_to_end_of_line' } - | { type: 'vim_change_to_end_of_line' } - | { - type: 'vim_change_movement'; - payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number }; - } - // New vim actions for stateless command handling - | { type: 'vim_move_left'; payload: { count: number } } - | { type: 'vim_move_right'; payload: { count: number } } - | { type: 'vim_move_up'; payload: { count: number } } - | { type: 'vim_move_down'; payload: { count: number } } - | { type: 'vim_move_word_forward'; payload: { count: number } } - | { type: 'vim_move_word_backward'; payload: { count: number } } - | { type: 'vim_move_word_end'; payload: { count: number } } - | { type: 'vim_delete_char'; payload: { count: number } } - | { type: 'vim_insert_at_cursor' } - | { type: 'vim_append_at_cursor' } - | { type: 'vim_open_line_below' } - | { type: 'vim_open_line_above' } - | { type: 'vim_append_at_line_end' } - | { type: 'vim_insert_at_line_start' } - | { type: 'vim_move_to_line_start' } - | { type: 'vim_move_to_line_end' } - | { type: 'vim_move_to_first_nonwhitespace' } - | { type: 'vim_move_to_first_line' } - | { type: 'vim_move_to_last_line' } - | { type: 'vim_move_to_line'; payload: { lineNumber: number } } - | { type: 'vim_escape_insert_mode' }; - -export interface TextBufferOptions { - inputFilter?: (text: string) => string; - singleLine?: boolean; -} - -function textBufferReducerLogic( - state: TextBufferState, - action: TextBufferAction, - options: TextBufferOptions = {}, -): TextBufferState { - const pushUndoLocal = pushUndo; - - const currentLine = (r: number): string => state.lines[r] ?? ''; - const currentLineLen = (r: number): number => cpLen(currentLine(r)); - - switch (action.type) { - case 'set_text': { - let nextState = state; - if (action.pushToUndo !== false) { - nextState = pushUndoLocal(state); - } - const newContentLines = action.payload - .replace(/\r\n?/g, '\n') - .split('\n'); - const lines = newContentLines.length === 0 ? [''] : newContentLines; - const lastNewLineIndex = lines.length - 1; - return { - ...nextState, - lines, - cursorRow: lastNewLineIndex, - cursorCol: cpLen(lines[lastNewLineIndex] ?? ''), - preferredCol: null, - }; - } - - case 'insert': { - const nextState = pushUndoLocal(state); - const newLines = [...nextState.lines]; - let newCursorRow = nextState.cursorRow; - let newCursorCol = nextState.cursorCol; - - const currentLine = (r: number) => newLines[r] ?? ''; - - let payload = action.payload; - if (options.singleLine) { - payload = payload.replace(/[\r\n]/g, ''); - } - if (options.inputFilter) { - payload = options.inputFilter(payload); - } - - if (payload.length === 0) { - return state; - } - - const str = stripUnsafeCharacters( - payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), - ); - const parts = str.split('\n'); - const lineContent = currentLine(newCursorRow); - const before = cpSlice(lineContent, 0, newCursorCol); - const after = cpSlice(lineContent, newCursorCol); - - if (parts.length > 1) { - newLines[newCursorRow] = before + parts[0]; - const remainingParts = parts.slice(1); - const lastPartOriginal = remainingParts.pop() ?? ''; - newLines.splice(newCursorRow + 1, 0, ...remainingParts); - newLines.splice( - newCursorRow + parts.length - 1, - 0, - lastPartOriginal + after, - ); - newCursorRow = newCursorRow + parts.length - 1; - newCursorCol = cpLen(lastPartOriginal); - } else { - newLines[newCursorRow] = before + parts[0] + after; - newCursorCol = cpLen(before) + cpLen(parts[0]); - } - - return { - ...nextState, - lines: newLines, - cursorRow: newCursorRow, - cursorCol: newCursorCol, - preferredCol: null, - }; - } - - case 'backspace': { - const nextState = pushUndoLocal(state); - const newLines = [...nextState.lines]; - let newCursorRow = nextState.cursorRow; - let newCursorCol = nextState.cursorCol; - - const currentLine = (r: number) => newLines[r] ?? ''; - - if (newCursorCol === 0 && newCursorRow === 0) return state; - - if (newCursorCol > 0) { - const lineContent = currentLine(newCursorRow); - newLines[newCursorRow] = - cpSlice(lineContent, 0, newCursorCol - 1) + - cpSlice(lineContent, newCursorCol); - newCursorCol--; - } else if (newCursorRow > 0) { - const prevLineContent = currentLine(newCursorRow - 1); - const currentLineContentVal = currentLine(newCursorRow); - const newCol = cpLen(prevLineContent); - newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal; - newLines.splice(newCursorRow, 1); - newCursorRow--; - newCursorCol = newCol; - } - - return { - ...nextState, - lines: newLines, - cursorRow: newCursorRow, - cursorCol: newCursorCol, - preferredCol: null, - }; - } - - case 'set_viewport': { - const { width, height } = action.payload; - if (width === state.viewportWidth && height === state.viewportHeight) { - return state; - } - return { - ...state, - viewportWidth: width, - viewportHeight: height, - }; - } - - case 'move': { - const { dir } = action.payload; - const { cursorRow, cursorCol, lines, visualLayout, preferredCol } = state; - - // Visual movements - if ( - dir === 'left' || - dir === 'right' || - dir === 'up' || - dir === 'down' || - dir === 'home' || - dir === 'end' - ) { - const visualCursor = calculateVisualCursorFromLayout(visualLayout, [ - cursorRow, - cursorCol, - ]); - const { visualLines, visualToLogicalMap } = visualLayout; - - let newVisualRow = visualCursor[0]; - let newVisualCol = visualCursor[1]; - let newPreferredCol = preferredCol; - - const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? ''); - - switch (dir) { - case 'left': - newPreferredCol = null; - if (newVisualCol > 0) { - newVisualCol--; - } else if (newVisualRow > 0) { - newVisualRow--; - newVisualCol = cpLen(visualLines[newVisualRow] ?? ''); - } - break; - case 'right': - newPreferredCol = null; - if (newVisualCol < currentVisLineLen) { - newVisualCol++; - } else if (newVisualRow < visualLines.length - 1) { - newVisualRow++; - newVisualCol = 0; - } - break; - case 'up': - if (newVisualRow > 0) { - if (newPreferredCol === null) newPreferredCol = newVisualCol; - newVisualRow--; - newVisualCol = clamp( - newPreferredCol, - 0, - cpLen(visualLines[newVisualRow] ?? ''), - ); - } - break; - case 'down': - if (newVisualRow < visualLines.length - 1) { - if (newPreferredCol === null) newPreferredCol = newVisualCol; - newVisualRow++; - newVisualCol = clamp( - newPreferredCol, - 0, - cpLen(visualLines[newVisualRow] ?? ''), - ); - } - break; - case 'home': - newPreferredCol = null; - newVisualCol = 0; - break; - case 'end': - newPreferredCol = null; - newVisualCol = currentVisLineLen; - break; - default: { - const exhaustiveCheck: never = dir; - console.error( - `Unknown visual movement direction: ${exhaustiveCheck}`, - ); - return state; - } - } - - if (visualToLogicalMap[newVisualRow]) { - const [logRow, logStartCol] = visualToLogicalMap[newVisualRow]; - return { - ...state, - cursorRow: logRow, - cursorCol: clamp( - logStartCol + newVisualCol, - 0, - cpLen(lines[logRow] ?? ''), - ), - preferredCol: newPreferredCol, - }; - } - return state; - } - - // Logical movements - switch (dir) { - case 'wordLeft': { - if (cursorCol === 0 && cursorRow === 0) return state; - - let newCursorRow = cursorRow; - let newCursorCol = cursorCol; - - if (cursorCol === 0) { - newCursorRow--; - newCursorCol = cpLen(lines[newCursorRow] ?? ''); - } else { - const lineContent = lines[cursorRow]; - const arr = toCodePoints(lineContent); - let start = cursorCol; - let onlySpaces = true; - for (let i = 0; i < start; i++) { - if (isWordChar(arr[i])) { - onlySpaces = false; - break; - } - } - if (onlySpaces && start > 0) { - start--; - } else { - while (start > 0 && !isWordChar(arr[start - 1])) start--; - while (start > 0 && isWordChar(arr[start - 1])) start--; - } - newCursorCol = start; - } - return { - ...state, - cursorRow: newCursorRow, - cursorCol: newCursorCol, - preferredCol: null, - }; - } - case 'wordRight': { - if ( - cursorRow === lines.length - 1 && - cursorCol === cpLen(lines[cursorRow] ?? '') - ) { - return state; - } - - let newCursorRow = cursorRow; - let newCursorCol = cursorCol; - const lineContent = lines[cursorRow] ?? ''; - const arr = toCodePoints(lineContent); - - if (cursorCol >= arr.length) { - newCursorRow++; - newCursorCol = 0; - } else { - let end = cursorCol; - while (end < arr.length && !isWordChar(arr[end])) end++; - while (end < arr.length && isWordChar(arr[end])) end++; - newCursorCol = end; - } - return { - ...state, - cursorRow: newCursorRow, - cursorCol: newCursorCol, - preferredCol: null, - }; - } - default: - return state; - } - } - - case 'set_cursor': { - return { - ...state, - ...action.payload, - }; - } - - case 'delete': { - const { cursorRow, cursorCol, lines } = state; - const lineContent = currentLine(cursorRow); - if (cursorCol < currentLineLen(cursorRow)) { - const nextState = pushUndoLocal(state); - const newLines = [...nextState.lines]; - newLines[cursorRow] = - cpSlice(lineContent, 0, cursorCol) + - cpSlice(lineContent, cursorCol + 1); - return { - ...nextState, - lines: newLines, - preferredCol: null, - }; - } else if (cursorRow < lines.length - 1) { - const nextState = pushUndoLocal(state); - const nextLineContent = currentLine(cursorRow + 1); - const newLines = [...nextState.lines]; - newLines[cursorRow] = lineContent + nextLineContent; - newLines.splice(cursorRow + 1, 1); - return { - ...nextState, - lines: newLines, - preferredCol: null, - }; - } - return state; - } - - case 'delete_word_left': { - const { cursorRow, cursorCol } = state; - if (cursorCol === 0 && cursorRow === 0) return state; - - const nextState = pushUndoLocal(state); - const newLines = [...nextState.lines]; - let newCursorRow = cursorRow; - let newCursorCol = cursorCol; - - if (newCursorCol > 0) { - const lineContent = currentLine(newCursorRow); - const prevWordStart = findPrevWordStartInLine( - lineContent, - newCursorCol, - ); - const start = prevWordStart === null ? 0 : prevWordStart; - newLines[newCursorRow] = - cpSlice(lineContent, 0, start) + cpSlice(lineContent, newCursorCol); - newCursorCol = start; - } else { - // Act as a backspace - const prevLineContent = currentLine(cursorRow - 1); - const currentLineContentVal = currentLine(cursorRow); - const newCol = cpLen(prevLineContent); - newLines[cursorRow - 1] = prevLineContent + currentLineContentVal; - newLines.splice(cursorRow, 1); - newCursorRow--; - newCursorCol = newCol; - } - - return { - ...nextState, - lines: newLines, - cursorRow: newCursorRow, - cursorCol: newCursorCol, - preferredCol: null, - }; - } - - case 'delete_word_right': { - const { cursorRow, cursorCol, lines } = state; - const lineContent = currentLine(cursorRow); - const lineLen = cpLen(lineContent); - - if (cursorCol >= lineLen && cursorRow === lines.length - 1) { - return state; - } - - const nextState = pushUndoLocal(state); - const newLines = [...nextState.lines]; - - if (cursorCol >= lineLen) { - // Act as a delete, joining with the next line - const nextLineContent = currentLine(cursorRow + 1); - newLines[cursorRow] = lineContent + nextLineContent; - newLines.splice(cursorRow + 1, 1); - } else { - const nextWordStart = findNextWordStartInLine(lineContent, cursorCol); - const end = nextWordStart === null ? lineLen : nextWordStart; - newLines[cursorRow] = - cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end); - } - - return { - ...nextState, - lines: newLines, - preferredCol: null, - }; - } - - case 'kill_line_right': { - const { cursorRow, cursorCol, lines } = state; - const lineContent = currentLine(cursorRow); - if (cursorCol < currentLineLen(cursorRow)) { - const nextState = pushUndoLocal(state); - const newLines = [...nextState.lines]; - newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol); - return { - ...nextState, - lines: newLines, - }; - } else if (cursorRow < lines.length - 1) { - // Act as a delete - const nextState = pushUndoLocal(state); - const nextLineContent = currentLine(cursorRow + 1); - const newLines = [...nextState.lines]; - newLines[cursorRow] = lineContent + nextLineContent; - newLines.splice(cursorRow + 1, 1); - return { - ...nextState, - lines: newLines, - preferredCol: null, - }; - } - return state; - } - - case 'kill_line_left': { - const { cursorRow, cursorCol } = state; - if (cursorCol > 0) { - const nextState = pushUndoLocal(state); - const lineContent = currentLine(cursorRow); - const newLines = [...nextState.lines]; - newLines[cursorRow] = cpSlice(lineContent, cursorCol); - return { - ...nextState, - lines: newLines, - cursorCol: 0, - preferredCol: null, - }; - } - return state; - } - - case 'undo': { - const stateToRestore = state.undoStack[state.undoStack.length - 1]; - if (!stateToRestore) return state; - - const currentSnapshot = { - lines: [...state.lines], - cursorRow: state.cursorRow, - cursorCol: state.cursorCol, - }; - return { - ...state, - ...stateToRestore, - undoStack: state.undoStack.slice(0, -1), - redoStack: [...state.redoStack, currentSnapshot], - }; - } - - case 'redo': { - const stateToRestore = state.redoStack[state.redoStack.length - 1]; - if (!stateToRestore) return state; - - const currentSnapshot = { - lines: [...state.lines], - cursorRow: state.cursorRow, - cursorCol: state.cursorCol, - }; - return { - ...state, - ...stateToRestore, - redoStack: state.redoStack.slice(0, -1), - undoStack: [...state.undoStack, currentSnapshot], - }; - } - - case 'replace_range': { - const { startRow, startCol, endRow, endCol, text } = action.payload; - const nextState = pushUndoLocal(state); - return replaceRangeInternal( - nextState, - startRow, - startCol, - endRow, - endCol, - text, - ); - } - - case 'move_to_offset': { - const { offset } = action.payload; - const [newRow, newCol] = offsetToLogicalPos( - state.lines.join('\n'), - offset, - ); - return { - ...state, - cursorRow: newRow, - cursorCol: newCol, - preferredCol: null, - }; - } - - case 'create_undo_snapshot': { - return pushUndoLocal(state); - } - - // Vim-specific operations - case 'vim_delete_word_forward': - case 'vim_delete_word_backward': - case 'vim_delete_word_end': - case 'vim_change_word_forward': - case 'vim_change_word_backward': - case 'vim_change_word_end': - case 'vim_delete_line': - case 'vim_change_line': - case 'vim_delete_to_end_of_line': - case 'vim_change_to_end_of_line': - case 'vim_change_movement': - case 'vim_move_left': - case 'vim_move_right': - case 'vim_move_up': - case 'vim_move_down': - case 'vim_move_word_forward': - case 'vim_move_word_backward': - case 'vim_move_word_end': - case 'vim_delete_char': - case 'vim_insert_at_cursor': - case 'vim_append_at_cursor': - case 'vim_open_line_below': - case 'vim_open_line_above': - case 'vim_append_at_line_end': - case 'vim_insert_at_line_start': - case 'vim_move_to_line_start': - case 'vim_move_to_line_end': - case 'vim_move_to_first_nonwhitespace': - case 'vim_move_to_first_line': - case 'vim_move_to_last_line': - case 'vim_move_to_line': - case 'vim_escape_insert_mode': - return handleVimAction(state, action as VimAction); - - default: { - const exhaustiveCheck: never = action; - console.error(`Unknown action encountered: ${exhaustiveCheck}`); - return state; - } - } -} - -export function textBufferReducer( - state: TextBufferState, - action: TextBufferAction, - options: TextBufferOptions = {}, -): TextBufferState { - const newState = textBufferReducerLogic(state, action, options); - - if ( - newState.lines !== state.lines || - newState.viewportWidth !== state.viewportWidth - ) { - return { - ...newState, - visualLayout: calculateLayout(newState.lines, newState.viewportWidth), - }; - } - - return newState; -} - -// --- End of reducer logic --- - -export function useTextBuffer({ - initialText = '', - initialCursorOffset = 0, - viewport, - stdin, - setRawMode, - onChange, - isValidPath, - shellModeActive = false, - inputFilter, - singleLine = false, -}: UseTextBufferProps): TextBuffer { - const initialState = useMemo((): TextBufferState => { - const lines = initialText.split('\n'); - const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition( - lines.length === 0 ? [''] : lines, - initialCursorOffset, - ); - const visualLayout = calculateLayout( - lines.length === 0 ? [''] : lines, - viewport.width, - ); - return { - lines: lines.length === 0 ? [''] : lines, - cursorRow: initialCursorRow, - cursorCol: initialCursorCol, - preferredCol: null, - undoStack: [], - redoStack: [], - clipboard: null, - selectionAnchor: null, - viewportWidth: viewport.width, - viewportHeight: viewport.height, - visualLayout, - }; - }, [initialText, initialCursorOffset, viewport.width, viewport.height]); - - const [state, dispatch] = useReducer( - (s: TextBufferState, a: TextBufferAction) => - textBufferReducer(s, a, { inputFilter, singleLine }), - initialState, - ); - const { - lines, - cursorRow, - cursorCol, - preferredCol, - selectionAnchor, - visualLayout, - } = state; - - const text = useMemo(() => lines.join('\n'), [lines]); - - const visualCursor = useMemo( - () => calculateVisualCursorFromLayout(visualLayout, [cursorRow, cursorCol]), - [visualLayout, cursorRow, cursorCol], - ); - - const { visualLines, visualToLogicalMap } = visualLayout; - - const [visualScrollRow, setVisualScrollRow] = useState(0); - - useEffect(() => { - if (onChange) { - onChange(text); - } - }, [text, onChange]); - - useEffect(() => { - dispatch({ - type: 'set_viewport', - payload: { width: viewport.width, height: viewport.height }, - }); - }, [viewport.width, viewport.height]); - - // Update visual scroll (vertical) - useEffect(() => { - const { height } = viewport; - const totalVisualLines = visualLines.length; - const maxScrollStart = Math.max(0, totalVisualLines - height); - let newVisualScrollRow = visualScrollRow; - - if (visualCursor[0] < visualScrollRow) { - newVisualScrollRow = visualCursor[0]; - } else if (visualCursor[0] >= visualScrollRow + height) { - newVisualScrollRow = visualCursor[0] - height + 1; - } - - // When the number of visual lines shrinks (e.g., after widening the viewport), - // ensure scroll never starts beyond the last valid start so we can render a full window. - newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart); - - if (newVisualScrollRow !== visualScrollRow) { - setVisualScrollRow(newVisualScrollRow); - } - }, [visualCursor, visualScrollRow, viewport, visualLines.length]); - - const insert = useCallback( - (ch: string, { paste = false }: { paste?: boolean } = {}): void => { - if (!singleLine && /[\n\r]/.test(ch)) { - dispatch({ type: 'insert', payload: ch }); - return; - } - - const minLengthToInferAsDragDrop = 3; - if ( - ch.length >= minLengthToInferAsDragDrop && - !shellModeActive && - paste - ) { - let potentialPath = ch.trim(); - const quoteMatch = potentialPath.match(/^'(.*)'$/); - if (quoteMatch) { - potentialPath = quoteMatch[1]; - } - - potentialPath = potentialPath.trim(); - if (isValidPath(unescapePath(potentialPath))) { - ch = `@${potentialPath} `; - } - } - - let currentText = ''; - for (const char of toCodePoints(ch)) { - if (char.codePointAt(0) === 127) { - if (currentText.length > 0) { - dispatch({ type: 'insert', payload: currentText }); - currentText = ''; - } - dispatch({ type: 'backspace' }); - } else { - currentText += char; - } - } - if (currentText.length > 0) { - dispatch({ type: 'insert', payload: currentText }); - } - }, - [isValidPath, shellModeActive, singleLine], - ); - - const newline = useCallback((): void => { - if (singleLine) { - return; - } - dispatch({ type: 'insert', payload: '\n' }); - }, [singleLine]); - - const backspace = useCallback((): void => { - dispatch({ type: 'backspace' }); - }, []); - - const del = useCallback((): void => { - dispatch({ type: 'delete' }); - }, []); - - const move = useCallback( - (dir: Direction): void => { - dispatch({ type: 'move', payload: { dir } }); - }, - [dispatch], - ); - - const undo = useCallback((): void => { - dispatch({ type: 'undo' }); - }, []); - - const redo = useCallback((): void => { - dispatch({ type: 'redo' }); - }, []); - - const setText = useCallback((newText: string): void => { - dispatch({ type: 'set_text', payload: newText }); - }, []); - - const deleteWordLeft = useCallback((): void => { - dispatch({ type: 'delete_word_left' }); - }, []); - - const deleteWordRight = useCallback((): void => { - dispatch({ type: 'delete_word_right' }); - }, []); - - const killLineRight = useCallback((): void => { - dispatch({ type: 'kill_line_right' }); - }, []); - - const killLineLeft = useCallback((): void => { - dispatch({ type: 'kill_line_left' }); - }, []); - - // Vim-specific operations - const vimDeleteWordForward = useCallback((count: number): void => { - dispatch({ type: 'vim_delete_word_forward', payload: { count } }); - }, []); - - const vimDeleteWordBackward = useCallback((count: number): void => { - dispatch({ type: 'vim_delete_word_backward', payload: { count } }); - }, []); - - const vimDeleteWordEnd = useCallback((count: number): void => { - dispatch({ type: 'vim_delete_word_end', payload: { count } }); - }, []); - - const vimChangeWordForward = useCallback((count: number): void => { - dispatch({ type: 'vim_change_word_forward', payload: { count } }); - }, []); - - const vimChangeWordBackward = useCallback((count: number): void => { - dispatch({ type: 'vim_change_word_backward', payload: { count } }); - }, []); - - const vimChangeWordEnd = useCallback((count: number): void => { - dispatch({ type: 'vim_change_word_end', payload: { count } }); - }, []); - - const vimDeleteLine = useCallback((count: number): void => { - dispatch({ type: 'vim_delete_line', payload: { count } }); - }, []); - - const vimChangeLine = useCallback((count: number): void => { - dispatch({ type: 'vim_change_line', payload: { count } }); - }, []); - - const vimDeleteToEndOfLine = useCallback((): void => { - dispatch({ type: 'vim_delete_to_end_of_line' }); - }, []); - - const vimChangeToEndOfLine = useCallback((): void => { - dispatch({ type: 'vim_change_to_end_of_line' }); - }, []); - - const vimChangeMovement = useCallback( - (movement: 'h' | 'j' | 'k' | 'l', count: number): void => { - dispatch({ type: 'vim_change_movement', payload: { movement, count } }); - }, - [], - ); - - // New vim navigation and operation methods - const vimMoveLeft = useCallback((count: number): void => { - dispatch({ type: 'vim_move_left', payload: { count } }); - }, []); - - const vimMoveRight = useCallback((count: number): void => { - dispatch({ type: 'vim_move_right', payload: { count } }); - }, []); - - const vimMoveUp = useCallback((count: number): void => { - dispatch({ type: 'vim_move_up', payload: { count } }); - }, []); - - const vimMoveDown = useCallback((count: number): void => { - dispatch({ type: 'vim_move_down', payload: { count } }); - }, []); - - const vimMoveWordForward = useCallback((count: number): void => { - dispatch({ type: 'vim_move_word_forward', payload: { count } }); - }, []); - - const vimMoveWordBackward = useCallback((count: number): void => { - dispatch({ type: 'vim_move_word_backward', payload: { count } }); - }, []); - - const vimMoveWordEnd = useCallback((count: number): void => { - dispatch({ type: 'vim_move_word_end', payload: { count } }); - }, []); - - const vimDeleteChar = useCallback((count: number): void => { - dispatch({ type: 'vim_delete_char', payload: { count } }); - }, []); - - const vimInsertAtCursor = useCallback((): void => { - dispatch({ type: 'vim_insert_at_cursor' }); - }, []); - - const vimAppendAtCursor = useCallback((): void => { - dispatch({ type: 'vim_append_at_cursor' }); - }, []); - - const vimOpenLineBelow = useCallback((): void => { - dispatch({ type: 'vim_open_line_below' }); - }, []); - - const vimOpenLineAbove = useCallback((): void => { - dispatch({ type: 'vim_open_line_above' }); - }, []); - - const vimAppendAtLineEnd = useCallback((): void => { - dispatch({ type: 'vim_append_at_line_end' }); - }, []); - - const vimInsertAtLineStart = useCallback((): void => { - dispatch({ type: 'vim_insert_at_line_start' }); - }, []); - - const vimMoveToLineStart = useCallback((): void => { - dispatch({ type: 'vim_move_to_line_start' }); - }, []); - - const vimMoveToLineEnd = useCallback((): void => { - dispatch({ type: 'vim_move_to_line_end' }); - }, []); - - const vimMoveToFirstNonWhitespace = useCallback((): void => { - dispatch({ type: 'vim_move_to_first_nonwhitespace' }); - }, []); - - const vimMoveToFirstLine = useCallback((): void => { - dispatch({ type: 'vim_move_to_first_line' }); - }, []); - - const vimMoveToLastLine = useCallback((): void => { - dispatch({ type: 'vim_move_to_last_line' }); - }, []); - - const vimMoveToLine = useCallback((lineNumber: number): void => { - dispatch({ type: 'vim_move_to_line', payload: { lineNumber } }); - }, []); - - const vimEscapeInsertMode = useCallback((): void => { - dispatch({ type: 'vim_escape_insert_mode' }); - }, []); - - const openInExternalEditor = useCallback( - async (opts: { editor?: string } = {}): Promise => { - const editor = - opts.editor ?? - process.env['VISUAL'] ?? - process.env['EDITOR'] ?? - (process.platform === 'win32' ? 'notepad' : 'vi'); - const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); - const filePath = pathMod.join(tmpDir, 'buffer.txt'); - fs.writeFileSync(filePath, text, 'utf8'); - - dispatch({ type: 'create_undo_snapshot' }); - - const wasRaw = stdin?.isRaw ?? false; - try { - setRawMode?.(false); - const { status, error } = spawnSync(editor, [filePath], { - stdio: 'inherit', - }); - if (error) throw error; - if (typeof status === 'number' && status !== 0) - throw new Error(`External editor exited with status ${status}`); - - let newText = fs.readFileSync(filePath, 'utf8'); - newText = newText.replace(/\r\n?/g, '\n'); - dispatch({ type: 'set_text', payload: newText, pushToUndo: false }); - } catch (err) { - console.error('[useTextBuffer] external editor error', err); - } finally { - if (wasRaw) setRawMode?.(true); - try { - fs.unlinkSync(filePath); - } catch { - /* ignore */ - } - try { - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } - } - }, - [text, stdin, setRawMode], - ); - - const handleInput = useCallback( - (key: Key): void => { - const { sequence: input } = key; - - if (key.paste) { - // Do not do any other processing on pastes so ensure we handle them - // before all other cases. - insert(input, { paste: key.paste }); - return; - } - - if ( - !singleLine && - (key.name === 'return' || - input === '\r' || - input === '\n' || - input === '\\r') // VSCode terminal represents shift + enter this way - ) - newline(); - else if (key.name === 'left' && !key.meta && !key.ctrl) move('left'); - else if (key.ctrl && key.name === 'b') move('left'); - else if (key.name === 'right' && !key.meta && !key.ctrl) move('right'); - else if (key.ctrl && key.name === 'f') move('right'); - else if (key.name === 'up') move('up'); - else if (key.name === 'down') move('down'); - else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft'); - else if (key.meta && key.name === 'b') move('wordLeft'); - else if ((key.ctrl || key.meta) && key.name === 'right') - move('wordRight'); - else if (key.meta && key.name === 'f') move('wordRight'); - else if (key.name === 'home') move('home'); - else if (key.ctrl && key.name === 'a') move('home'); - else if (key.name === 'end') move('end'); - else if (key.ctrl && key.name === 'e') move('end'); - else if (key.ctrl && key.name === 'w') deleteWordLeft(); - else if ( - (key.meta || key.ctrl) && - (key.name === 'backspace' || input === '\x7f') - ) - deleteWordLeft(); - else if ((key.meta || key.ctrl) && key.name === 'delete') - deleteWordRight(); - else if ( - key.name === 'backspace' || - input === '\x7f' || - (key.ctrl && key.name === 'h') - ) - backspace(); - else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del(); - else if (key.ctrl && !key.shift && key.name === 'z') undo(); - else if (key.ctrl && key.shift && key.name === 'z') redo(); - else if (key.insertable) { - insert(input, { paste: key.paste }); - } - }, - [ - newline, - move, - deleteWordLeft, - deleteWordRight, - backspace, - del, - insert, - undo, - redo, - singleLine, - ], - ); - - const renderedVisualLines = useMemo( - () => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height), - [visualLines, visualScrollRow, viewport.height], - ); - - const replaceRange = useCallback( - ( - startRow: number, - startCol: number, - endRow: number, - endCol: number, - text: string, - ): void => { - dispatch({ - type: 'replace_range', - payload: { startRow, startCol, endRow, endCol, text }, - }); - }, - [], - ); - - const replaceRangeByOffset = useCallback( - (startOffset: number, endOffset: number, replacementText: string): void => { - const [startRow, startCol] = offsetToLogicalPos(text, startOffset); - const [endRow, endCol] = offsetToLogicalPos(text, endOffset); - replaceRange(startRow, startCol, endRow, endCol, replacementText); - }, - [text, replaceRange], - ); - - const moveToOffset = useCallback((offset: number): void => { - dispatch({ type: 'move_to_offset', payload: { offset } }); - }, []); - - const moveToVisualPosition = useCallback( - (visRow: number, visCol: number): void => { - const { visualLines, visualToLogicalMap } = visualLayout; - // Clamp visRow to valid range - const clampedVisRow = Math.max( - 0, - Math.min(visRow, visualLines.length - 1), - ); - const visualLine = visualLines[clampedVisRow] || ''; - // Clamp visCol to the length of the visual line - const clampedVisCol = Math.max(0, Math.min(visCol, cpLen(visualLine))); - - if (visualToLogicalMap[clampedVisRow]) { - const [logRow, logStartCol] = visualToLogicalMap[clampedVisRow]; - const newCursorRow = logRow; - const newCursorCol = logStartCol + clampedVisCol; - - dispatch({ - type: 'set_cursor', - payload: { - cursorRow: newCursorRow, - cursorCol: newCursorCol, - preferredCol: clampedVisCol, - }, - }); - } - }, - [visualLayout], - ); - - const returnValue: TextBuffer = useMemo( - () => ({ - lines, - text, - cursor: [cursorRow, cursorCol], - preferredCol, - selectionAnchor, - - allVisualLines: visualLines, - viewportVisualLines: renderedVisualLines, - visualCursor, - visualScrollRow, - visualToLogicalMap, - - setText, - insert, - newline, - backspace, - del, - move, - undo, - redo, - replaceRange, - replaceRangeByOffset, - moveToOffset, - moveToVisualPosition, - deleteWordLeft, - deleteWordRight, - - killLineRight, - killLineLeft, - handleInput, - openInExternalEditor, - // Vim-specific operations - vimDeleteWordForward, - vimDeleteWordBackward, - vimDeleteWordEnd, - vimChangeWordForward, - vimChangeWordBackward, - vimChangeWordEnd, - vimDeleteLine, - vimChangeLine, - vimDeleteToEndOfLine, - vimChangeToEndOfLine, - vimChangeMovement, - vimMoveLeft, - vimMoveRight, - vimMoveUp, - vimMoveDown, - vimMoveWordForward, - vimMoveWordBackward, - vimMoveWordEnd, - vimDeleteChar, - vimInsertAtCursor, - vimAppendAtCursor, - vimOpenLineBelow, - vimOpenLineAbove, - vimAppendAtLineEnd, - vimInsertAtLineStart, - vimMoveToLineStart, - vimMoveToLineEnd, - vimMoveToFirstNonWhitespace, - vimMoveToFirstLine, - vimMoveToLastLine, - vimMoveToLine, - vimEscapeInsertMode, - }), - [ - lines, - text, - cursorRow, - cursorCol, - preferredCol, - selectionAnchor, - visualLines, - renderedVisualLines, - visualCursor, - visualScrollRow, - setText, - insert, - newline, - backspace, - del, - move, - undo, - redo, - replaceRange, - replaceRangeByOffset, - moveToOffset, - moveToVisualPosition, - deleteWordLeft, - deleteWordRight, - killLineRight, - killLineLeft, - handleInput, - openInExternalEditor, - vimDeleteWordForward, - vimDeleteWordBackward, - vimDeleteWordEnd, - vimChangeWordForward, - vimChangeWordBackward, - vimChangeWordEnd, - vimDeleteLine, - vimChangeLine, - vimDeleteToEndOfLine, - vimChangeToEndOfLine, - vimChangeMovement, - vimMoveLeft, - vimMoveRight, - vimMoveUp, - vimMoveDown, - vimMoveWordForward, - vimMoveWordBackward, - vimMoveWordEnd, - vimDeleteChar, - vimInsertAtCursor, - vimAppendAtCursor, - vimOpenLineBelow, - vimOpenLineAbove, - vimAppendAtLineEnd, - vimInsertAtLineStart, - vimMoveToLineStart, - vimMoveToLineEnd, - vimMoveToFirstNonWhitespace, - vimMoveToFirstLine, - vimMoveToLastLine, - vimMoveToLine, - vimEscapeInsertMode, - visualToLogicalMap, - ], - ); - return returnValue; -} - -export interface TextBuffer { - // State - lines: string[]; // Logical lines - text: string; - cursor: [number, number]; // Logical cursor [row, col] - /** - * When the user moves the caret vertically we try to keep their original - * horizontal column even when passing through shorter lines. We remember - * that *preferred* column in this field while the user is still travelling - * vertically. Any explicit horizontal movement resets the preference. - */ - preferredCol: number | null; // Preferred visual column - selectionAnchor: [number, number] | null; // Logical selection anchor - - // Visual state (handles wrapping) - allVisualLines: string[]; // All visual lines for the current text and viewport width. - viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height - visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines - visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line) - /** - * For each visual line (by absolute index in allVisualLines) provides a tuple - * [logicalLineIndex, startColInLogical] that maps where that visual line - * begins within the logical buffer. Indices are code-point based. - */ - visualToLogicalMap: Array<[number, number]>; - - // Actions - - /** - * Replaces the entire buffer content with the provided text. - * The operation is undoable. - */ - setText: (text: string) => void; - /** - * Insert a single character or string without newlines. - */ - insert: (ch: string, opts?: { paste?: boolean }) => void; - newline: () => void; - backspace: () => void; - del: () => void; - move: (dir: Direction) => void; - undo: () => void; - redo: () => void; - /** - * Replaces the text within the specified range with new text. - * Handles both single-line and multi-line ranges. - * - * @param startRow The starting row index (inclusive). - * @param startCol The starting column index (inclusive, code-point based). - * @param endRow The ending row index (inclusive). - * @param endCol The ending column index (exclusive, code-point based). - * @param text The new text to insert. - * @returns True if the buffer was modified, false otherwise. - */ - replaceRange: ( - startRow: number, - startCol: number, - endRow: number, - endCol: number, - text: string, - ) => void; - /** - * Delete the word to the *left* of the caret, mirroring common - * Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent - * whitespace *and* the word characters immediately preceding the caret are - * removed. If the caret is already at column‑0 this becomes a no-op. - */ - deleteWordLeft: () => void; - /** - * Delete the word to the *right* of the caret, akin to many editors' - * Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that - * follows the caret and the next contiguous run of word characters. - */ - deleteWordRight: () => void; - - /** - * Deletes text from the cursor to the end of the current line. - */ - killLineRight: () => void; - /** - * Deletes text from the start of the current line to the cursor. - */ - killLineLeft: () => void; - /** - * High level "handleInput" – receives what Ink gives us. - */ - handleInput: (key: Key) => void; - /** - * Opens the current buffer contents in the user's preferred terminal text - * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks - * until the editor exits, then reloads the file and replaces the in‑memory - * buffer with whatever the user saved. - * - * The operation is treated as a single undoable edit – we snapshot the - * previous state *once* before launching the editor so one `undo()` will - * revert the entire change set. - * - * Note: We purposefully rely on the *synchronous* spawn API so that the - * calling process genuinely waits for the editor to close before - * continuing. This mirrors Git's behaviour and simplifies downstream - * control‑flow (callers can simply `await` the Promise). - */ - openInExternalEditor: (opts?: { editor?: string }) => Promise; - - replaceRangeByOffset: ( - startOffset: number, - endOffset: number, - replacementText: string, - ) => void; - moveToOffset(offset: number): void; - moveToVisualPosition(visualRow: number, visualCol: number): void; - - // Vim-specific operations - /** - * Delete N words forward from cursor position (vim 'dw' command) - */ - vimDeleteWordForward: (count: number) => void; - /** - * Delete N words backward from cursor position (vim 'db' command) - */ - vimDeleteWordBackward: (count: number) => void; - /** - * Delete to end of N words from cursor position (vim 'de' command) - */ - vimDeleteWordEnd: (count: number) => void; - /** - * Change N words forward from cursor position (vim 'cw' command) - */ - vimChangeWordForward: (count: number) => void; - /** - * Change N words backward from cursor position (vim 'cb' command) - */ - vimChangeWordBackward: (count: number) => void; - /** - * Change to end of N words from cursor position (vim 'ce' command) - */ - vimChangeWordEnd: (count: number) => void; - /** - * Delete N lines from cursor position (vim 'dd' command) - */ - vimDeleteLine: (count: number) => void; - /** - * Change N lines from cursor position (vim 'cc' command) - */ - vimChangeLine: (count: number) => void; - /** - * Delete from cursor to end of line (vim 'D' command) - */ - vimDeleteToEndOfLine: () => void; - /** - * Change from cursor to end of line (vim 'C' command) - */ - vimChangeToEndOfLine: () => void; - /** - * Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands) - */ - vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void; - /** - * Move cursor left N times (vim 'h' command) - */ - vimMoveLeft: (count: number) => void; - /** - * Move cursor right N times (vim 'l' command) - */ - vimMoveRight: (count: number) => void; - /** - * Move cursor up N times (vim 'k' command) - */ - vimMoveUp: (count: number) => void; - /** - * Move cursor down N times (vim 'j' command) - */ - vimMoveDown: (count: number) => void; - /** - * Move cursor forward N words (vim 'w' command) - */ - vimMoveWordForward: (count: number) => void; - /** - * Move cursor backward N words (vim 'b' command) - */ - vimMoveWordBackward: (count: number) => void; - /** - * Move cursor to end of Nth word (vim 'e' command) - */ - vimMoveWordEnd: (count: number) => void; - /** - * Delete N characters at cursor (vim 'x' command) - */ - vimDeleteChar: (count: number) => void; - /** - * Enter insert mode at cursor (vim 'i' command) - */ - vimInsertAtCursor: () => void; - /** - * Enter insert mode after cursor (vim 'a' command) - */ - vimAppendAtCursor: () => void; - /** - * Open new line below and enter insert mode (vim 'o' command) - */ - vimOpenLineBelow: () => void; - /** - * Open new line above and enter insert mode (vim 'O' command) - */ - vimOpenLineAbove: () => void; - /** - * Move to end of line and enter insert mode (vim 'A' command) - */ - vimAppendAtLineEnd: () => void; - /** - * Move to first non-whitespace and enter insert mode (vim 'I' command) - */ - vimInsertAtLineStart: () => void; - /** - * Move cursor to beginning of line (vim '0' command) - */ - vimMoveToLineStart: () => void; - /** - * Move cursor to end of line (vim '$' command) - */ - vimMoveToLineEnd: () => void; - /** - * Move cursor to first non-whitespace character (vim '^' command) - */ - vimMoveToFirstNonWhitespace: () => void; - /** - * Move cursor to first line (vim 'gg' command) - */ - vimMoveToFirstLine: () => void; - /** - * Move cursor to last line (vim 'G' command) - */ - vimMoveToLastLine: () => void; - /** - * Move cursor to specific line number (vim '[N]G' command) - */ - vimMoveToLine: (lineNumber: number) => void; - /** - * Handle escape from insert mode (moves cursor left if not at line start) - */ - vimEscapeInsertMode: () => void; -} diff --git a/apps/airiscode-cli/src/config/auth.ts b/apps/airiscode-cli/src/config/auth.ts new file mode 100644 index 000000000..5445bdb40 --- /dev/null +++ b/apps/airiscode-cli/src/config/auth.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + type Config, + type ModelProvidersConfig, + type ProviderModelConfig, +} from '@airiscode/core'; +import { loadEnvironment, loadSettings, type Settings } from './settings.js'; +import { t } from '../i18n/index.js'; + +/** + * Default environment variable names for each auth type + */ +const DEFAULT_ENV_KEYS: Record = { + [AuthType.USE_OPENAI]: 'OPENAI_API_KEY', + [AuthType.USE_OLLAMA]: '', + [AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY', +}; + +/** + * Find model configuration from modelProviders by authType and modelId + */ +function findModelConfig( + modelProviders: ModelProvidersConfig | undefined, + authType: string, + modelId: string | undefined, +): ProviderModelConfig | undefined { + if (!modelProviders || !modelId) { + return undefined; + } + + const models = modelProviders[authType]; + if (!Array.isArray(models)) { + return undefined; + } + + return models.find((m) => m.id === modelId); +} + +/** + * Check if API key is available for the given auth type and model configuration. + * Prioritizes custom envKey from modelProviders over default environment variables. + */ +function hasApiKeyForAuth( + authType: string, + settings: Settings, + config?: Config, +): { + hasKey: boolean; + checkedEnvKey: string | undefined; + isExplicitEnvKey: boolean; +} { + const modelProviders = settings.modelProviders as + | ModelProvidersConfig + | undefined; + + // Use config.getModelsConfig().getModel() if available for accurate model ID resolution + // that accounts for CLI args, env vars, and settings. Fall back to settings.model.name. + const modelId = config?.getModelsConfig().getModel() ?? settings.model?.name; + + // Try to find model-specific envKey from modelProviders + const modelConfig = findModelConfig(modelProviders, authType, modelId); + if (modelConfig?.envKey) { + // Explicit envKey configured - only check this env var, no apiKey fallback + const hasKey = !!process.env[modelConfig.envKey]; + return { + hasKey, + checkedEnvKey: modelConfig.envKey, + isExplicitEnvKey: true, + }; + } + + // Using default environment variable - apiKey fallback is allowed + const defaultEnvKey = DEFAULT_ENV_KEYS[authType]; + if (defaultEnvKey) { + const hasKey = !!process.env[defaultEnvKey]; + if (hasKey) { + return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false }; + } + } + + // Also check settings.security.auth.apiKey as fallback (only for default env key) + if (settings.security?.auth?.apiKey) { + return { + hasKey: true, + checkedEnvKey: defaultEnvKey || undefined, + isExplicitEnvKey: false, + }; + } + + return { + hasKey: false, + checkedEnvKey: defaultEnvKey, + isExplicitEnvKey: false, + }; +} + +/** + * Generate API key error message based on auth check result. + * Returns null if API key is present, otherwise returns the appropriate error message. + */ +function getApiKeyError( + authMethod: string, + settings: Settings, + config?: Config, +): string | null { + const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( + authMethod, + settings, + config, + ); + if (hasKey) { + return null; + } + + const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod]; + if (isExplicitEnvKey) { + return t( + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', + { envKeyHint }, + ); + } + return t( + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.', + { envKeyHint }, + ); +} + +/** + * Validate that the required credentials and configuration exist for the given auth method. + */ +export function validateAuthMethod( + authMethod: string, + config?: Config, +): string | null { + const settings = loadSettings(); + loadEnvironment(settings.merged); + + if (authMethod === AuthType.USE_OPENAI) { + const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( + authMethod, + settings.merged, + config, + ); + if (!hasKey) { + const envKeyHint = checkedEnvKey + ? `'${checkedEnvKey}'` + : "'OPENAI_API_KEY'"; + if (isExplicitEnvKey) { + // Explicit envKey configured - only suggest setting the env var + return t( + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.', + { envKeyHint }, + ); + } + // Default env key - can use either apiKey or env var + return t( + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.', + { envKeyHint }, + ); + } + return null; + } + + if (authMethod === AuthType.USE_ANTHROPIC) { + const apiKeyError = getApiKeyError(authMethod, settings.merged, config); + if (apiKeyError) { + return apiKeyError; + } + + // Check baseUrl - can come from modelProviders or environment + const modelProviders = settings.merged.modelProviders as + | ModelProvidersConfig + | undefined; + // Use config.getModelsConfig().getModel() if available for accurate model ID + const modelId = + config?.getModelsConfig().getModel() ?? settings.merged.model?.name; + const modelConfig = findModelConfig(modelProviders, authMethod, modelId); + + if (modelConfig && !modelConfig.baseUrl) { + return t( + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.', + ); + } + if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) { + return t('ANTHROPIC_BASE_URL environment variable not found.'); + } + + return null; + } + + if (authMethod === AuthType.USE_OPENAI) { + const apiKeyError = getApiKeyError(authMethod, settings.merged, config); + if (apiKeyError) { + return apiKeyError; + } + return null; + } + + return t('Invalid auth method selected.'); +} diff --git a/apps/airiscode-cli/src/config/config.ts b/apps/airiscode-cli/src/config/config.ts new file mode 100755 index 000000000..8e59563e0 --- /dev/null +++ b/apps/airiscode-cli/src/config/config.ts @@ -0,0 +1,1169 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ApprovalMode, + AuthType, + Config, + DEFAULT_QWEN_EMBEDDING_MODEL, + FileDiscoveryService, + getAllGeminiMdFilenames, + loadServerHierarchicalMemory, + setGeminiMdFilename as setServerGeminiMdFilename, + resolveTelemetrySettings, + FatalConfigError, + Storage, + InputFormat, + OutputFormat, + SessionService, + ideContextStore, + type ResumedSessionData, + type LspClient, + type ToolName, + EditTool, + ShellTool, + WriteFileTool, + NativeLspClient, + createDebugLogger, + NativeLspService, + isToolEnabled, +} from '@airiscode/core'; +import { extensionsCommand } from '../commands/extensions.js'; +import { hooksCommand } from '../commands/hooks.js'; +import type { Settings } from './settings.js'; +import { loadSettings, SettingScope } from './settings.js'; +import { authCommand } from '../commands/auth.js'; +import { + resolveCliGenerationConfig, + getAuthTypeFromEnv, +} from '../utils/modelConfigUtils.js'; +import yargs, { type Argv } from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; + +import { resolvePath } from '../utils/resolvePath.js'; +import { getCliVersion } from '../utils/version.js'; +import { loadSandboxConfig } from './sandboxConfig.js'; +import { appEvents } from '../utils/events.js'; +import { mcpCommand } from '../commands/mcp.js'; +import { channelCommand } from '../commands/channel.js'; + +// UUID v4 regex pattern for validation +const SESSION_ID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i; + +/** + * Validates if a string is a valid session ID format. + * Accepts a standard UUID, or a UUID followed by `-agent-{suffix}` + * (used by Arena to give each agent a deterministic session ID). + */ +function isValidSessionId(value: string): boolean { + return SESSION_ID_REGEX.test(value); +} + +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { buildWebSearchConfig } from './webSearch.js'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; + +const debugLogger = createDebugLogger('CONFIG'); + +const VALID_APPROVAL_MODE_VALUES = [ + 'plan', + 'default', + 'auto-edit', + 'yolo', +] as const; + +function formatApprovalModeError(value: string): Error { + return new Error( + `Invalid approval mode: ${value}. Valid values are: ${VALID_APPROVAL_MODE_VALUES.join( + ', ', + )}`, + ); +} + +function parseApprovalModeValue(value: string): ApprovalMode { + const normalized = value.trim().toLowerCase(); + switch (normalized) { + case 'plan': + return ApprovalMode.PLAN; + case 'default': + return ApprovalMode.DEFAULT; + case 'yolo': + return ApprovalMode.YOLO; + case 'auto_edit': + case 'autoedit': + case 'auto-edit': + return ApprovalMode.AUTO_EDIT; + default: + throw formatApprovalModeError(value); + } +} + +export interface CliArgs { + query: string | undefined; + model: string | undefined; + sandbox: boolean | string | undefined; + sandboxImage: string | undefined; + debug: boolean | undefined; + prompt: string | undefined; + promptInteractive: string | undefined; + systemPrompt: string | undefined; + appendSystemPrompt: string | undefined; + yolo: boolean | undefined; + approvalMode: string | undefined; + telemetry: boolean | undefined; + checkpointing: boolean | undefined; + telemetryTarget: string | undefined; + telemetryOtlpEndpoint: string | undefined; + telemetryOtlpProtocol: string | undefined; + telemetryLogPrompts: boolean | undefined; + telemetryOutfile: string | undefined; + allowedMcpServerNames: string[] | undefined; + allowedTools: string[] | undefined; + acp: boolean | undefined; + experimentalAcp: boolean | undefined; + experimentalLsp: boolean | undefined; + extensions: string[] | undefined; + listExtensions: boolean | undefined; + openaiLogging: boolean | undefined; + openaiApiKey: string | undefined; + openaiBaseUrl: string | undefined; + openaiLoggingDir: string | undefined; + proxy: string | undefined; + includeDirectories: string[] | undefined; + tavilyApiKey: string | undefined; + googleApiKey: string | undefined; + googleSearchEngineId: string | undefined; + webSearchDefault: string | undefined; + screenReader: boolean | undefined; + inputFormat?: string | undefined; + outputFormat: string | undefined; + includePartialMessages?: boolean; + /** + * If chat recording is disabled, the chat history would not be recorded, + * so --continue and --resume would not take effect. + */ + chatRecording: boolean | undefined; + /** Resume the most recent session for the current project */ + continue: boolean | undefined; + /** Resume a specific session by its ID */ + resume: string | undefined; + /** Specify a session ID without session resumption */ + sessionId: string | undefined; + maxSessionTurns: number | undefined; + coreTools: string[] | undefined; + excludeTools: string[] | undefined; + authType: string | undefined; + channel: string | undefined; +} + +function normalizeOutputFormat( + format: string | OutputFormat | undefined, +): OutputFormat | undefined { + if (!format) { + return undefined; + } + if (format === OutputFormat.STREAM_JSON) { + return OutputFormat.STREAM_JSON; + } + if (format === 'json' || format === OutputFormat.JSON) { + return OutputFormat.JSON; + } + return OutputFormat.TEXT; +} + +export async function parseArguments(): Promise { + let rawArgv = hideBin(process.argv); + + // hack: if the first argument is the CLI entry point, remove it + if ( + rawArgv.length > 0 && + (rawArgv[0].endsWith('/dist/qwen-cli/cli.js') || + rawArgv[0].endsWith('/dist/cli.js') || + rawArgv[0].endsWith('/dist/cli/cli.js')) + ) { + rawArgv = rawArgv.slice(1); + } + + const yargsInstance = yargs(rawArgv) + .locale('en') + .scriptName('airiscode') + .usage( + 'Usage: qwen [options] [command]\n\nAIRIS Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode', + ) + .option('telemetry', { + type: 'boolean', + description: + 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.', + }) + .option('telemetry-target', { + type: 'string', + choices: ['local', 'gcp'], + description: + 'Set the telemetry target (local or gcp). Overrides settings files.', + }) + .option('telemetry-otlp-endpoint', { + type: 'string', + description: + 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', + }) + .option('telemetry-otlp-protocol', { + type: 'string', + choices: ['grpc', 'http'], + description: + 'Set the OTLP protocol for telemetry (grpc or http). Overrides settings files.', + }) + .option('telemetry-log-prompts', { + type: 'boolean', + description: + 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', + }) + .option('telemetry-outfile', { + type: 'string', + description: 'Redirect all telemetry output to the specified file.', + }) + .deprecateOption( + 'telemetry', + 'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-target', + 'Use the "telemetry.target" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-otlp-endpoint', + 'Use the "telemetry.otlpEndpoint" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-otlp-protocol', + 'Use the "telemetry.otlpProtocol" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-log-prompts', + 'Use the "telemetry.logPrompts" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-outfile', + 'Use the "telemetry.outfile" setting in settings.json instead. This flag will be removed in a future version.', + ) + .option('debug', { + alias: 'd', + type: 'boolean', + description: 'Run in debug mode?', + default: false, + }) + .option('proxy', { + type: 'string', + description: 'Proxy for AIRIS Code, like schema://user:password@host:port', + }) + .deprecateOption( + 'proxy', + 'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.', + ) + .option('chat-recording', { + type: 'boolean', + description: + 'Enable chat recording to disk. If false, chat history is not saved and --continue/--resume will not work.', + }) + .command('$0 [query..]', 'Launch AIRIS Code CLI', (yargsInstance: Argv) => + yargsInstance + .positional('query', { + description: + 'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.', + }) + .option('model', { + alias: 'm', + type: 'string', + description: `Model`, + }) + .option('prompt', { + alias: 'p', + type: 'string', + description: 'Prompt. Appended to input on stdin (if any).', + }) + .option('prompt-interactive', { + alias: 'i', + type: 'string', + description: + 'Execute the provided prompt and continue in interactive mode', + }) + .option('system-prompt', { + type: 'string', + description: + 'Override the main session system prompt for this run. Can be combined with --append-system-prompt.', + }) + .option('append-system-prompt', { + type: 'string', + description: + 'Append instructions to the main session system prompt for this run. Can be combined with --system-prompt.', + }) + .option('sandbox', { + alias: 's', + type: 'boolean', + description: 'Run in sandbox?', + }) + .option('sandbox-image', { + type: 'string', + description: 'Sandbox image URI.', + }) + .option('yolo', { + alias: 'y', + type: 'boolean', + description: + 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', + default: false, + }) + .option('approval-mode', { + type: 'string', + choices: ['plan', 'default', 'auto-edit', 'yolo'], + description: + 'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)', + }) + .option('checkpointing', { + type: 'boolean', + description: 'Enables checkpointing of file edits', + default: false, + }) + .option('acp', { + type: 'boolean', + description: 'Starts the agent in ACP mode', + }) + .option('experimental-acp', { + type: 'boolean', + description: + 'Starts the agent in ACP mode (deprecated, use --acp instead)', + hidden: true, + }) + .option('experimental-skills', { + type: 'boolean', + description: + 'Deprecated: Skills are now enabled by default. This flag is ignored.', + hidden: true, + }) + .option('experimental-lsp', { + type: 'boolean', + description: + 'Enable experimental LSP (Language Server Protocol) feature for code intelligence', + default: false, + }) + .option('channel', { + type: 'string', + choices: ['VSCode', 'ACP', 'SDK', 'CI'], + description: 'Channel identifier (VSCode, ACP, SDK, CI)', + }) + .option('allowed-mcp-server-names', { + type: 'array', + string: true, + description: 'Allowed MCP server names', + coerce: (mcpServerNames: string[]) => + // Handle comma-separated values + mcpServerNames.flatMap((mcpServerName) => + mcpServerName.split(',').map((m) => m.trim()), + ), + }) + .option('allowed-tools', { + type: 'array', + string: true, + description: 'Tools that are allowed to run without confirmation', + coerce: (tools: string[]) => + // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('extensions', { + alias: 'e', + type: 'array', + string: true, + description: + 'A list of extensions to use. If not provided, all extensions are used.', + coerce: (extensions: string[]) => + // Handle comma-separated values + extensions.flatMap((extension) => + extension.split(',').map((e) => e.trim()), + ), + }) + .option('list-extensions', { + alias: 'l', + type: 'boolean', + description: 'List all available extensions and exit.', + }) + .option('include-directories', { + alias: 'add-dir', + type: 'array', + string: true, + description: + 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', + coerce: (dirs: string[]) => + // Handle comma-separated values + dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), + }) + .option('openai-logging', { + type: 'boolean', + description: + 'Enable logging of OpenAI API calls for debugging and analysis', + }) + .option('openai-logging-dir', { + type: 'string', + description: + 'Custom directory path for OpenAI API logs. Overrides settings files.', + }) + .option('openai-api-key', { + type: 'string', + description: 'OpenAI API key to use for authentication', + }) + .option('openai-base-url', { + type: 'string', + description: 'OpenAI base URL (for custom endpoints)', + }) + .option('tavily-api-key', { + type: 'string', + description: 'Tavily API key for web search', + }) + .option('google-api-key', { + type: 'string', + description: 'Google Custom Search API key', + }) + .option('google-search-engine-id', { + type: 'string', + description: 'Google Custom Search Engine ID', + }) + .option('web-search-default', { + type: 'string', + description: + 'Default web search provider (dashscope, tavily, google)', + }) + .option('screen-reader', { + type: 'boolean', + description: 'Enable screen reader mode for accessibility.', + }) + .option('input-format', { + type: 'string', + choices: ['text', 'stream-json'], + description: 'The format consumed from standard input.', + default: 'text', + }) + .option('output-format', { + alias: 'o', + type: 'string', + description: 'The format of the CLI output.', + choices: ['text', 'json', 'stream-json'], + }) + .option('include-partial-messages', { + type: 'boolean', + description: + 'Include partial assistant messages when using stream-json output.', + default: false, + }) + .option('continue', { + alias: 'c', + type: 'boolean', + description: + 'Resume the most recent session for the current project.', + default: false, + }) + .option('resume', { + alias: 'r', + type: 'string', + description: + 'Resume a specific session by its ID. Use without an ID to show session picker.', + }) + .option('session-id', { + type: 'string', + description: 'Specify a session ID for this run.', + }) + .option('max-session-turns', { + type: 'number', + description: 'Maximum number of session turns', + }) + .option('core-tools', { + type: 'array', + string: true, + description: 'Core tool paths', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('exclude-tools', { + type: 'array', + string: true, + description: 'Tools to exclude', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('allowed-tools', { + type: 'array', + string: true, + description: 'Tools to allow, will bypass confirmation', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('auth-type', { + type: 'string', + choices: [ + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_OLLAMA, + ], + description: 'Authentication type', + }) + .deprecateOption( + 'sandbox-image', + 'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'checkpointing', + 'Use the "general.checkpointing.enabled" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'prompt', + 'Use the positional prompt instead. This flag will be removed in a future version.', + ) + // Ensure validation flows through .fail() for clean UX + .fail((msg: string, err: Error | undefined, yargs: Argv) => { + writeStderrLine(msg || err?.message || 'Unknown error'); + yargs.showHelp(); + process.exit(1); + }) + .check((argv: { [x: string]: unknown }) => { + // The 'query' positional can be a string (for one arg) or string[] (for multiple). + // This guard safely checks if any positional argument was provided. + const query = argv['query'] as string | string[] | undefined; + const hasPositionalQuery = Array.isArray(query) + ? query.length > 0 + : !!query; + + if (argv['prompt'] && hasPositionalQuery) { + return 'Cannot use both a positional prompt and the --prompt (-p) flag together'; + } + if (argv['prompt'] && argv['promptInteractive']) { + return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together'; + } + if (argv['yolo'] && argv['approvalMode']) { + return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; + } + if ( + argv['includePartialMessages'] && + argv['outputFormat'] !== OutputFormat.STREAM_JSON + ) { + return '--include-partial-messages requires --output-format stream-json'; + } + if ( + argv['inputFormat'] === 'stream-json' && + argv['outputFormat'] !== OutputFormat.STREAM_JSON + ) { + return '--input-format stream-json requires --output-format stream-json'; + } + if (argv['continue'] && argv['resume']) { + return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume to resume a specific session.'; + } + if (argv['sessionId'] && (argv['continue'] || argv['resume'])) { + return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.'; + } + if ( + argv['sessionId'] && + !isValidSessionId(argv['sessionId'] as string) + ) { + return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + } + if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) { + return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + } + return true; + }), + ) + // Register MCP subcommands + .command(mcpCommand) + // Register Extension subcommands + .command(extensionsCommand) + // Register Auth subcommands + .command(authCommand) + // Register Hooks subcommands + .command(hooksCommand) + // Register Channel subcommands + .command(channelCommand); + + yargsInstance + .version(await getCliVersion()) // This will enable the --version flag based on package.json + .alias('v', 'version') + .help() + .alias('h', 'help') + .strict() + .demandCommand(0, 0); // Allow base command to run with no subcommands + + yargsInstance.wrap(yargsInstance.terminalWidth()); + const result = await yargsInstance.parse(); + + // If yargs handled --help/--version it will have exited; nothing to do here. + + // Handle case where MCP subcommands are executed - they should exit the process + // and not return to main CLI logic + if ( + result._.length > 0 && + (result._[0] === 'mcp' || + result._[0] === 'extensions' || + result._[0] === 'hooks' || + result._[0] === 'channel') + ) { + // MCP/Extensions/Hooks commands handle their own execution and process exit + process.exit(0); + } + + // Normalize query args: handle both quoted "@path file" and unquoted @path file + const queryArg = (result as { query?: string | string[] | undefined }).query; + const q: string | undefined = Array.isArray(queryArg) + ? queryArg.join(' ') + : queryArg; + + // Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands) + if (q && !result['prompt']) { + const hasExplicitInteractive = + result['promptInteractive'] === '' || !!result['promptInteractive']; + if (hasExplicitInteractive) { + result['promptInteractive'] = q; + } else { + result['prompt'] = q; + } + } + + // Keep CliArgs.query as a string for downstream typing + (result as Record)['query'] = q || undefined; + + // The import format is now only controlled by settings.memoryImportFormat + // We no longer accept it as a CLI argument + + // Handle deprecated --experimental-acp flag + if (result['experimentalAcp']) { + writeStderrLine( + '\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m', + ); + // Map experimental-acp to acp if acp is not explicitly set + if (!result['acp']) { + (result as Record)['acp'] = true; + } + } + + // Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP + if ((result['acp'] || result['experimentalAcp']) && !result['channel']) { + (result as Record)['channel'] = 'ACP'; + } + + return result as unknown as CliArgs; +} + +// This function is now a thin wrapper around the server's implementation. +// It's kept in the CLI for now as App.tsx directly calls it for memory refresh. +// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself. +export async function loadHierarchicalGeminiMemory( + currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[] = [], + fileService: FileDiscoveryService, + extensionContextFilePaths: string[] = [], + folderTrust: boolean, + memoryImportFormat: 'flat' | 'tree' = 'tree', +): Promise<{ memoryContent: string; fileCount: number }> { + // FIX: Use real, canonical paths for a reliable comparison to handle symlinks. + const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory)); + const realHome = fs.realpathSync(path.resolve(homedir())); + const isHomeDirectory = realCwd === realHome; + + // If it is the home directory, pass an empty string to the core memory + // function to signal that it should skip the workspace search. + const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory; + + // Directly call the server function with the corrected path. + return loadServerHierarchicalMemory( + effectiveCwd, + includeDirectoriesToReadGemini, + fileService, + extensionContextFilePaths, + folderTrust, + memoryImportFormat, + ); +} + +export function isDebugMode(argv: CliArgs): boolean { + return ( + argv.debug || + [process.env['DEBUG'], process.env['DEBUG_MODE']].some( + (v) => v === 'true' || v === '1', + ) + ); +} + +export async function loadCliConfig( + settings: Settings, + argv: CliArgs, + cwd: string = process.cwd(), + overrideExtensions?: string[], +): Promise { + const debugMode = isDebugMode(argv); + + // Set runtime output directory from settings (env var QWEN_RUNTIME_DIR + // is auto-detected inside getRuntimeBaseDir() at each call site). + // Pass cwd so that relative paths like ".airiscode" resolve per-project. + Storage.setRuntimeBaseDir(settings.advanced?.runtimeOutputDir, cwd); + + const ideMode = settings.ide?.enabled ?? false; + + const folderTrust = settings.security?.folderTrust?.enabled ?? false; + const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true; + + // Set the context filename in the server's memoryTool module BEFORE loading memory + // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed + // directly to the Config constructor in core, and have core handle setGeminiMdFilename. + // However, loadHierarchicalGeminiMemory is called *before* createServerConfig. + if (settings.context?.fileName) { + setServerGeminiMdFilename(settings.context.fileName); + } else { + // Reset to default context filenames if not provided in settings. + setServerGeminiMdFilename(getAllGeminiMdFilenames()); + } + + // Automatically load output-language.md if it exists + const projectStorage = new Storage(cwd); + const projectOutputLanguagePath = path.join( + projectStorage.getQwenDir(), + 'output-language.md', + ); + const globalOutputLanguagePath = path.join( + Storage.getGlobalQwenDir(), + 'output-language.md', + ); + + let outputLanguageFilePath: string | undefined; + if (fs.existsSync(projectOutputLanguagePath)) { + outputLanguageFilePath = projectOutputLanguagePath; + } else if (fs.existsSync(globalOutputLanguagePath)) { + outputLanguageFilePath = globalOutputLanguagePath; + } + + const fileService = new FileDiscoveryService(cwd); + + const includeDirectories = (settings.context?.includeDirectories || []) + .map(resolvePath) + .concat((argv.includeDirectories || []).map(resolvePath)); + + // LSP configuration: enabled only via --experimental-lsp flag + const lspEnabled = argv.experimentalLsp === true; + let lspClient: LspClient | undefined; + const question = argv.promptInteractive || argv.prompt || ''; + const inputFormat: InputFormat = + (argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT; + const argvOutputFormat = normalizeOutputFormat( + argv.outputFormat as string | OutputFormat | undefined, + ); + const settingsOutputFormat = normalizeOutputFormat(settings.output?.format); + const outputFormat = + argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT; + const outputSettingsFormat: OutputFormat = + outputFormat === OutputFormat.STREAM_JSON + ? settingsOutputFormat && + settingsOutputFormat !== OutputFormat.STREAM_JSON + ? settingsOutputFormat + : OutputFormat.TEXT + : (outputFormat as OutputFormat); + const includePartialMessages = Boolean(argv.includePartialMessages); + + // Determine approval mode with backward compatibility + let approvalMode: ApprovalMode; + if (argv.approvalMode) { + approvalMode = parseApprovalModeValue(argv.approvalMode); + } else if (argv.yolo) { + approvalMode = ApprovalMode.YOLO; + } else if (settings.tools?.approvalMode) { + approvalMode = parseApprovalModeValue(settings.tools.approvalMode); + } else { + approvalMode = ApprovalMode.DEFAULT; + } + + // Force approval mode to default if the folder is not trusted. + if ( + !trustedFolder && + approvalMode !== ApprovalMode.DEFAULT && + approvalMode !== ApprovalMode.PLAN + ) { + writeStderrLine( + `Approval mode overridden to "default" because the current folder is not trusted.`, + ); + approvalMode = ApprovalMode.DEFAULT; + } + + let telemetrySettings; + try { + telemetrySettings = await resolveTelemetrySettings({ + argv, + env: process.env as unknown as Record, + settings: settings.telemetry, + }); + } catch (err) { + if (err instanceof FatalConfigError) { + throw new FatalConfigError( + `Invalid telemetry configuration: ${err.message}.`, + ); + } + throw err; + } + + // Interactive mode determination with priority: + // 1. If promptInteractive (-i flag) is provided, it is explicitly interactive + // 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive + // 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive + const hasQuery = !!argv.query; + const hasPrompt = !!argv.prompt; + let interactive: boolean; + if (argv.promptInteractive) { + // Priority 1: Explicit -i flag means interactive + interactive = true; + } else if ( + (outputFormat === OutputFormat.STREAM_JSON || + outputFormat === OutputFormat.JSON) && + (hasQuery || hasPrompt) + ) { + // Priority 2: JSON/stream-json output with query/prompt means non-interactive + interactive = false; + } else if (!hasQuery && !hasPrompt) { + // Priority 3: No query or prompt means interactive only if TTY (format arguments ignored) + interactive = process.stdin.isTTY ?? false; + } else { + // Default: If we have query/prompt but output format is TEXT, assume non-interactive + // (fallback for edge cases where query/prompt is provided with TEXT output) + interactive = false; + } + // ── Unified permissions construction ───────────────────────────────────── + // All permission sources are merged here, before constructing Config. + // The resulting three arrays are the single source of truth that Config / + // PermissionManager will use. + // + // Sources (in order of precedence within each list): + // 1. settings.permissions.{allow,ask,deny} (persistent, merged by LoadedSettings) + // 2. argv.coreTools → allow (allowlist mode: only these tools are available) + // 3. argv.allowedTools → allow (auto-approve these tools/commands) + // 4. argv.excludeTools → deny (block these tools completely) + // 5. Non-interactive mode exclusions → deny (unless explicitly allowed above) + + // Start from settings-level rules. + // Read from both new `permissions` and legacy `tools` paths for compatibility. + // Note: settings.tools.core / argv.coreTools are intentionally NOT merged into + // mergedAllow — they have whitelist semantics (only listed tools are registered), + // not auto-approve semantics. They are passed via the `coreTools` Config param + // and handled by PermissionManager.coreToolsAllowList. + const resolvedCoreTools: string[] = [ + ...(argv.coreTools ?? []), + ...(settings.tools?.core ?? []), + ]; + const mergedAllow: string[] = [ + ...(settings.permissions?.allow ?? []), + ...(settings.tools?.allowed ?? []), + ]; + const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])]; + const mergedDeny: string[] = [ + ...(settings.permissions?.deny ?? []), + ...(settings.tools?.exclude ?? []), + ]; + + // argv.allowedTools adds allow rules (auto-approve). + for (const t of argv.allowedTools ?? []) { + if (t && !mergedAllow.includes(t)) mergedAllow.push(t); + } + + // argv.excludeTools adds deny rules. + for (const t of argv.excludeTools ?? []) { + if (t && !mergedDeny.includes(t)) mergedDeny.push(t); + } + + // Helper: check if a tool is explicitly covered by an allow rule OR by the + // coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled) + // to preserve the original behaviour where "ShellTool", "Shell", and + // "run_shell_command" are all accepted as the same tool. + const isExplicitlyAllowed = (toolName: ToolName): boolean => { + const name = toolName as string; + // 1. Check permissions.allow / allowedTools rules. + if ( + mergedAllow.some((rule) => { + const openParen = rule.indexOf('('); + const ruleName = + openParen === -1 ? rule.trim() : rule.substring(0, openParen).trim(); + return ruleName === name; + }) + ) { + return true; + } + // 2. Check coreTools whitelist (with alias matching). + // If coreTools is non-empty and explicitly includes this tool, it is + // considered allowed for non-interactive mode exclusion purposes. + if (resolvedCoreTools.length > 0) { + return isToolEnabled(toolName, resolvedCoreTools, []); + } + return false; + }; + + // In non-interactive mode, tools that require a user prompt are denied unless + // the caller has explicitly allowed them. Stream-JSON input is excluded from + // this logic because approval can be sent programmatically via JSON messages. + const isAcpMode = argv.acp || argv.experimentalAcp; + if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) { + const denyUnlessAllowed = (toolName: ToolName): void => { + if (!isExplicitlyAllowed(toolName)) { + const name = toolName as string; + if (!mergedDeny.includes(name)) mergedDeny.push(name); + } + }; + + switch (approvalMode) { + case ApprovalMode.PLAN: + case ApprovalMode.DEFAULT: + // Deny all write/execute tools unless explicitly allowed. + denyUnlessAllowed(ShellTool.Name as ToolName); + denyUnlessAllowed(EditTool.Name as ToolName); + denyUnlessAllowed(WriteFileTool.Name as ToolName); + break; + case ApprovalMode.AUTO_EDIT: + // Only shell requires a prompt in auto-edit mode. + denyUnlessAllowed(ShellTool.Name as ToolName); + break; + case ApprovalMode.YOLO: + // No extra denials for YOLO mode. + break; + default: + break; + } + } + + let allowedMcpServers: Set | undefined; + let excludedMcpServers: Set | undefined; + if (argv.allowedMcpServerNames) { + allowedMcpServers = new Set(argv.allowedMcpServerNames.filter(Boolean)); + excludedMcpServers = undefined; + } else { + allowedMcpServers = settings.mcp?.allowed + ? new Set(settings.mcp.allowed.filter(Boolean)) + : undefined; + excludedMcpServers = settings.mcp?.excluded + ? new Set(settings.mcp.excluded.filter(Boolean)) + : undefined; + } + + const selectedAuthType = + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType || + /* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */ + getAuthTypeFromEnv(); + + // Unified resolution of generation config with source attribution + const resolvedCliConfig = resolveCliGenerationConfig({ + argv: { + model: argv.model, + openaiApiKey: argv.openaiApiKey, + openaiBaseUrl: argv.openaiBaseUrl, + openaiLogging: argv.openaiLogging, + openaiLoggingDir: argv.openaiLoggingDir, + }, + settings, + selectedAuthType, + env: process.env as Record, + }); + + const { model: resolvedModel } = resolvedCliConfig; + + const sandboxConfig = await loadSandboxConfig(settings, argv); + const screenReader = + argv.screenReader !== undefined + ? argv.screenReader + : (settings.ui?.accessibility?.screenReader ?? false); + + let sessionId: string | undefined; + let sessionData: ResumedSessionData | undefined; + + if (argv.continue || argv.resume) { + const sessionService = new SessionService(cwd); + if (argv.continue) { + sessionData = await sessionService.loadLastSession(); + if (sessionData) { + sessionId = sessionData.conversation.sessionId; + } + } + + if (argv.resume) { + sessionId = argv.resume; + sessionData = await sessionService.loadSession(argv.resume); + if (!sessionData) { + const message = `No saved session found with ID ${argv.resume}. Run \`airiscode --resume\` without an ID to choose from existing sessions.`; + writeStderrLine(message); + process.exit(1); + } + } + } else if (argv['sessionId']) { + // Use provided session ID without session resumption + // Check if session ID is already in use + const sessionService = new SessionService(cwd); + const exists = await sessionService.sessionExists(argv['sessionId']); + if (exists) { + const message = `Error: Session Id ${argv['sessionId']} is already in use.`; + writeStderrLine(message); + process.exit(1); + } + sessionId = argv['sessionId']; + } + + const modelProvidersConfig = settings.modelProviders; + + const config = new Config({ + sessionId, + sessionData, + embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL, + sandbox: sandboxConfig, + targetDir: cwd, + includeDirectories, + loadMemoryFromIncludeDirectories: + settings.context?.loadFromIncludeDirectories || false, + importFormat: settings.context?.importFormat || 'tree', + debugMode, + question, + systemPrompt: argv.systemPrompt, + appendSystemPrompt: argv.appendSystemPrompt, + // Legacy fields – kept for backward compatibility with getCoreTools() etc. + coreTools: argv.coreTools || settings.tools?.core || undefined, + allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, + excludeTools: mergedDeny, + // New unified permissions (PermissionManager source of truth). + permissions: { + allow: mergedAllow.length > 0 ? mergedAllow : undefined, + ask: mergedAsk.length > 0 ? mergedAsk : undefined, + deny: mergedDeny.length > 0 ? mergedDeny : undefined, + }, + // Permission rule persistence callback (writes to settings files). + onPersistPermissionRule: async (scope, ruleType, rule) => { + const currentSettings = loadSettings(cwd); + const settingScope = + scope === 'project' ? SettingScope.Workspace : SettingScope.User; + const key = `permissions.${ruleType}`; + const currentRules: string[] = + currentSettings.forScope(settingScope).settings.permissions?.[ + ruleType + ] ?? []; + if (!currentRules.includes(rule)) { + currentSettings.setValue(settingScope, key, [...currentRules, rule]); + } + }, + toolDiscoveryCommand: settings.tools?.discoveryCommand, + toolCallCommand: settings.tools?.callCommand, + mcpServerCommand: settings.mcp?.serverCommand, + mcpServers: settings.mcpServers || {}, + allowedMcpServers: allowedMcpServers + ? Array.from(allowedMcpServers) + : undefined, + excludedMcpServers: excludedMcpServers + ? Array.from(excludedMcpServers) + : undefined, + approvalMode, + accessibility: { + ...settings.ui?.accessibility, + screenReader, + }, + telemetry: telemetrySettings, + usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, + fileFiltering: settings.context?.fileFiltering, + thinkingIdleThresholdMinutes: settings.context?.gapThresholdMinutes, + checkpointing: + argv.checkpointing || settings.general?.checkpointing?.enabled, + proxy: + argv.proxy || + process.env['HTTPS_PROXY'] || + process.env['https_proxy'] || + process.env['HTTP_PROXY'] || + process.env['http_proxy'], + cwd, + fileDiscoveryService: fileService, + bugCommand: settings.advanced?.bugCommand, + model: resolvedModel, + outputLanguageFilePath, + sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, + maxSessionTurns: + argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, + experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, + cronEnabled: settings.experimental?.cron ?? false, + listExtensions: argv.listExtensions || false, + overrideExtensions: overrideExtensions || argv.extensions, + noBrowser: !!process.env['NO_BROWSER'], + authType: selectedAuthType, + inputFormat, + outputFormat, + includePartialMessages, + modelProvidersConfig, + generationConfigSources: resolvedCliConfig.sources, + generationConfig: resolvedCliConfig.generationConfig, + warnings: resolvedCliConfig.warnings, + cliVersion: await getCliVersion(), + webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), + ideMode, + chatCompression: settings.model?.chatCompression, + folderTrust, + interactive, + trustedFolder, + useRipgrep: settings.tools?.useRipgrep, + useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep, + shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, + skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, + skipLoopDetection: settings.model?.skipLoopDetection ?? true, + skipStartupContext: settings.model?.skipStartupContext ?? false, + truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, + truncateToolOutputLines: settings.tools?.truncateToolOutputLines, + eventEmitter: appEvents, + gitCoAuthor: settings.general?.gitCoAuthor, + output: { + format: outputSettingsFormat, + }, + hooks: settings.hooks, + disableAllHooks: settings.disableAllHooks ?? false, + channel: argv.channel, + // Precedence: explicit CLI flag > settings file > default(true). + // NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will + // always be true and the settings file can never disable recording. + chatRecording: + argv.chatRecording ?? settings.general?.chatRecording ?? true, + defaultFileEncoding: settings.general?.defaultFileEncoding, + lsp: { + enabled: lspEnabled, + }, + agents: settings.agents + ? { + displayMode: settings.agents.displayMode, + arena: settings.agents.arena + ? { + worktreeBaseDir: settings.agents.arena.worktreeBaseDir, + preserveArtifacts: + settings.agents.arena.preserveArtifacts ?? false, + } + : undefined, + } + : undefined, + }); + + if (lspEnabled) { + try { + const lspService = new NativeLspService( + config, + config.getWorkspaceContext(), + appEvents, + fileService, + ideContextStore, + { + requireTrustedWorkspace: folderTrust, + }, + ); + + await lspService.discoverAndPrepare(); + await lspService.start(); + lspClient = new NativeLspClient(lspService); + config.setLspClient(lspClient); + } catch (err) { + debugLogger.warn('Failed to initialize native LSP service:', err); + } + } + + return config; +} diff --git a/apps/airiscode-cli/src/config/keyBindings.ts b/apps/airiscode-cli/src/config/keyBindings.ts index c1e5c67bc..b13da27fa 100644 --- a/apps/airiscode-cli/src/config/keyBindings.ts +++ b/apps/airiscode-cli/src/config/keyBindings.ts @@ -4,25 +4,185 @@ * SPDX-License-Identifier: Apache-2.0 */ -// Represents a single key press combination +/** + * Command enum for all available keyboard shortcuts + */ +export enum Command { + // Basic bindings + RETURN = 'return', + ESCAPE = 'escape', + + // Cursor movement + HOME = 'home', + END = 'end', + + // Text deletion + KILL_LINE_RIGHT = 'killLineRight', + KILL_LINE_LEFT = 'killLineLeft', + CLEAR_INPUT = 'clearInput', + DELETE_WORD_BACKWARD = 'deleteWordBackward', + + // Screen control + CLEAR_SCREEN = 'clearScreen', + + // History navigation + HISTORY_UP = 'historyUp', + HISTORY_DOWN = 'historyDown', + NAVIGATION_UP = 'navigationUp', + NAVIGATION_DOWN = 'navigationDown', + + // Auto-completion + ACCEPT_SUGGESTION = 'acceptSuggestion', + COMPLETION_UP = 'completionUp', + COMPLETION_DOWN = 'completionDown', + + // Text input + SUBMIT = 'submit', + NEWLINE = 'newline', + + // External tools + OPEN_EXTERNAL_EDITOR = 'openExternalEditor', + PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage', + + // App level bindings + TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions', + TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', + QUIT = 'quit', + EXIT = 'exit', + SHOW_MORE_LINES = 'showMoreLines', + RETRY_LAST = 'retryLast', + TOGGLE_VERBOSE_MODE = 'toggleVerboseMode', + + // Shell commands + REVERSE_SEARCH = 'reverseSearch', + SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', + ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', + TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus', + + // Suggestion expansion + EXPAND_SUGGESTION = 'expandSuggestion', + COLLAPSE_SUGGESTION = 'collapseSuggestion', +} + +/** + * Data-driven key binding structure for user configuration + */ export interface KeyBinding { + /** The key name (e.g., 'a', 'return', 'tab', 'escape') */ key?: string; + /** The key sequence (e.g., '\x18' for Ctrl+X) - alternative to key name */ sequence?: string; + /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ ctrl?: boolean; + /** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ shift?: boolean; + /** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ command?: boolean; + /** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */ paste?: boolean; + meta?: boolean; } -// Enum for all possible commands -export enum Command { - // TODO: Add commands here -} - -// Maps commands to one or more key bindings +/** + * Configuration type mapping commands to their key bindings + */ export type KeyBindingConfig = { - [C in Command]: KeyBinding[]; + readonly [C in Command]: readonly KeyBinding[]; }; -// Default key bindings configuration -export const defaultKeyBindings: KeyBindingConfig = {} as KeyBindingConfig; +/** + * Default key binding configuration + * Matches the original hard-coded logic exactly + */ +export const defaultKeyBindings: KeyBindingConfig = { + // Basic bindings + [Command.RETURN]: [{ key: 'return' }], + [Command.ESCAPE]: [{ key: 'escape' }], + + // Cursor movement + [Command.HOME]: [{ key: 'a', ctrl: true }], + [Command.END]: [{ key: 'e', ctrl: true }], + + // Text deletion + [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], + [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], + [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], + // Added command (meta/alt/option) for mac compatibility + [Command.DELETE_WORD_BACKWARD]: [ + { key: 'backspace', ctrl: true }, + { key: 'backspace', command: true }, + ], + + // Screen control + [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], + + // History navigation + [Command.HISTORY_UP]: [{ key: 'p', ctrl: true }], + [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }], + [Command.NAVIGATION_UP]: [{ key: 'up' }], + [Command.NAVIGATION_DOWN]: [{ key: 'down' }], + + // Auto-completion + [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], + // Completion navigation uses only arrow keys + // Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN) + [Command.COMPLETION_UP]: [{ key: 'up' }], + [Command.COMPLETION_DOWN]: [{ key: 'down' }], + + // Text input + // Must also exclude shift to allow shift+enter for newline + [Command.SUBMIT]: [ + { + key: 'return', + ctrl: false, + command: false, + paste: false, + shift: false, + }, + ], + // Split into multiple data-driven bindings + // Now also includes shift+enter for multi-line input + [Command.NEWLINE]: [ + { key: 'return', ctrl: true }, + { key: 'return', command: true }, + { key: 'return', paste: true }, + { key: 'return', shift: true }, + { key: 'j', ctrl: true }, + ], + + // External tools + [Command.OPEN_EXTERNAL_EDITOR]: [ + { key: 'x', ctrl: true }, + { sequence: '\x18', ctrl: true }, + ], + [Command.PASTE_CLIPBOARD_IMAGE]: + process.platform === 'win32' + ? [ + { key: 'v', command: true }, + { key: 'v', meta: true }, + ] + : [ + { key: 'v', ctrl: true }, + { key: 'v', command: true }, + ], + + // App level bindings + [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], + [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], + [Command.QUIT]: [{ key: 'c', ctrl: true }], + [Command.EXIT]: [{ key: 'd', ctrl: true }], + [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], + [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], + [Command.TOGGLE_VERBOSE_MODE]: [{ key: 'o', ctrl: true }], + + // Shell commands + [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], + // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste + [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], + [Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }], + + // Suggestion expansion + [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], + [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], +}; diff --git a/apps/airiscode-cli/src/config/migration/index.ts b/apps/airiscode-cli/src/config/migration/index.ts new file mode 100644 index 000000000..40d176cbe --- /dev/null +++ b/apps/airiscode-cli/src/config/migration/index.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Export types +export type { SettingsMigration, MigrationResult } from './types.js'; + +// Export scheduler +export { MigrationScheduler } from './scheduler.js'; + +// Export migrations +export { v1ToV2Migration, V1ToV2Migration } from './versions/v1-to-v2.js'; +export { v2ToV3Migration, V2ToV3Migration } from './versions/v2-to-v3.js'; + +// Import settings version from single source of truth +import { SETTINGS_VERSION } from '../settings.js'; + +// Ordered array of all migrations for use with MigrationScheduler +// Each migration handles one version transition (N → N+1) +// Order matters: migrations must be sorted by ascending version +import { v1ToV2Migration } from './versions/v1-to-v2.js'; +import { v2ToV3Migration } from './versions/v2-to-v3.js'; +import { MigrationScheduler } from './scheduler.js'; +import type { MigrationResult } from './types.js'; + +/** + * Ordered array of all settings migrations. + * Use this with MigrationScheduler to run the full migration chain. + * + * @example + * ```typescript + * const scheduler = new MigrationScheduler(ALL_MIGRATIONS); + * const result = scheduler.migrate(settings); + * ``` + */ +export const ALL_MIGRATIONS = [v1ToV2Migration, v2ToV3Migration] as const; + +/** + * Convenience function that runs all migrations on the given settings. + * This is the primary entry point for settings migration. + * + * @param settings - The settings object to migrate + * @param scope - The scope of settings being migrated + * @returns MigrationResult containing the final settings, version, and execution log + * + * @example + * ```typescript + * const result = runMigrations(settings, 'User'); + * if (result.executedMigrations.length > 0) { + * console.log(`Migrated from version ${result.executedMigrations[0].fromVersion} to ${result.finalVersion}`); + * } + * ``` + */ +export function runMigrations( + settings: unknown, + scope: string, +): MigrationResult { + const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], scope); + return scheduler.migrate(settings); +} + +/** + * Checks if the given settings need migration. + * Returns true only if at least one registered migration would be applied. + * + * This function checks: + * 1. If $version field exists and is a number: + * - Returns false if $version >= SETTINGS_VERSION + * - Returns true only when $version < SETTINGS_VERSION AND at least one + * migration can execute for the current settings shape + * 2. If $version field is missing or invalid: + * - Uses fallback logic by checking individual migrations + * + * Note: + * - Legacy numeric versions that have no executable migrations are handled by + * the settings loader via version normalization (bump metadata to current). + * + * @param settings - The settings object to check + * @returns true if migration is needed, false otherwise + */ +export function needsMigration(settings: unknown): boolean { + if (typeof settings !== 'object' || settings === null) { + return false; + } + + const s = settings as Record; + const version = s['$version']; + const hasApplicableMigration = ALL_MIGRATIONS.some((migration) => + migration.shouldMigrate(settings), + ); + + // If $version is a valid number, use version comparison + if (typeof version === 'number') { + if (version >= SETTINGS_VERSION) { + return false; + } + // Guardrail: only report migration-needed if at least one migration can execute. + return hasApplicableMigration; + } + + // If $version exists but is not a number (invalid), or is missing: + // Use fallback logic - check if any migration would be applied + return hasApplicableMigration; +} diff --git a/apps/airiscode-cli/src/config/migration/scheduler.ts b/apps/airiscode-cli/src/config/migration/scheduler.ts new file mode 100644 index 000000000..8cdbe8214 --- /dev/null +++ b/apps/airiscode-cli/src/config/migration/scheduler.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '@airiscode/core'; +import type { SettingsMigration, MigrationResult } from './types.js'; + +const debugLogger = createDebugLogger('SETTINGS_MIGRATION'); + +/** + * Formats a SettingScope enum value to a human-readable string. + * - Converts to lowercase + * - Special case: 'SystemDefaults' -> 'system default' + */ +export function formatScope(scope: string): string { + if (scope === 'SystemDefaults') { + return 'system default'; + } + return scope.toLowerCase(); +} + +/** + * Chain scheduler for settings migrations. + * + * The MigrationScheduler orchestrates multiple migrations in sequence, + * delegating version detection to each individual migration via `shouldMigrate`. + * It has no centralized version logic - migrations self-determine applicability. + * + * Key characteristics: + * - Linear chain execution: migrations are applied in registration order + * - Idempotent: already-migrated versions return false from shouldMigrate + * - Adjacent versions only: each migration handles N → N+1 + * - Pure functions: migrations don't modify input objects + */ +export class MigrationScheduler { + /** + * Creates a new MigrationScheduler with the given migrations. + * + * @param migrations - Array of migrations in execution order (typically ascending version) + * @param scope - The scope of settings being migrated + */ + constructor( + private readonly migrations: SettingsMigration[], + private readonly scope: string, + ) {} + + /** + * Executes the migration chain on the given settings. + * + * Iterates through all registered migrations in order. For each migration: + * 1. Calls `shouldMigrate` with the current settings + * 2. If true, calls `migrate` to transform the settings + * 3. Records the execution + * + * The scheduler itself has no version awareness - all version detection + * is delegated to the individual migrations. + * + * @param settings - The settings object to migrate + * @returns MigrationResult containing the final settings, version, and execution log + */ + migrate(settings: unknown): MigrationResult { + debugLogger.debug('MigrationScheduler: Starting migration chain'); + + let current = settings; + const executed: Array<{ fromVersion: number; toVersion: number }> = []; + const allWarnings: string[] = []; + + for (const migration of this.migrations) { + try { + if (migration.shouldMigrate(current)) { + debugLogger.debug( + `MigrationScheduler: Executing migration ${migration.fromVersion} → ${migration.toVersion}`, + ); + + const formattedScope = formatScope(this.scope); + const result = migration.migrate(current, formattedScope); + current = result.settings; + allWarnings.push(...result.warnings); + + executed.push({ + fromVersion: migration.fromVersion, + toVersion: migration.toVersion, + }); + + debugLogger.debug( + `MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} completed successfully`, + ); + } + } catch (error) { + debugLogger.error( + `MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} failed:`, + error, + ); + throw error; + } + } + + // Determine final version from the settings object + const finalVersion = + ((current as Record)['$version'] as number) ?? 1; + + debugLogger.debug( + `MigrationScheduler: Migration chain complete. Final version: ${finalVersion}, Executed: ${executed.length} migrations`, + ); + + return { + settings: current, + finalVersion, + executedMigrations: executed, + warnings: allWarnings, + }; + } +} diff --git a/apps/airiscode-cli/src/config/migration/types.ts b/apps/airiscode-cli/src/config/migration/types.ts new file mode 100644 index 000000000..ca1e23aaf --- /dev/null +++ b/apps/airiscode-cli/src/config/migration/types.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Interface that all settings migrations must implement. + * Each migration handles a single version transition (N → N+1). + */ +export interface SettingsMigration { + /** Source version number */ + readonly fromVersion: number; + + /** Target version number */ + readonly toVersion: number; + + /** + * Determines whether this migration should be applied to the given settings. + * The migration inspects the settings object to detect its current version + * and returns true if this migration is applicable. + * + * @param settings - The current settings object + * @returns true if this migration should be applied, false otherwise + */ + shouldMigrate(settings: unknown): boolean; + + /** + * Executes the migration transformation. + * This should be a pure function that does not modify the input object. + * + * @param settings - The current settings object of version N + * @param scope - The scope of settings being migrated + * @returns The migrated settings object of version N+1 with optional warnings + * @throws Error if the migration fails + */ + migrate( + settings: unknown, + scope: string, + ): { settings: unknown; warnings: string[] }; +} + +/** + * Result of a migration execution by MigrationScheduler. + */ +export interface MigrationResult { + /** The final settings object after all applicable migrations */ + settings: unknown; + + /** The final version number after migrations */ + finalVersion: number; + + /** List of migrations that were executed */ + executedMigrations: Array<{ fromVersion: number; toVersion: number }>; + + /** List of warning messages generated during migration */ + warnings: string[]; +} diff --git a/apps/airiscode-cli/src/config/migration/versions/v1-to-v2-shared.ts b/apps/airiscode-cli/src/config/migration/versions/v1-to-v2-shared.ts new file mode 100644 index 000000000..c63979f35 --- /dev/null +++ b/apps/airiscode-cli/src/config/migration/versions/v1-to-v2-shared.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Structural mapping table for V1 -> V2. + * + * Used by: + * - v1->v2 migration execution + * - warnings for residual legacy keys in latest-version settings files + */ +export const V1_TO_V2_MIGRATION_MAP: Record = { + accessibility: 'ui.accessibility', + allowedTools: 'tools.allowed', + allowMCPServers: 'mcp.allowed', + autoAccept: 'tools.autoAccept', + autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', + bugCommand: 'advanced.bugCommand', + chatCompression: 'model.chatCompression', + checkpointing: 'general.checkpointing', + coreTools: 'tools.core', + contextFileName: 'context.fileName', + customThemes: 'ui.customThemes', + customWittyPhrases: 'ui.customWittyPhrases', + debugKeystrokeLogging: 'general.debugKeystrokeLogging', + dnsResolutionOrder: 'advanced.dnsResolutionOrder', + enforcedAuthType: 'security.auth.enforcedType', + excludeTools: 'tools.exclude', + excludeMCPServers: 'mcp.excluded', + excludedProjectEnvVars: 'advanced.excludedEnvVars', + extensions: 'extensions', + fileFiltering: 'context.fileFiltering', + folderTrustFeature: 'security.folderTrust.featureEnabled', + folderTrust: 'security.folderTrust.enabled', + hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', + hideWindowTitle: 'ui.hideWindowTitle', + showStatusInTitle: 'ui.showStatusInTitle', + hideTips: 'ui.hideTips', + showLineNumbers: 'ui.showLineNumbers', + showCitations: 'ui.showCitations', + ideMode: 'ide.enabled', + includeDirectories: 'context.includeDirectories', + loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', + maxSessionTurns: 'model.maxSessionTurns', + mcpServers: 'mcpServers', + mcpServerCommand: 'mcp.serverCommand', + memoryImportFormat: 'context.importFormat', + model: 'model.name', + preferredEditor: 'general.preferredEditor', + sandbox: 'tools.sandbox', + selectedAuthType: 'security.auth.selectedType', + shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell', + shellPager: 'tools.shell.pager', + shellShowColor: 'tools.shell.showColor', + skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', + telemetry: 'telemetry', + theme: 'ui.theme', + toolDiscoveryCommand: 'tools.discoveryCommand', + toolCallCommand: 'tools.callCommand', + usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', + useExternalAuth: 'security.auth.useExternal', + useRipgrep: 'tools.useRipgrep', + vimMode: 'general.vimMode', + enableWelcomeBack: 'ui.enableWelcomeBack', + approvalMode: 'tools.approvalMode', + sessionTokenLimit: 'model.sessionTokenLimit', + contentGenerator: 'model.generationConfig', + skipLoopDetection: 'model.skipLoopDetection', + skipStartupContext: 'model.skipStartupContext', + enableOpenAILogging: 'model.enableOpenAILogging', + tavilyApiKey: 'advanced.tavilyApiKey', +}; + +/** + * Top-level keys that are V2/V3 containers. + * If one of these keys already has object value, treat it as latest-format data. + */ +export const V2_CONTAINER_KEYS = new Set([ + 'ui', + 'tools', + 'mcp', + 'advanced', + 'model', + 'general', + 'context', + 'security', + 'ide', + 'privacy', + 'telemetry', + 'extensions', +]); + +/** + * Legacy disable* keys that remain in disable* form for V2. + */ +export const V1_TO_V2_PRESERVE_DISABLE_MAP: Record = { + disableAutoUpdate: 'general.disableAutoUpdate', + disableUpdateNag: 'general.disableUpdateNag', + disableLoadingPhrases: 'ui.accessibility.disableLoadingPhrases', + disableFuzzySearch: 'context.fileFiltering.disableFuzzySearch', + disableCacheControl: 'model.generationConfig.disableCacheControl', +}; + +export const CONSOLIDATED_DISABLE_KEYS = new Set([ + 'disableAutoUpdate', + 'disableUpdateNag', +]); + +/** + * Keys that indicate V1-like top-level structure when holding primitive values. + */ +export const V1_INDICATOR_KEYS = [ + // From V1_TO_V2_MIGRATION_MAP - keys that map to different paths in V2 + 'theme', + 'model', + 'autoAccept', + 'hideTips', + 'vimMode', + 'checkpointing', + 'accessibility', + 'allowedTools', + 'allowMCPServers', + 'autoConfigureMaxOldSpaceSize', + 'bugCommand', + 'chatCompression', + 'coreTools', + 'contextFileName', + 'customThemes', + 'customWittyPhrases', + 'debugKeystrokeLogging', + 'dnsResolutionOrder', + 'enforcedAuthType', + 'excludeTools', + 'excludeMCPServers', + 'excludedProjectEnvVars', + 'fileFiltering', + 'folderTrustFeature', + 'folderTrust', + 'hasSeenIdeIntegrationNudge', + 'hideWindowTitle', + 'showStatusInTitle', + 'showLineNumbers', + 'showCitations', + 'ideMode', + 'includeDirectories', + 'loadMemoryFromIncludeDirectories', + 'maxSessionTurns', + 'mcpServerCommand', + 'memoryImportFormat', + 'preferredEditor', + 'sandbox', + 'selectedAuthType', + 'shouldUseNodePtyShell', + 'shellPager', + 'shellShowColor', + 'skipNextSpeakerCheck', + 'toolDiscoveryCommand', + 'toolCallCommand', + 'usageStatisticsEnabled', + 'useExternalAuth', + 'useRipgrep', + 'enableWelcomeBack', + 'approvalMode', + 'sessionTokenLimit', + 'contentGenerator', + 'skipLoopDetection', + 'skipStartupContext', + 'enableOpenAILogging', + 'tavilyApiKey', + // From V1_TO_V2_PRESERVE_DISABLE_MAP - disable* keys that get nested in V2 + 'disableAutoUpdate', + 'disableUpdateNag', + 'disableLoadingPhrases', + 'disableFuzzySearch', + 'disableCacheControl', +]; diff --git a/apps/airiscode-cli/src/config/migration/versions/v1-to-v2.ts b/apps/airiscode-cli/src/config/migration/versions/v1-to-v2.ts new file mode 100644 index 000000000..4dceffe44 --- /dev/null +++ b/apps/airiscode-cli/src/config/migration/versions/v1-to-v2.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SettingsMigration } from '../types.js'; +import { + CONSOLIDATED_DISABLE_KEYS, + V1_INDICATOR_KEYS, + V1_TO_V2_MIGRATION_MAP, + V1_TO_V2_PRESERVE_DISABLE_MAP, + V2_CONTAINER_KEYS, +} from './v1-to-v2-shared.js'; +import { setNestedPropertySafe } from '../../../utils/settingsUtils.js'; + +/** + * Heuristic indicators for deciding whether an object is "V1-like". + * + * Detection strategy: + * - A file is considered migratable as V1 when: + * 1) It is not explicitly versioned as V2+ (`$version` is missing or invalid), and + * 2) At least one indicator key appears in a legacy-compatible top-level shape. + * - Indicator list intentionally excludes keys that are valid top-level entries in + * both old and new structures to reduce false positives. + * + * Shape rule: + * - Object values for indicator keys are treated as already-nested V2-like content + * and do not alone trigger migration. + * - Primitive/array/null values on indicator keys are treated as legacy V1 signals. + */ + +/** + * V1 -> V2 migration (structural normalization stage). + * + * Migration contract: + * - Input: settings in legacy V1-like shape (mostly flat, may contain mixed partial V2). + * - Output: V2-compatible nested structure with `$version: 2`. + * - No semantic inversion of disable* naming in this stage. + * + * Data-preservation strategy: + * - Prefer transforming known keys into canonical V2 locations. + * - Preserve unrecognized keys verbatim. + * - Preserve parent-path scalar values when nested writes would collide with them. + * - Preserve/merge existing partial V2 objects where safe. + * + * This class intentionally optimizes for backward compatibility and non-destructive + * behavior over aggressive normalization. + */ +export class V1ToV2Migration implements SettingsMigration { + readonly fromVersion = 1; + readonly toVersion = 2; + + /** + * Determines whether this migration should execute. + * + * Decision strategy: + * - Hard-stop when `$version` is a number >= 2 (already V2+). + * - Otherwise, scan indicator keys and trigger only when at least one indicator is + * still in legacy top-level shape (primitive/array/null). + * + * Mixed-shape tolerance: + * - Files that are partially migrated are supported; V2-like object-valued indicators + * are ignored while legacy-shaped indicators can still trigger migration. + */ + shouldMigrate(settings: unknown): boolean { + if (typeof settings !== 'object' || settings === null) { + return false; + } + + const s = settings as Record; + + // If $version exists and is a number >= 2, it's not V1 + const version = s['$version']; + if (typeof version === 'number' && version >= 2) { + return false; + } + + // Check for V1 indicator keys with primitive values + // A setting is considered V1 if ANY indicator key has a primitive value + // (string, number, boolean, null, or array) at the top level. + // Keys with object values are skipped as they may already be in V2 format. + return V1_INDICATOR_KEYS.some((key) => { + if (!(key in s)) { + return false; + } + const value = s[key]; + // Skip keys with object values - they may already be in V2 nested format + // But don't let them block migration of other keys + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + // This key appears to be in V2 format, skip it but continue + // checking other keys + return false; + } + // Found a key with primitive value - this is V1 format + return true; + }); + } + + /** + * Performs non-destructive V1 -> V2 transformation. + * + * Detailed strategy: + * 1) Relocate known V1 keys using `V1_TO_V2_MIGRATION_MAP`. + * - If a source value is already an object and maps to a child path of itself + * (partial V2 shape), merge child properties into target path. + * 2) Relocate disable* keys into V2 disable* locations. + * - Consolidated keys (`disableAutoUpdate`, `disableUpdateNag`): normalize to + * boolean with stable-compatible presence semantics (`value === true`). + * - Other disable* keys: migrate only boolean values. + * 3) Preserve `mcpServers` top-level placement. + * 4) Carry over remaining keys: + * - If a key is parent of migrated nested paths, merge unprocessed object children. + * - If parent value is non-object, preserve that scalar/array/null as-is. + * - Otherwise copy untouched key/value. + * 5) Stamp `$version = 2`. + * + * The method is pure with respect to input mutation. + */ + migrate( + settings: unknown, + _scope: string, + ): { settings: unknown; warnings: string[] } { + if (typeof settings !== 'object' || settings === null) { + throw new Error('Settings must be an object'); + } + + const source = settings as Record; + const result: Record = {}; + const processedKeys = new Set(); + const warnings: string[] = []; + + // Step 1: Map known V1 keys to V2 nested paths + for (const [v1Key, v2Path] of Object.entries(V1_TO_V2_MIGRATION_MAP)) { + if (v1Key in source) { + const value = source[v1Key]; + + // Safety check: If this key is a V2 container (like 'model') and it's + // already an object, it's likely already in V2 format. Skip migration + // to prevent double-nesting (e.g., model.name.name). + if ( + V2_CONTAINER_KEYS.has(v1Key) && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + // This is already a V2 container, carry it over as-is + result[v1Key] = value; + processedKeys.add(v1Key); + continue; + } + + // If value is already an object and the path matches the key, + // it might be a partial V2 structure. Merge its contents. + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + v2Path.startsWith(v1Key + '.') + ) { + // Merge nested properties from this partial V2 structure + for (const [nestedKey, nestedValue] of Object.entries(value)) { + setNestedPropertySafe( + result, + `${v2Path}.${nestedKey}`, + nestedValue, + ); + } + } else { + setNestedPropertySafe(result, v2Path, value); + } + processedKeys.add(v1Key); + } + } + + // Step 2: Map V1 disable* keys to V2 nested disable* paths + for (const [v1Key, v2Path] of Object.entries( + V1_TO_V2_PRESERVE_DISABLE_MAP, + )) { + if (v1Key in source) { + const value = source[v1Key]; + if (CONSOLIDATED_DISABLE_KEYS.has(v1Key)) { + // Preserve stable behavior: consolidated keys use presence semantics. + // Only literal true remains true; all other present values become false. + setNestedPropertySafe(result, v2Path, value === true); + } else if (typeof value === 'boolean') { + // Non-consolidated disable* keys only migrate when explicitly boolean. + setNestedPropertySafe(result, v2Path, value); + } + processedKeys.add(v1Key); + } + } + + // Step 3: Preserve mcpServers at the top level + if ('mcpServers' in source) { + result['mcpServers'] = source['mcpServers']; + processedKeys.add('mcpServers'); + } + + // Step 4: Carry over any unrecognized keys (including unknown nested objects) + // Important: Skip keys that are parent paths of already-migrated properties + // to avoid overwriting merged structures (e.g., 'ui' should not overwrite 'ui.theme') + for (const key of Object.keys(source)) { + if (!processedKeys.has(key)) { + // Check if this key is a parent of any already-migrated path + const isParentOfMigratedPath = Array.from(processedKeys).some( + (processedKey) => { + // Get the v2 path for this processed key + const v2Path = + V1_TO_V2_MIGRATION_MAP[processedKey] || + V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey]; + if (!v2Path) return false; + // Check if the v2 path starts with this key + '.' + return v2Path.startsWith(key + '.'); + }, + ); + + if (isParentOfMigratedPath) { + // This key is a parent of an already-migrated path + // Merge its unprocessed children instead of overwriting + const existingValue = source[key]; + if ( + typeof existingValue === 'object' && + existingValue !== null && + !Array.isArray(existingValue) + ) { + for (const [nestedKey, nestedValue] of Object.entries( + existingValue, + )) { + // Only merge if this nested key wasn't already processed + const fullNestedPath = `${key}.${nestedKey}`; + const wasProcessed = Array.from(processedKeys).some( + (processedKey) => { + const v2Path = + V1_TO_V2_MIGRATION_MAP[processedKey] || + V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey]; + return v2Path === fullNestedPath; + }, + ); + if (!wasProcessed) { + setNestedPropertySafe(result, fullNestedPath, nestedValue); + } + } + } else { + // Preserve non-object parent values to match legacy overwrite semantics. + result[key] = source[key]; + } + } else { + // Not a parent path, safe to copy as-is + result[key] = source[key]; + } + } + } + + // Step 5: Set version to 2 + result['$version'] = 2; + + return { settings: result, warnings }; + } +} + +/** Singleton instance of V1→V2 migration */ +export const v1ToV2Migration = new V1ToV2Migration(); diff --git a/apps/airiscode-cli/src/config/migration/versions/v2-to-v3.ts b/apps/airiscode-cli/src/config/migration/versions/v2-to-v3.ts new file mode 100644 index 000000000..6c0133443 --- /dev/null +++ b/apps/airiscode-cli/src/config/migration/versions/v2-to-v3.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SettingsMigration } from '../types.js'; +import { + deleteNestedPropertySafe, + getNestedProperty, + setNestedPropertySafe, +} from '../../../utils/settingsUtils.js'; + +/** + * Path mapping for boolean polarity migration (V2 disable* -> V3 enable*). + * + * Strategy: + * - For each mapped path, values are normalized before migration: + * - boolean values are accepted directly + * - string values "true"/"false" (case-insensitive, trim-aware) are coerced + * - all other present values are treated as invalid + * - Transformation is inversion-based: disable=true -> enable=false, disable=false -> enable=true. + * - Deprecated disable* keys are removed whenever present (valid or invalid). + * - Invalid values do not create enable* keys and produce warnings. + */ +const V2_TO_V3_BOOLEAN_MAP: Record = { + 'general.disableAutoUpdate': 'general.enableAutoUpdate', + 'general.disableUpdateNag': 'general.enableAutoUpdate', + 'ui.accessibility.disableLoadingPhrases': + 'ui.accessibility.enableLoadingPhrases', + 'context.fileFiltering.disableFuzzySearch': + 'context.fileFiltering.enableFuzzySearch', + 'model.generationConfig.disableCacheControl': + 'model.generationConfig.enableCacheControl', +}; + +/** + * Consolidated old paths that collapse into one V3 field. + * + * Current policy: + * - `general.disableAutoUpdate` and `general.disableUpdateNag` both drive + * `general.enableAutoUpdate`. + * - If any valid normalized source is true, target becomes false. + * - If at least one valid normalized source exists, consolidated target is emitted. + * - Invalid present values are removed and warned, and do not contribute to target calculation. + */ +const CONSOLIDATED_V2_PATHS: Record = { + 'general.enableAutoUpdate': [ + 'general.disableAutoUpdate', + 'general.disableUpdateNag', + ], +}; + +/** + * Normalizes deprecated disable* values for migration. + * + * Returns: + * - `isPresent=false` when the path does not exist + * - `isPresent=true, isValid=true` when value is boolean or coercible string + * - `isPresent=true, isValid=false` for invalid values (number/object/array/null/other strings) + */ +function normalizeDisableValue(value: unknown): { + isPresent: boolean; + isValid: boolean; + booleanValue?: boolean; +} { + if (value === undefined) { + return { isPresent: false, isValid: false }; + } + if (typeof value === 'boolean') { + return { isPresent: true, isValid: true, booleanValue: value }; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return { isPresent: true, isValid: true, booleanValue: true }; + } + if (normalized === 'false') { + return { isPresent: true, isValid: true, booleanValue: false }; + } + } + return { isPresent: true, isValid: false }; +} + +/** + * V2 -> V3 migration (boolean polarity normalization stage). + * + * Migration contract: + * - Input: V2 settings object (`$version: 2`). + * - Output: `$version: 3` with deprecated disable* fields removed and + * valid values migrated to enable* equivalents. + * + * Compatibility strategy: + * - Accept boolean values and coercible strings "true"/"false". + * - Remove invalid deprecated values (rather than preserving them). + * - Emit warnings for each removed invalid deprecated key. + * - Always bump version to 3 so future loads are idempotent and skip repeated checks. + */ +export class V2ToV3Migration implements SettingsMigration { + readonly fromVersion = 2; + readonly toVersion = 3; + + /** + * Migration trigger rule. + * + * Execute only when `$version === 2`. + * This includes V2 files with no migratable disable* booleans so that version + * metadata still advances to 3. + */ + shouldMigrate(settings: unknown): boolean { + if (typeof settings !== 'object' || settings === null) { + return false; + } + + const s = settings as Record; + + // Migrate if $version is 2 + return s['$version'] === 2; + } + + /** + * Applies V2 -> V3 transformation with deterministic deprecated-key cleanup. + * + * Detailed strategy: + * 1) Clone input. + * 2) Process consolidated paths first: + * - Inspect each source path. + * - Normalize each present value (boolean / coercible string / invalid). + * - Always delete present deprecated source key. + * - Valid normalized values contribute to aggregate. + * - Invalid values emit warnings. + * - Emit consolidated target when at least one valid source was consumed. + * 3) Process remaining one-to-one mappings: + * - For each unmapped source, normalize value. + * - If valid -> delete old key and write inverted target. + * - If invalid -> delete old key and emit warning. + * 4) Set `$version = 3`. + * + * Guarantees: + * - Input object is not mutated. + * - Valid migration and invalid cleanup are deterministic. + * - Deprecated disable* keys are not retained after migration. + */ + migrate( + settings: unknown, + scope: string, + ): { settings: unknown; warnings: string[] } { + if (typeof settings !== 'object' || settings === null) { + throw new Error('Settings must be an object'); + } + + // Deep clone to avoid mutating input + const result = structuredClone(settings) as Record; + const processedPaths = new Set(); + const warnings: string[] = []; + + // Step 1: Handle consolidated paths (multiple old paths → single new path) + // Policy: if ANY of the old disable* settings is true, the new enable* should be false + for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) { + let hasAnyDisable = false; + let hasAnyBooleanValue = false; + + for (const oldPath of oldPaths) { + const oldValue = getNestedProperty(result, oldPath); + const normalized = normalizeDisableValue(oldValue); + if (!normalized.isPresent) { + continue; + } + + deleteNestedPropertySafe(result, oldPath); + processedPaths.add(oldPath); + + if (normalized.isValid) { + hasAnyBooleanValue = true; + if (normalized.booleanValue === true) { + hasAnyDisable = true; + } + } else { + warnings.push( + `Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean.`, + ); + } + } + + if (hasAnyBooleanValue) { + // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) + setNestedPropertySafe(result, newPath, !hasAnyDisable); + } + } + + // Step 2: Handle remaining individual disable* → enable* mappings + for (const [oldPath, newPath] of Object.entries(V2_TO_V3_BOOLEAN_MAP)) { + if (processedPaths.has(oldPath)) { + continue; + } + + const oldValue = getNestedProperty(result, oldPath); + const normalized = normalizeDisableValue(oldValue); + if (!normalized.isPresent) { + continue; + } + + deleteNestedPropertySafe(result, oldPath); + if (normalized.isValid) { + // Set new property with inverted value + setNestedPropertySafe(result, newPath, !normalized.booleanValue); + } else { + warnings.push( + `Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean or string "true"/"false".`, + ); + } + } + + // Step 3: Always update version to 3 + result['$version'] = 3; + + return { settings: result, warnings }; + } +} + +/** Singleton instance of V2→V3 migration */ +export const v2ToV3Migration = new V2ToV3Migration(); diff --git a/apps/airiscode-cli/src/config/modelProvidersScope.ts b/apps/airiscode-cli/src/config/modelProvidersScope.ts new file mode 100644 index 000000000..136141103 --- /dev/null +++ b/apps/airiscode-cli/src/config/modelProvidersScope.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingScope, type LoadedSettings } from './settings.js'; + +function hasOwnModelProviders(settingsObj: unknown): boolean { + if (!settingsObj || typeof settingsObj !== 'object') { + return false; + } + const obj = settingsObj as Record; + // Treat an explicitly configured empty object (modelProviders: {}) as "owned" + // by this scope, which is important when mergeStrategy is REPLACE. + return Object.prototype.hasOwnProperty.call(obj, 'modelProviders'); +} + +/** + * Returns which writable scope (Workspace/User) owns the effective modelProviders + * configuration. + * + * Note: Workspace scope is only considered when the workspace is trusted. + */ +export function getModelProvidersOwnerScope( + settings: LoadedSettings, +): SettingScope | undefined { + if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) { + return SettingScope.Workspace; + } + + if (hasOwnModelProviders(settings.user.settings)) { + return SettingScope.User; + } + + return undefined; +} + +/** + * Choose the settings scope to persist a model selection. + * Prefer persisting back to the scope that contains the effective modelProviders + * config, otherwise fall back to the legacy trust-based heuristic. + */ +export function getPersistScopeForModelSelection( + settings: LoadedSettings, +): SettingScope { + return getModelProvidersOwnerScope(settings) ?? SettingScope.User; +} diff --git a/apps/airiscode-cli/src/config/models.ts b/apps/airiscode-cli/src/config/models.ts deleted file mode 100644 index 72a68c05f..000000000 --- a/apps/airiscode-cli/src/config/models.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Model configuration for role-based execution - */ - -export interface RoleModelConfig { - /** Model for planning and architecture decisions */ - planner: string; - /** Model for implementation and coding tasks */ - implementer: string; - /** Model for code review and validation */ - reviewer?: string; - /** Model for testing and verification */ - tester?: string; -} - -/** - * Recommended model configurations by performance tier - */ -export const MODEL_PRESETS: Record = { - // High performance - best quality, slower - premium: { - planner: 'qwen2.5-coder:32b', - implementer: 'qwen2.5-coder:7b', - reviewer: 'qwen2.5-coder:14b', - tester: 'qwen2.5-coder:7b', - }, - - // Balanced - good quality, reasonable speed - balanced: { - planner: 'qwen2.5-coder:14b', - implementer: 'qwen2.5-coder:7b', - reviewer: 'qwen2.5-coder:7b', - tester: 'qwen2.5:3b', - }, - - // Fast - lower quality, maximum speed - fast: { - planner: 'qwen2.5-coder:7b', - implementer: 'qwen2.5:3b', - reviewer: 'qwen2.5:3b', - tester: 'qwen2.5:3b', - }, - - // Development - using currently available models - dev: { - planner: 'codegeex4', - implementer: 'qwen2.5:3b', - reviewer: 'llama3.2:3b', - }, -}; - -/** - * Get model for specific role - */ -export function getModelForRole( - role: 'planner' | 'implementer' | 'reviewer' | 'tester', - preset: string = 'balanced' -): string { - const config = MODEL_PRESETS[preset] || MODEL_PRESETS.balanced; - return config[role] || config.implementer; -} - -/** - * Model metadata for display and selection - */ -export interface ModelInfo { - name: string; - size: string; - description: string; - recommended: boolean; - roles: Array<'planner' | 'implementer' | 'reviewer' | 'tester'>; -} - -export const KNOWN_MODELS: ModelInfo[] = [ - { - name: 'qwen2.5-coder:32b', - size: '19GB', - description: 'Best for planning and architecture', - recommended: true, - roles: ['planner', 'reviewer'], - }, - { - name: 'qwen2.5-coder:14b', - size: '8GB', - description: 'Balanced planning and implementation', - recommended: true, - roles: ['planner', 'implementer', 'reviewer'], - }, - { - name: 'qwen2.5-coder:7b', - size: '4.5GB', - description: 'Fast implementation and coding', - recommended: true, - roles: ['implementer', 'reviewer', 'tester'], - }, - { - name: 'qwen2.5:3b', - size: '1.9GB', - description: 'Ultra-fast for simple tasks', - recommended: true, - roles: ['implementer', 'tester'], - }, - { - name: 'deepseek-coder:33b', - size: '19GB', - description: 'Alternative high-quality planner', - recommended: false, - roles: ['planner'], - }, - { - name: 'codegeex4', - size: '5.5GB', - description: 'Code generation specialist', - recommended: false, - roles: ['implementer'], - }, -]; diff --git a/apps/airiscode-cli/src/gemini-base/config/sandboxConfig.ts b/apps/airiscode-cli/src/config/sandboxConfig.ts similarity index 79% rename from apps/airiscode-cli/src/gemini-base/config/sandboxConfig.ts rename to apps/airiscode-cli/src/config/sandboxConfig.ts index 4f53451b5..bc5248a41 100644 --- a/apps/airiscode-cli/src/gemini-base/config/sandboxConfig.ts +++ b/apps/airiscode-cli/src/config/sandboxConfig.ts @@ -4,25 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - getPackageJson, - type SandboxConfig, - FatalSandboxError, -} from '@airiscode/gemini-cli-core'; +import type { SandboxConfig } from '@airiscode/core'; +import { FatalSandboxError } from '@airiscode/core'; import commandExists from 'command-exists'; import * as os from 'node:os'; +import { getPackageJson } from '../utils/package.js'; import type { Settings } from './settings.js'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); // This is a stripped-down version of the CliArgs interface from config.ts // to avoid circular dependencies. interface SandboxCliArgs { sandbox?: boolean | string; + sandboxImage?: string; } + const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ 'docker', 'podman', @@ -43,7 +38,7 @@ function getSandboxCommand( // note environment variable takes precedence over argument (from command line or settings) const environmentConfiguredSandbox = - process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? ''; + process.env['AIRISCODE_SANDBOX']?.toLowerCase().trim() ?? ''; sandbox = environmentConfiguredSandbox?.length > 0 ? environmentConfiguredSandbox @@ -68,7 +63,7 @@ function getSandboxCommand( return sandbox; } throw new FatalSandboxError( - `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, + `Missing sandbox command '${sandbox}' (from AIRISCODE_SANDBOX)`, ); } @@ -85,8 +80,8 @@ function getSandboxCommand( // throw an error if user requested sandbox but no command was found if (sandbox === true) { throw new FatalSandboxError( - 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + - 'install docker or podman or specify command in GEMINI_SANDBOX', + 'AIRISCODE_SANDBOX is true but failed to determine command for sandbox; ' + + 'install docker or podman or specify command in AIRISCODE_SANDBOX', ); } @@ -100,9 +95,11 @@ export async function loadSandboxConfig( const sandboxOption = argv.sandbox ?? settings.tools?.sandbox; const command = getSandboxCommand(sandboxOption); - const packageJson = await getPackageJson(__dirname); + const packageJson = await getPackageJson(); const image = - process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri; + argv.sandboxImage ?? + process.env['AIRISCODE_SANDBOX_IMAGE'] ?? + packageJson?.config?.sandboxImageUri; return command && image ? { command, image } : undefined; } diff --git a/apps/airiscode-cli/src/config/settings.ts b/apps/airiscode-cli/src/config/settings.ts new file mode 100644 index 000000000..06f33f480 --- /dev/null +++ b/apps/airiscode-cli/src/config/settings.ts @@ -0,0 +1,835 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir, platform } from 'node:os'; +import * as dotenv from 'dotenv'; +import process from 'node:process'; +import { + FatalConfigError, + AIRISCODE_DIR, + getErrorMessage, + Storage, + createDebugLogger, +} from '@airiscode/core'; +import stripJsonComments from 'strip-json-comments'; +import { DefaultLight } from '../ui/themes/default-light.js'; +import { DefaultDark } from '../ui/themes/default.js'; +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { + type Settings, + type MemoryImportFormat, + type MergeStrategy, + type SettingsSchema, + type SettingDefinition, + getSettingsSchema, +} from './settingsSchema.js'; +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; +import { setNestedPropertySafe } from '../utils/settingsUtils.js'; +import { customDeepMerge } from '../utils/deepMerge.js'; +import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; +import { runMigrations, needsMigration } from './migration/index.js'; +import { + V1_TO_V2_MIGRATION_MAP, + V2_CONTAINER_KEYS, +} from './migration/versions/v1-to-v2-shared.js'; +import { writeWithBackupSync } from '../utils/writeWithBackup.js'; + +const debugLogger = createDebugLogger('SETTINGS'); + +function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { + let current: SettingDefinition | undefined = undefined; + let currentSchema: SettingsSchema | undefined = getSettingsSchema(); + + for (const key of path) { + if (!currentSchema || !currentSchema[key]) { + return undefined; + } + current = currentSchema[key]; + currentSchema = current.properties; + } + + return current?.mergeStrategy; +} + +export type { Settings, MemoryImportFormat }; + +export const SETTINGS_DIRECTORY_NAME = '.airiscode'; +export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); +export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); +export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; + +// Settings version to track migration state +export const SETTINGS_VERSION = 3; +export const SETTINGS_VERSION_KEY = '$version'; + +/** + * Migrate legacy tool permission settings (tools.core / tools.allowed / tools.exclude) + * to the new permissions.allow / permissions.ask / permissions.deny format. + * + * Conversion rules: + * tools.allowed → permissions.allow (bypass confirmation) + * tools.exclude → permissions.deny (block tools) + * tools.core → permissions.allow (only listed tools enabled) + * + permissions.deny with a wildcard deny-all if needed + * + * Returns the updated settings object, or null if no migration is needed. + */ +export function migrateLegacyPermissions( + settings: Record, +): Record | null { + const tools = settings['tools'] as Record | undefined; + if (!tools) return null; + + const hasLegacy = + Array.isArray(tools['core']) || + Array.isArray(tools['allowed']) || + Array.isArray(tools['exclude']); + + if (!hasLegacy) return null; + + const result = structuredClone(settings) as Record; + const resultTools = result['tools'] as Record; + const permissions = (result['permissions'] as Record) ?? {}; + result['permissions'] = permissions; + + const mergeInto = (key: string, items: string[]) => { + const existing = Array.isArray(permissions[key]) + ? (permissions[key] as string[]) + : []; + const merged = Array.from(new Set([...existing, ...items])); + permissions[key] = merged; + }; + + // tools.allowed → permissions.allow + if (Array.isArray(resultTools['allowed'])) { + mergeInto('allow', resultTools['allowed'] as string[]); + delete resultTools['allowed']; + } + + // tools.exclude → permissions.deny + if (Array.isArray(resultTools['exclude'])) { + mergeInto('deny', resultTools['exclude'] as string[]); + delete resultTools['exclude']; + } + + // tools.core → permissions.allow (explicit enables) + // IMPORTANT: tools.core has whitelist semantics: "only these tools can run". + // To preserve this, we also add deny rules for all tools NOT in the list. + // A wildcard deny-all followed by specific allows achieves this because + // allow rules take precedence over the catch-all deny in the evaluation order: + // deny = [everything not listed], allow = [listed tools] + // However, since our priority is deny > allow, we cannot use a blanket deny. + // Instead we just migrate to allow (auto-approve) and let the coreTools + // semantics continue to work through the Config.getCoreTools() path until + // the old API is fully removed. + if (Array.isArray(resultTools['core'])) { + mergeInto('allow', resultTools['core'] as string[]); + delete resultTools['core']; + } + + return result; +} + +export function getSystemSettingsPath(): string { + if (process.env['AIRISCODE_SYSTEM_SETTINGS_PATH']) { + return process.env['AIRISCODE_SYSTEM_SETTINGS_PATH']; + } + if (platform() === 'darwin') { + return '/Library/Application Support/AirisCode/settings.json'; + } else if (platform() === 'win32') { + return 'C:\\ProgramData\\airiscode\\settings.json'; + } else { + return '/etc/airiscode/settings.json'; + } +} + +export function getSystemDefaultsPath(): string { + if (process.env['AIRISCODE_SYSTEM_DEFAULTS_PATH']) { + return process.env['AIRISCODE_SYSTEM_DEFAULTS_PATH']; + } + return path.join( + path.dirname(getSystemSettingsPath()), + 'system-defaults.json', + ); +} + +export type { DnsResolutionOrder } from './settingsSchema.js'; + +export enum SettingScope { + User = 'User', + Workspace = 'Workspace', + System = 'System', + SystemDefaults = 'SystemDefaults', +} + +export interface CheckpointingSettings { + enabled?: boolean; +} + +export interface AccessibilitySettings { + enableLoadingPhrases?: boolean; + screenReader?: boolean; +} + +export interface SettingsError { + message: string; + path: string; +} + +export interface SettingsFile { + settings: Settings; + originalSettings: Settings; + path: string; + rawJson?: string; +} + +function getSettingsFileKeyWarnings( + settings: Record, + settingsFilePath: string, +): string[] { + const version = settings[SETTINGS_VERSION_KEY]; + if (typeof version !== 'number' || version < SETTINGS_VERSION) { + return []; + } + + const warnings: string[] = []; + const ignoredLegacyKeys = new Set(); + + // Ignored legacy keys (V1 top-level keys that moved to a nested V2 path). + for (const [oldKey, newPath] of Object.entries(V1_TO_V2_MIGRATION_MAP)) { + if (oldKey === newPath) { + continue; + } + if (!(oldKey in settings)) { + continue; + } + + const oldValue = settings[oldKey]; + + // If this key is a V2 container (like 'model') and it's already an object, + // it's likely already in V2 format. Don't warn. + if ( + V2_CONTAINER_KEYS.has(oldKey) && + typeof oldValue === 'object' && + oldValue !== null && + !Array.isArray(oldValue) + ) { + continue; + } + + ignoredLegacyKeys.add(oldKey); + warnings.push( + `Warning: Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`, + ); + } + + // Unknown top-level keys — log silently to debug output. + const schemaKeys = new Set(Object.keys(getSettingsSchema())); + for (const key of Object.keys(settings)) { + if (key === SETTINGS_VERSION_KEY) { + continue; + } + if (ignoredLegacyKeys.has(key)) { + continue; + } + if (schemaKeys.has(key)) { + continue; + } + + debugLogger.warn( + `Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, + ); + } + + return warnings; +} + +/** + * Collects warnings for ignored legacy and unknown settings keys, + * as well as migration warnings. + * + * For `$version: 2` settings files, we do not apply implicit migrations. + * Instead, we surface actionable, de-duplicated warnings in the terminal UI. + */ +export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] { + const warningSet = new Set(); + + // Add migration warnings first + for (const warning of loadedSettings.migrationWarnings) { + warningSet.add(`Warning: ${warning}`); + } + + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const settingsFile = loadedSettings.forScope(scope); + if (settingsFile.rawJson === undefined) { + continue; + // File not present / not loaded. + } + const settingsObject = settingsFile.originalSettings as unknown as Record< + string, + unknown + >; + + for (const warning of getSettingsFileKeyWarnings( + settingsObject, + settingsFile.path, + )) { + warningSet.add(warning); + } + } + + return [...warningSet]; +} + +function mergeSettings( + system: Settings, + systemDefaults: Settings, + user: Settings, + workspace: Settings, + isTrusted: boolean, +): Settings { + const safeWorkspace = isTrusted ? workspace : ({} as Settings); + + // Settings are merged with the following precedence (last one wins for + // single values): + // 1. System Defaults + // 2. User Settings + // 3. Workspace Settings + // 4. System Settings (as overrides) + return customDeepMerge( + getMergeStrategyForPath, + {}, // Start with an empty object + systemDefaults, + user, + safeWorkspace, + system, + ) as Settings; +} + +export class LoadedSettings { + constructor( + system: SettingsFile, + systemDefaults: SettingsFile, + user: SettingsFile, + workspace: SettingsFile, + isTrusted: boolean, + migratedInMemorScopes: Set, + migrationWarnings: string[] = [], + ) { + this.system = system; + this.systemDefaults = systemDefaults; + this.user = user; + this.workspace = workspace; + this.isTrusted = isTrusted; + this.migratedInMemorScopes = migratedInMemorScopes; + this.migrationWarnings = migrationWarnings; + this._merged = this.computeMergedSettings(); + } + + readonly system: SettingsFile; + readonly systemDefaults: SettingsFile; + readonly user: SettingsFile; + readonly workspace: SettingsFile; + readonly isTrusted: boolean; + readonly migratedInMemorScopes: Set; + readonly migrationWarnings: string[]; + + private _merged: Settings; + + get merged(): Settings { + return this._merged; + } + + private computeMergedSettings(): Settings { + return mergeSettings( + this.system.settings, + this.systemDefaults.settings, + this.user.settings, + this.workspace.settings, + this.isTrusted, + ); + } + + forScope(scope: SettingScope): SettingsFile { + switch (scope) { + case SettingScope.User: + return this.user; + case SettingScope.Workspace: + return this.workspace; + case SettingScope.System: + return this.system; + case SettingScope.SystemDefaults: + return this.systemDefaults; + default: + throw new Error(`Invalid scope: ${scope}`); + } + } + + setValue(scope: SettingScope, key: string, value: unknown): void { + const settingsFile = this.forScope(scope); + setNestedPropertySafe(settingsFile.settings, key, value); + setNestedPropertySafe(settingsFile.originalSettings, key, value); + this._merged = this.computeMergedSettings(); + saveSettings(settingsFile, createSettingsUpdate(key, value)); + } +} + +/** + * Creates a minimal LoadedSettings instance with empty settings. + * Used in stream-json mode where settings are ignored. + */ +export function createMinimalSettings(): LoadedSettings { + const emptySettingsFile: SettingsFile = { + path: '', + settings: {}, + originalSettings: {}, + rawJson: '{}', + }; + return new LoadedSettings( + emptySettingsFile, + emptySettingsFile, + emptySettingsFile, + emptySettingsFile, + false, + new Set(), + [], + ); +} + +/** + * Finds the .env file to load, respecting workspace trust settings. + * + * When workspace is untrusted, only allow user-level .env files at: + * - ~/.airiscode/.env + * - ~/.env + */ +function findEnvFile(settings: Settings, startDir: string): string | null { + const homeDir = homedir(); + const isTrusted = isWorkspaceTrusted(settings).isTrusted; + + // Pre-compute user-level .env paths for fast comparison + const userLevelPaths = new Set([ + path.normalize(path.join(homeDir, '.env')), + path.normalize(path.join(homeDir, AIRISCODE_DIR, '.env')), + ]); + + // Determine if we can use this .env file based on trust settings + const canUseEnvFile = (filePath: string): boolean => + isTrusted !== false || userLevelPaths.has(path.normalize(filePath)); + + let currentDir = path.resolve(startDir); + while (true) { + // Prefer gemini-specific .env under AIRISCODE_DIR + const geminiEnvPath = path.join(currentDir, AIRISCODE_DIR, '.env'); + if (fs.existsSync(geminiEnvPath) && canUseEnvFile(geminiEnvPath)) { + return geminiEnvPath; + } + + const envPath = path.join(currentDir, '.env'); + if (fs.existsSync(envPath) && canUseEnvFile(envPath)) { + return envPath; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir || !parentDir) { + // At home directory - check fallback .env files + const homeGeminiEnvPath = path.join(homeDir, AIRISCODE_DIR, '.env'); + if (fs.existsSync(homeGeminiEnvPath)) { + return homeGeminiEnvPath; + } + const homeEnvPath = path.join(homeDir, '.env'); + if (fs.existsSync(homeEnvPath)) { + return homeEnvPath; + } + return null; + } + currentDir = parentDir; + } +} + +export function setUpCloudShellEnvironment(envFilePath: string | null): void { + // Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell: + // Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project + // set by the user using "gcloud config set project" we do not want to + // use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in + // one of the .env files, we set the Cloud Shell-specific default here. + if (envFilePath && fs.existsSync(envFilePath)) { + const envFileContent = fs.readFileSync(envFilePath); + const parsedEnv = dotenv.parse(envFileContent); + if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { + // .env file takes precedence in Cloud Shell + process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; + } else { + // If not in .env, set to default and override global + process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; + } + } else { + // If no .env file, set to default and override global + process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; + } +} +/** + * Loads environment variables from .env files and settings.env. + * + * Priority order (highest to lowest): + * 1. CLI flags + * 2. process.env (system/export/inline environment variables) + * 3. .env files (no-override mode) + * 4. settings.env (no-override mode) + * 5. defaults + */ +export function loadEnvironment(settings: Settings): void { + const envFilePath = findEnvFile(settings, process.cwd()); + + // Cloud Shell environment variable handling + if (process.env['CLOUD_SHELL'] === 'true') { + setUpCloudShellEnvironment(envFilePath); + } + + // Step 1: Load from .env files (higher priority than settings.env) + // Only set if not already present in process.env (no-override mode) + if (envFilePath) { + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + + const excludedVars = + settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const isProjectEnvFile = !envFilePath.includes(AIRISCODE_DIR); + + for (const key in parsedEnv) { + if (Object.hasOwn(parsedEnv, key)) { + // If it's a project .env file, skip loading excluded variables. + if (isProjectEnvFile && excludedVars.includes(key)) { + continue; + } + + // Only set if not already present in process.env (no-override) + if (!Object.hasOwn(process.env, key)) { + process.env[key] = parsedEnv[key]; + } + } + } + } catch (_e) { + // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. + } + } + + // Step 2: Load environment variables from settings.env as fallback (lowest priority) + // Only set if not already present (no-override, after .env is loaded) + if (settings.env) { + for (const [key, value] of Object.entries(settings.env)) { + if (!Object.hasOwn(process.env, key) && typeof value === 'string') { + process.env[key] = value; + } + } + } +} + +/** + * Loads settings from user and workspace directories. + * Project settings override user settings. + */ +export function loadSettings( + workspaceDir: string = process.cwd(), +): LoadedSettings { + let systemSettings: Settings = {}; + let systemDefaultSettings: Settings = {}; + let userSettings: Settings = {}; + let workspaceSettings: Settings = {}; + const settingsErrors: SettingsError[] = []; + const systemSettingsPath = getSystemSettingsPath(); + const systemDefaultsPath = getSystemDefaultsPath(); + const migratedInMemorScopes = new Set(); + + // Resolve paths to their canonical representation to handle symlinks + const resolvedWorkspaceDir = path.resolve(workspaceDir); + const resolvedHomeDir = path.resolve(homedir()); + + let realWorkspaceDir = resolvedWorkspaceDir; + try { + // fs.realpathSync gets the "true" path, resolving any symlinks + realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir); + } catch (_e) { + // This is okay. The path might not exist yet, and that's a valid state. + } + + // We expect homedir to always exist and be resolvable. + const realHomeDir = fs.realpathSync(resolvedHomeDir); + + const workspaceSettingsPath = new Storage( + workspaceDir, + ).getWorkspaceSettingsPath(); + + const loadAndMigrate = ( + filePath: string, + scope: SettingScope, + ): { settings: Settings; rawJson?: string; migrationWarnings?: string[] } => { + try { + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8'); + const rawSettings: unknown = JSON.parse(stripJsonComments(content)); + + if ( + typeof rawSettings !== 'object' || + rawSettings === null || + Array.isArray(rawSettings) + ) { + settingsErrors.push({ + message: 'Settings file is not a valid JSON object.', + path: filePath, + }); + return { settings: {} }; + } + + let settingsObject = rawSettings as Record; + const hasVersionKey = SETTINGS_VERSION_KEY in settingsObject; + const versionValue = settingsObject[SETTINGS_VERSION_KEY]; + const hasInvalidVersion = + hasVersionKey && typeof versionValue !== 'number'; + const hasLegacyNumericVersion = + typeof versionValue === 'number' && versionValue < SETTINGS_VERSION; + let migrationWarnings: string[] | undefined; + + const persistSettingsObject = (warningPrefix: string) => { + try { + writeWithBackupSync( + filePath, + JSON.stringify(settingsObject, null, 2), + ); + } catch (e) { + debugLogger.error(`${warningPrefix}: ${getErrorMessage(e)}`); + } + }; + + if (needsMigration(settingsObject)) { + const migrationResult = runMigrations(settingsObject, scope); + if (migrationResult.executedMigrations.length > 0) { + settingsObject = migrationResult.settings as Record< + string, + unknown + >; + migrationWarnings = migrationResult.warnings; + persistSettingsObject('Error migrating settings file on disk'); + } else if (hasLegacyNumericVersion || hasInvalidVersion) { + // Migration was deemed needed but nothing executed. Normalize version metadata + // to avoid repeated no-op checks on startup. + settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; + debugLogger.warn( + `Settings version metadata in ${filePath} could not be migrated by any registered migration. Normalizing ${SETTINGS_VERSION_KEY} to ${SETTINGS_VERSION}.`, + ); + persistSettingsObject('Error normalizing settings version on disk'); + } + } else if ( + !hasVersionKey || + hasInvalidVersion || + hasLegacyNumericVersion + ) { + // No migration needed/executable, but version metadata is missing or invalid. + // Normalize it to current version to avoid repeated startup work. + settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; + persistSettingsObject('Error normalizing settings version on disk'); + } + + return { + settings: settingsObject as Settings, + rawJson: content, + migrationWarnings, + }; + } + } catch (error: unknown) { + settingsErrors.push({ + message: getErrorMessage(error), + path: filePath, + }); + } + return { settings: {} }; + }; + + const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System); + const systemDefaultsResult = loadAndMigrate( + systemDefaultsPath, + SettingScope.SystemDefaults, + ); + const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); + + let workspaceResult: { + settings: Settings; + rawJson?: string; + migrationWarnings?: string[]; + } = { + settings: {} as Settings, + rawJson: undefined, + }; + if (realWorkspaceDir !== realHomeDir) { + workspaceResult = loadAndMigrate( + workspaceSettingsPath, + SettingScope.Workspace, + ); + } + + // Load workspace-local settings (.airiscode/settings.local.json) + // This file is gitignored and has higher priority than workspace settings + let workspaceLocalSettings: Settings = {}; + if (realWorkspaceDir !== realHomeDir) { + const localSettingsPath = path.join( + path.dirname(workspaceSettingsPath), + 'settings.local.json', + ); + try { + if (fs.existsSync(localSettingsPath)) { + const content = fs.readFileSync(localSettingsPath, 'utf-8'); + const parsed = JSON.parse(stripJsonComments(content)); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + workspaceLocalSettings = parsed as Settings; + debugLogger.debug(`Loaded local settings from ${localSettingsPath}`); + } + } + } catch (e) { + debugLogger.warn(`Failed to load local settings: ${e}`); + } + } + + const systemOriginalSettings = structuredClone(systemResult.settings); + const systemDefaultsOriginalSettings = structuredClone( + systemDefaultsResult.settings, + ); + const userOriginalSettings = structuredClone(userResult.settings); + const workspaceOriginalSettings = structuredClone(workspaceResult.settings); + + // Environment variables for runtime use + systemSettings = resolveEnvVarsInObject(systemResult.settings); + systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings); + userSettings = resolveEnvVarsInObject(userResult.settings); + workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); + + // Merge workspace-local settings on top of workspace settings + if (Object.keys(workspaceLocalSettings).length > 0) { + workspaceSettings = customDeepMerge( + getMergeStrategyForPath, + workspaceSettings, + resolveEnvVarsInObject(workspaceLocalSettings), + ); + } + + // Support legacy theme names + if (userSettings.ui?.theme === 'VS') { + userSettings.ui.theme = DefaultLight.name; + } else if (userSettings.ui?.theme === 'VS2015') { + userSettings.ui.theme = DefaultDark.name; + } + if (workspaceSettings.ui?.theme === 'VS') { + workspaceSettings.ui.theme = DefaultLight.name; + } else if (workspaceSettings.ui?.theme === 'VS2015') { + workspaceSettings.ui.theme = DefaultDark.name; + } + + // For the initial trust check, we can only use user and system settings. + const initialTrustCheckSettings = customDeepMerge( + getMergeStrategyForPath, + {}, + systemSettings, + userSettings, + ); + const isTrusted = + isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; + + // Create a temporary merged settings object to pass to loadEnvironment. + const tempMergedSettings = mergeSettings( + systemSettings, + systemDefaultSettings, + userSettings, + workspaceSettings, + isTrusted, + ); + + // loadEnviroment depends on settings so we have to create a temp version of + // the settings to avoid a cycle + loadEnvironment(tempMergedSettings); + + // Create LoadedSettings first + + if (settingsErrors.length > 0) { + const errorMessages = settingsErrors.map( + (error) => `Error in ${error.path}: ${error.message}`, + ); + throw new FatalConfigError( + `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, + ); + } + + // Collect all migration warnings from all scopes + const allMigrationWarnings: string[] = [ + ...(systemResult.migrationWarnings ?? []), + ...(systemDefaultsResult.migrationWarnings ?? []), + ...(userResult.migrationWarnings ?? []), + ...(workspaceResult.migrationWarnings ?? []), + ]; + + return new LoadedSettings( + { + path: systemSettingsPath, + settings: systemSettings, + originalSettings: systemOriginalSettings, + rawJson: systemResult.rawJson, + }, + { + path: systemDefaultsPath, + settings: systemDefaultSettings, + originalSettings: systemDefaultsOriginalSettings, + rawJson: systemDefaultsResult.rawJson, + }, + { + path: USER_SETTINGS_PATH, + settings: userSettings, + originalSettings: userOriginalSettings, + rawJson: userResult.rawJson, + }, + { + path: workspaceSettingsPath, + settings: workspaceSettings, + originalSettings: workspaceOriginalSettings, + rawJson: workspaceResult.rawJson, + }, + isTrusted, + migratedInMemorScopes, + allMigrationWarnings, + ); +} + +function createSettingsUpdate( + key: string, + value: unknown, +): Record { + const root: Record = {}; + setNestedPropertySafe(root, key, value); + return root; +} + +export function saveSettings( + settingsFile: SettingsFile, + updates: Record = settingsFile.originalSettings as Record< + string, + unknown + >, +): void { + try { + // Ensure the directory exists + const dirPath = path.dirname(settingsFile.path); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + // Use the format-preserving update function + updateSettingsFilePreservingFormat(settingsFile.path, updates); + } catch (error) { + debugLogger.error('Error saving user settings file.'); + debugLogger.error(error instanceof Error ? error.message : String(error)); + throw error; + } +} diff --git a/apps/airiscode-cli/src/config/settingsSchema.ts b/apps/airiscode-cli/src/config/settingsSchema.ts new file mode 100644 index 000000000..c67ad25ea --- /dev/null +++ b/apps/airiscode-cli/src/config/settingsSchema.ts @@ -0,0 +1,1700 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + MCPServerConfig, + BugCommandSettings, + TelemetrySettings, + AuthType, + ChatCompressionSettings, + ModelProvidersConfig, +} from '@airiscode/core'; +import { + ApprovalMode, + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, +} from '@airiscode/core'; +import type { CustomTheme } from '../ui/themes/theme.js'; +import { getLanguageSettingsOptions } from '../i18n/languages.js'; + +export type SettingsType = + | 'boolean' + | 'string' + | 'number' + | 'array' + | 'object' + | 'enum'; + +export type SettingsValue = + | boolean + | string + | number + | string[] + | object + | undefined; + +/** + * Setting datatypes that "toggle" through a fixed list of options + * (e.g. an enum or true/false) rather than allowing for free form input + * (like a number or string). + */ +export const TOGGLE_TYPES: ReadonlySet = new Set([ + 'boolean', + 'enum', +]); + +export interface SettingEnumOption { + value: string | number; + label: string; +} + +export enum MergeStrategy { + // Replace the old value with the new value. This is the default. + REPLACE = 'replace', + // Concatenate arrays. + CONCAT = 'concat', + // Merge arrays, ensuring unique values. + UNION = 'union', + // Shallow merge objects. + SHALLOW_MERGE = 'shallow_merge', +} + +export interface SettingDefinition { + type: SettingsType; + label: string; + category: string; + requiresRestart: boolean; + default: SettingsValue; + description?: string; + parentKey?: string; + key?: string; + properties?: SettingsSchema; + showInDialog?: boolean; + mergeStrategy?: MergeStrategy; + /** Enum type options */ + options?: readonly SettingEnumOption[]; + /** Schema for array items when type is 'array' */ + items?: SettingItemDefinition; +} + +/** + * Schema definition for array item types. + * Supports simple types (string, number, boolean) and complex object types. + */ +export interface SettingItemDefinition { + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + properties?: Record< + string, + SettingItemDefinition & { + required?: boolean; + enum?: string[]; + additionalProperties?: SettingItemDefinition; + } + >; + items?: SettingItemDefinition; + required?: boolean; + enum?: string[]; + description?: string; + additionalProperties?: boolean | SettingItemDefinition; +} + +export interface SettingsSchema { + [key: string]: SettingDefinition; +} + +/** + * Common items schema for hook definitions. + * Used by all hook event types in the hooks configuration. + */ +const HOOK_DEFINITION_ITEMS: SettingItemDefinition = { + type: 'object', + description: + 'A hook definition with an optional matcher and a list of hook configurations.', + properties: { + matcher: { + type: 'string', + description: + 'An optional matcher pattern to filter when this hook definition applies.', + }, + sequential: { + type: 'boolean', + description: + 'Whether the hooks should be executed sequentially instead of in parallel.', + }, + hooks: { + type: 'array', + description: 'The list of hook configurations to execute.', + required: true, + items: { + type: 'object', + description: + 'A hook configuration entry that defines a command to execute.', + properties: { + type: { + type: 'string', + description: 'The type of hook.', + enum: ['command'], + required: true, + }, + command: { + type: 'string', + description: 'The command to execute when the hook is triggered.', + required: true, + }, + name: { + type: 'string', + description: 'An optional name for the hook.', + }, + description: { + type: 'string', + description: 'An optional description of what the hook does.', + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for the hook execution.', + }, + env: { + type: 'object', + description: + 'Environment variables to set when executing the hook command.', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, +}; + +export type MemoryImportFormat = 'tree' | 'flat'; +export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; + +/** + * The canonical schema for all settings. + * The structure of this object defines the structure of the `Settings` type. + * `as const` is crucial for TypeScript to infer the most specific types possible. + */ +const SETTINGS_SCHEMA = { + // Maintained for compatibility/criticality + mcpServers: { + type: 'object', + label: 'MCP Servers', + category: 'Advanced', + requiresRestart: true, + default: {} as Record, + description: 'Configuration for MCP servers.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, + + // Channels configuration (Telegram, Discord, etc.) + channels: { + type: 'object', + label: 'Channels', + category: 'Advanced', + requiresRestart: true, + default: {} as Record>, + description: 'Configuration for messaging channels.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, + + // Model providers configuration grouped by authType + modelProviders: { + type: 'object', + label: 'Model Providers', + category: 'Model', + requiresRestart: false, + default: {} as ModelProvidersConfig, + description: + 'Model providers configuration grouped by authType. Each authType contains an array of model configurations.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + + // Coding Plan configuration + codingPlan: { + type: 'object', + label: 'Coding Plan', + category: 'Model', + requiresRestart: false, + default: {}, + description: 'Coding Plan template version tracking and configuration.', + showInDialog: false, + properties: { + version: { + type: 'string', + label: 'Coding Plan Template Version', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'SHA256 hash of the Coding Plan template. Used to detect template updates.', + showInDialog: false, + }, + }, + }, + + // Environment variables fallback + env: { + type: 'object', + label: 'Environment Variables', + category: 'Advanced', + requiresRestart: true, + default: {} as Record, + description: + 'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, + + general: { + type: 'object', + label: 'General', + category: 'General', + requiresRestart: false, + default: {}, + description: 'General application settings.', + showInDialog: false, + properties: { + preferredEditor: { + type: 'string', + label: 'Preferred Editor', + category: 'General', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The preferred editor to open files in.', + showInDialog: true, + }, + vimMode: { + type: 'boolean', + label: 'Vim Mode', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable Vim keybindings', + showInDialog: true, + }, + enableAutoUpdate: { + type: 'boolean', + label: 'Enable Auto Update', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Enable automatic update checks and installations on startup.', + showInDialog: true, + }, + gitCoAuthor: { + type: 'boolean', + label: 'Attribution: commit', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Automatically add a Co-authored-by trailer to git commit messages when commits are made through AIRIS Code.', + showInDialog: true, + }, + checkpointing: { + type: 'object', + label: 'Checkpointing', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Session checkpointing settings.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Checkpointing', + category: 'General', + requiresRestart: true, + default: false, + description: 'Enable session checkpointing for recovery', + showInDialog: false, + }, + }, + }, + debugKeystrokeLogging: { + type: 'boolean', + label: 'Debug Keystroke Logging', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable debug logging of keystrokes to the console.', + showInDialog: false, + }, + language: { + type: 'enum', + label: 'Language: UI', + category: 'General', + requiresRestart: true, + default: 'auto', + description: + 'The language for the user interface. Use "auto" to detect from system settings. ' + + 'You can also use custom language codes (e.g., "es", "fr") by placing JS language files ' + + 'in ~/.airiscode/locales/ (e.g., ~/.airiscode/locales/es.js).', + showInDialog: true, + options: [] as readonly SettingEnumOption[], + }, + outputLanguage: { + type: 'string', + label: 'Language: Model', + category: 'General', + requiresRestart: true, + default: 'auto', + description: + 'The language for LLM output. Use "auto" to detect from system settings, ' + + 'or set a specific language.', + showInDialog: true, + }, + terminalBell: { + type: 'boolean', + label: 'Terminal Bell Notification', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Play terminal bell sound when response completes or needs approval.', + showInDialog: true, + }, + chatRecording: { + type: 'boolean', + label: 'Chat Recording', + category: 'General', + requiresRestart: true, + default: true, + description: + 'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.', + showInDialog: false, + }, + defaultFileEncoding: { + type: 'enum', + label: 'Default File Encoding', + category: 'General', + requiresRestart: false, + default: 'utf-8', + description: + 'Default encoding for new files. Use "utf-8" (default) for UTF-8 without BOM, or "utf-8-bom" for UTF-8 with BOM. Only change this if your project specifically requires BOM.', + showInDialog: false, + options: [ + { value: 'utf-8', label: 'UTF-8 (without BOM)' }, + { value: 'utf-8-bom', label: 'UTF-8 with BOM' }, + ], + }, + }, + }, + output: { + type: 'object', + label: 'Output', + category: 'General', + requiresRestart: false, + default: {}, + description: 'Settings for the CLI output.', + showInDialog: false, + properties: { + format: { + type: 'enum', + label: 'Output Format', + category: 'General', + requiresRestart: false, + default: 'text', + description: 'The format of the CLI output.', + showInDialog: false, + options: [ + { value: 'text', label: 'Text' }, + { value: 'json', label: 'JSON' }, + ], + }, + }, + }, + + ui: { + type: 'object', + label: 'UI', + category: 'UI', + requiresRestart: false, + default: {}, + description: 'User interface settings.', + showInDialog: false, + properties: { + theme: { + type: 'string', + label: 'Theme', + category: 'UI', + requiresRestart: false, + default: 'Qwen Dark' as string, + description: 'The color theme for the UI.', + showInDialog: true, + }, + statusLine: { + type: 'object', + label: 'Status Line', + category: 'UI', + requiresRestart: false, + default: undefined as { type: 'command'; command: string } | undefined, + description: 'Custom status line display configuration.', + showInDialog: false, + }, + customThemes: { + type: 'object', + label: 'Custom Themes', + category: 'UI', + requiresRestart: false, + default: {} as Record, + description: 'Custom theme definitions.', + showInDialog: false, + }, + hideWindowTitle: { + type: 'boolean', + label: 'Hide Window Title', + category: 'UI', + requiresRestart: true, + default: false, + description: 'Hide the window title bar', + showInDialog: false, + }, + showStatusInTitle: { + type: 'boolean', + label: 'Show Status in Title', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Show AIRIS Code status and thoughts in the terminal window title', + showInDialog: false, + }, + hideTips: { + type: 'boolean', + label: 'Hide Tips', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide helpful tips in the UI', + showInDialog: true, + }, + showLineNumbers: { + type: 'boolean', + label: 'Show Line Numbers in Code', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Show line numbers in the code output.', + showInDialog: true, + }, + showCitations: { + type: 'boolean', + label: 'Show Citations', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Show citations for generated text in the chat.', + showInDialog: false, + }, + customWittyPhrases: { + type: 'array', + label: 'Custom Witty Phrases', + category: 'UI', + requiresRestart: false, + default: [] as string[], + description: 'Custom witty phrases to display during loading.', + showInDialog: false, + }, + enableWelcomeBack: { + type: 'boolean', + label: 'Show Welcome Back Dialog', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Show welcome back dialog when returning to a project with conversation history.', + showInDialog: true, + }, + enableUserFeedback: { + type: 'boolean', + label: 'Enable User Feedback', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Show optional feedback dialog after conversations to help improve Qwen performance.', + showInDialog: true, + }, + enableFollowupSuggestions: { + type: 'boolean', + label: 'Enable Follow-up Suggestions', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Show context-aware follow-up suggestions after task completion. Press Tab or Right Arrow to accept, Enter to accept and submit.', + showInDialog: true, + }, + enableCacheSharing: { + type: 'boolean', + label: 'Enable Cache Sharing for Suggestions', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Use cache-aware forked queries for suggestion generation. Reduces cost on providers that support prefix caching (experimental).', + showInDialog: false, + }, + enableSpeculation: { + type: 'boolean', + label: 'Enable Speculative Execution', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Speculatively execute accepted suggestions before submission. Results appear instantly when you accept (experimental).', + showInDialog: false, + }, + accessibility: { + type: 'object', + label: 'Accessibility', + category: 'UI', + requiresRestart: true, + default: {}, + description: 'Accessibility settings.', + showInDialog: false, + properties: { + enableLoadingPhrases: { + type: 'boolean', + label: 'Enable Loading Phrases', + category: 'UI', + requiresRestart: true, + default: true, + description: 'Enable loading phrases (disable for accessibility)', + showInDialog: true, + }, + screenReader: { + type: 'boolean', + label: 'Screen Reader Mode', + category: 'UI', + requiresRestart: true, + default: undefined as boolean | undefined, + description: + 'Render output in plain-text to be more screen reader accessible', + showInDialog: false, + }, + }, + }, + feedbackLastShownTimestamp: { + type: 'number', + label: 'Feedback Last Shown Timestamp', + category: 'UI', + requiresRestart: false, + default: 0, + description: 'The last time the feedback dialog was shown.', + showInDialog: false, + }, + verboseMode: { + type: 'boolean', + label: 'Verbose Mode', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).', + showInDialog: false, + }, + }, + }, + + ide: { + type: 'object', + label: 'IDE', + category: 'IDE', + requiresRestart: true, + default: {}, + description: 'IDE integration settings.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Auto-connect to IDE', + category: 'IDE', + requiresRestart: true, + default: false, + description: 'Enable IDE integration mode', + showInDialog: true, + }, + hasSeenNudge: { + type: 'boolean', + label: 'Has Seen IDE Integration Nudge', + category: 'IDE', + requiresRestart: false, + default: false, + description: 'Whether the user has seen the IDE integration nudge.', + showInDialog: false, + }, + }, + }, + + privacy: { + type: 'object', + label: 'Privacy', + category: 'Privacy', + requiresRestart: true, + default: {}, + description: 'Privacy-related settings.', + showInDialog: false, + properties: { + usageStatisticsEnabled: { + type: 'boolean', + label: 'Enable Usage Statistics', + category: 'Privacy', + requiresRestart: true, + default: true, + description: 'Enable collection of usage statistics', + showInDialog: true, + }, + }, + }, + + telemetry: { + type: 'object', + label: 'Telemetry', + category: 'Advanced', + requiresRestart: true, + default: undefined as TelemetrySettings | undefined, + description: 'Telemetry configuration.', + showInDialog: false, + }, + + fastModel: { + type: 'string', + label: 'Fast Model', + category: 'Model', + requiresRestart: false, + default: '', + description: + 'Model for background tasks (suggestion generation, speculation). Leave empty to use the main model. A smaller/faster model (e.g., qwen3.5-flash) reduces latency and cost.', + showInDialog: true, + }, + + model: { + type: 'object', + label: 'Model', + category: 'Model', + requiresRestart: false, + default: {}, + description: 'Settings related to the generative model.', + showInDialog: false, + properties: { + name: { + type: 'string', + label: 'Model', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The model to use for conversations.', + showInDialog: false, + }, + maxSessionTurns: { + type: 'number', + label: 'Max Session Turns', + category: 'Model', + requiresRestart: false, + default: -1, + description: + 'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.', + showInDialog: false, + }, + chatCompression: { + type: 'object', + label: 'Chat Compression', + category: 'Model', + requiresRestart: false, + default: undefined as ChatCompressionSettings | undefined, + description: 'Chat compression settings.', + showInDialog: false, + }, + sessionTokenLimit: { + type: 'number', + label: 'Session Token Limit', + category: 'Model', + requiresRestart: false, + default: undefined as number | undefined, + description: 'The maximum number of tokens allowed in a session.', + showInDialog: false, + }, + skipNextSpeakerCheck: { + type: 'boolean', + label: 'Skip Next Speaker Check', + category: 'Model', + requiresRestart: false, + default: true, + description: 'Skip the next speaker check.', + showInDialog: false, + }, + skipLoopDetection: { + type: 'boolean', + label: 'Skip Loop Detection', + category: 'Model', + requiresRestart: false, + default: true, + description: 'Disable all loop detection checks (streaming and LLM).', + showInDialog: false, + }, + skipStartupContext: { + type: 'boolean', + label: 'Skip Startup Context', + category: 'Model', + requiresRestart: true, + default: false, + description: + 'Avoid sending the workspace startup context at the beginning of each session.', + showInDialog: false, + }, + enableOpenAILogging: { + type: 'boolean', + label: 'Enable OpenAI Logging', + category: 'Model', + requiresRestart: false, + default: false, + description: 'Enable OpenAI logging.', + showInDialog: false, + }, + openAILoggingDir: { + type: 'string', + label: 'OpenAI Logging Directory', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.', + showInDialog: false, + }, + generationConfig: { + type: 'object', + label: 'Generation Configuration', + category: 'Model', + requiresRestart: false, + default: undefined as Record | undefined, + description: 'Generation configuration settings.', + showInDialog: false, + properties: { + timeout: { + type: 'number', + label: 'Timeout', + category: 'Generation Configuration', + requiresRestart: false, + default: undefined as number | undefined, + description: 'Request timeout in milliseconds.', + parentKey: 'generationConfig', + showInDialog: false, + }, + maxRetries: { + type: 'number', + label: 'Max Retries', + category: 'Generation Configuration', + requiresRestart: false, + default: undefined as number | undefined, + description: 'Maximum number of retries for failed requests.', + parentKey: 'generationConfig', + showInDialog: false, + }, + enableCacheControl: { + type: 'boolean', + label: 'Enable Cache Control', + category: 'Generation Configuration', + requiresRestart: false, + default: true, + description: 'Enable cache control for DashScope providers.', + parentKey: 'generationConfig', + showInDialog: false, + }, + schemaCompliance: { + type: 'enum', + label: 'Tool Schema Compliance', + category: 'Generation Configuration', + requiresRestart: false, + default: 'auto', + description: + 'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).', + parentKey: 'generationConfig', + showInDialog: false, + options: [ + { value: 'auto', label: 'Auto (Default)' }, + { value: 'openapi_30', label: 'OpenAPI 3.0 Strict' }, + ], + }, + contextWindowSize: { + type: 'number', + label: 'Context Window Size', + category: 'Generation Configuration', + requiresRestart: false, + default: undefined, + description: + "Overrides the default context window size for the selected model. Use this setting when a provider's effective context limit differs from AIRIS Code's default. This value defines the model's assumed maximum context capacity, not a per-request token limit.", + parentKey: 'generationConfig', + showInDialog: false, + }, + }, + }, + }, + }, + + context: { + type: 'object', + label: 'Context', + category: 'Context', + requiresRestart: false, + default: {}, + description: 'Settings for managing context provided to the model.', + showInDialog: false, + properties: { + fileName: { + type: 'object', + label: 'Context File Name', + category: 'Context', + requiresRestart: false, + default: undefined as string | string[] | undefined, + description: 'The name of the context file.', + showInDialog: false, + }, + importFormat: { + type: 'string', + label: 'Memory Import Format', + category: 'Context', + requiresRestart: false, + default: undefined as MemoryImportFormat | undefined, + description: 'The format to use when importing memory.', + showInDialog: false, + }, + includeDirectories: { + type: 'array', + label: 'Include Directories', + category: 'Context', + requiresRestart: false, + default: [] as string[], + description: + 'Additional directories to include in the workspace context. Missing directories will be skipped with a warning.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + loadFromIncludeDirectories: { + type: 'boolean', + label: 'Load Memory From Include Directories', + category: 'Context', + requiresRestart: false, + default: false, + description: 'Whether to load memory files from include directories.', + showInDialog: false, + }, + fileFiltering: { + type: 'object', + label: 'File Filtering', + category: 'Context', + requiresRestart: true, + default: {}, + description: 'Settings for git-aware file filtering.', + showInDialog: false, + properties: { + respectGitIgnore: { + type: 'boolean', + label: 'Respect .gitignore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .gitignore files when searching', + showInDialog: true, + }, + respectAiriscodeIgnore: { + type: 'boolean', + label: 'Respect .airiscodeigenore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .airiscodeigenore files when searching', + showInDialog: true, + }, + enableRecursiveFileSearch: { + type: 'boolean', + label: 'Enable Recursive File Search', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Enable recursive file search functionality', + showInDialog: false, + }, + enableFuzzySearch: { + type: 'boolean', + label: 'Enable Fuzzy Search', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Enable fuzzy search when searching for files.', + showInDialog: true, + }, + }, + }, + gapThresholdMinutes: { + type: 'number', + label: 'Thinking Block Idle Threshold (minutes)', + category: 'Context', + requiresRestart: false, + default: 5, + description: + 'Minutes of inactivity after which retained thinking blocks are cleared to free context tokens. Aligns with provider prompt-cache TTL.', + showInDialog: false, + }, + }, + }, + + permissions: { + type: 'object', + label: 'Permissions', + category: 'Tools', + requiresRestart: true, + default: {}, + description: + 'Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.', + showInDialog: false, + properties: { + allow: { + type: 'array', + label: 'Allow Rules', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Tools or commands that are auto-approved without confirmation. ' + + 'Examples: "ShellTool", "Bash(git *)", "ReadFileTool".', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + ask: { + type: 'array', + label: 'Ask Rules', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Tools or commands that always require user confirmation. ' + + 'Takes precedence over allow rules.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + deny: { + type: 'array', + label: 'Deny Rules', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Tools or commands that are always blocked. Highest priority rule. ' + + 'Examples: "ShellTool", "Bash(rm -rf *)".', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + + tools: { + type: 'object', + label: 'Tools', + category: 'Tools', + requiresRestart: true, + default: {}, + description: 'Settings for built-in and custom tools.', + showInDialog: false, + properties: { + sandbox: { + type: 'object', + label: 'Sandbox', + category: 'Tools', + requiresRestart: true, + default: undefined as boolean | string | undefined, + description: + 'Sandbox execution environment (can be a boolean or a path string).', + showInDialog: false, + }, + shell: { + type: 'object', + label: 'Shell', + category: 'Tools', + requiresRestart: false, + default: {}, + description: 'Settings for shell execution.', + showInDialog: false, + properties: { + enableInteractiveShell: { + type: 'boolean', + label: 'Interactive Shell (PTY)', + category: 'Tools', + requiresRestart: true, + default: true, + description: + 'Use node-pty for an interactive shell experience. Falls back to child_process if PTY is unavailable.', + showInDialog: true, + }, + pager: { + type: 'string', + label: 'Pager', + category: 'Tools', + requiresRestart: false, + default: 'cat' as string | undefined, + description: + 'The pager command to use for shell output. Defaults to `cat`.', + showInDialog: false, + }, + showColor: { + type: 'boolean', + label: 'Show Color', + category: 'Tools', + requiresRestart: false, + default: false, + description: 'Show color in shell output.', + showInDialog: false, + }, + }, + }, + // Legacy tool permission fields – kept for backward compatibility. + // Use permissions.{allow,ask,deny} instead. + core: { + type: 'array', + label: 'Core Tools (deprecated)', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'Deprecated. Use permissions.allow instead.', + showInDialog: false, + }, + allowed: { + type: 'array', + label: 'Allowed Tools (deprecated)', + category: 'Advanced', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'Deprecated. Use permissions.allow instead.', + showInDialog: false, + }, + exclude: { + type: 'array', + label: 'Exclude Tools (deprecated)', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'Deprecated. Use permissions.deny instead.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + approvalMode: { + type: 'enum', + label: 'Tool Approval Mode', + category: 'Tools', + requiresRestart: false, + default: ApprovalMode.DEFAULT, + description: + 'Approval mode for tool usage. Controls how tools are approved before execution.', + showInDialog: true, + options: [ + { value: ApprovalMode.PLAN, label: 'Plan' }, + { value: ApprovalMode.DEFAULT, label: 'Default' }, + { value: ApprovalMode.AUTO_EDIT, label: 'Auto Edit' }, + { value: ApprovalMode.YOLO, label: 'YOLO' }, + ], + }, + autoAccept: { + type: 'boolean', + label: 'Auto Accept', + category: 'Tools', + requiresRestart: false, + default: false, + description: + 'Automatically accept and execute tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation.', + showInDialog: false, + }, + discoveryCommand: { + type: 'string', + label: 'Tool Discovery Command', + category: 'Tools', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to run for tool discovery.', + showInDialog: false, + }, + callCommand: { + type: 'string', + label: 'Tool Call Command', + category: 'Tools', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to run for tool calls.', + showInDialog: false, + }, + useRipgrep: { + type: 'boolean', + label: 'Use Ripgrep', + category: 'Tools', + requiresRestart: false, + default: true, + description: + 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', + showInDialog: false, + }, + useBuiltinRipgrep: { + type: 'boolean', + label: 'Use Builtin Ripgrep', + category: 'Tools', + requiresRestart: false, + default: true, + description: + 'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.', + showInDialog: false, + }, + truncateToolOutputThreshold: { + type: 'number', + label: 'Tool Output Truncation Threshold', + category: 'General', + requiresRestart: true, + default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + description: + 'Truncate tool output if it is larger than this many characters. Set to -1 to disable.', + showInDialog: false, + }, + truncateToolOutputLines: { + type: 'number', + label: 'Tool Output Truncation Lines', + category: 'General', + requiresRestart: true, + default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + description: 'The number of lines to keep when truncating tool output.', + showInDialog: false, + }, + }, + }, + + mcp: { + type: 'object', + label: 'MCP', + category: 'MCP', + requiresRestart: true, + default: {}, + description: 'Settings for Model Context Protocol (MCP) servers.', + showInDialog: false, + properties: { + serverCommand: { + type: 'string', + label: 'MCP Server Command', + category: 'MCP', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to start an MCP server.', + showInDialog: false, + }, + allowed: { + type: 'array', + label: 'Allow MCP Servers', + category: 'MCP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A list of MCP servers to allow.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + excluded: { + type: 'array', + label: 'Exclude MCP Servers', + category: 'MCP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A list of MCP servers to exclude.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + }, + security: { + type: 'object', + label: 'Security', + category: 'Security', + requiresRestart: true, + default: {}, + description: 'Security-related settings.', + showInDialog: false, + properties: { + folderTrust: { + type: 'object', + label: 'Folder Trust', + category: 'Security', + requiresRestart: false, + default: {}, + description: 'Settings for folder trust.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Folder Trust', + category: 'Security', + requiresRestart: true, + default: false, + description: 'Setting to track whether Folder trust is enabled.', + showInDialog: false, + }, + }, + }, + auth: { + type: 'object', + label: 'Authentication', + category: 'Security', + requiresRestart: true, + default: {}, + description: 'Authentication settings.', + showInDialog: false, + properties: { + selectedType: { + type: 'string', + label: 'Selected Auth Type', + category: 'Security', + requiresRestart: true, + default: undefined as AuthType | undefined, + description: 'The currently selected authentication type.', + showInDialog: false, + }, + enforcedType: { + type: 'string', + label: 'Enforced Auth Type', + category: 'Advanced', + requiresRestart: true, + default: undefined as AuthType | undefined, + description: + 'The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.', + showInDialog: false, + }, + useExternal: { + type: 'boolean', + label: 'Use External Auth', + category: 'Security', + requiresRestart: true, + default: undefined as boolean | undefined, + description: 'Whether to use an external authentication flow.', + showInDialog: false, + }, + apiKey: { + type: 'string', + label: 'API Key', + category: 'Security', + requiresRestart: true, + default: undefined as string | undefined, + description: 'API key for OpenAI compatible authentication.', + showInDialog: false, + }, + baseUrl: { + type: 'string', + label: 'Base URL', + category: 'Security', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Base URL for OpenAI compatible API.', + showInDialog: false, + }, + }, + }, + }, + }, + + advanced: { + type: 'object', + label: 'Advanced', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Advanced settings for power users.', + showInDialog: false, + properties: { + autoConfigureMemory: { + type: 'boolean', + label: 'Auto Configure Max Old Space Size', + category: 'Advanced', + requiresRestart: true, + default: false, + description: 'Automatically configure Node.js memory limits', + showInDialog: false, + }, + dnsResolutionOrder: { + type: 'string', + label: 'DNS Resolution Order', + category: 'Advanced', + requiresRestart: true, + default: undefined as DnsResolutionOrder | undefined, + description: 'The DNS resolution order.', + showInDialog: false, + }, + excludedEnvVars: { + type: 'array', + label: 'Excluded Project Environment Variables', + category: 'Advanced', + requiresRestart: false, + default: ['DEBUG', 'DEBUG_MODE'] as string[], + description: 'Environment variables to exclude from project context.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + bugCommand: { + type: 'object', + label: 'Bug Command', + category: 'Advanced', + requiresRestart: false, + default: undefined as BugCommandSettings | undefined, + description: 'Configuration for the bug report command.', + showInDialog: false, + }, + runtimeOutputDir: { + type: 'string', + label: 'Runtime Output Directory', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). ' + + 'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.', + showInDialog: false, + }, + tavilyApiKey: { + type: 'string', + label: 'Tavily API Key (Deprecated)', + category: 'Advanced', + requiresRestart: false, + default: undefined as string | undefined, + description: + '⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.', + showInDialog: false, + }, + }, + }, + + webSearch: { + type: 'object', + label: 'Web Search', + category: 'Advanced', + requiresRestart: true, + default: undefined as + | { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + } + | undefined, + description: 'Configuration for web search providers.', + showInDialog: false, + }, + agents: { + type: 'object', + label: 'Agents', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Settings for multi-agent collaboration features (Arena, Team, Swarm).', + showInDialog: false, + properties: { + displayMode: { + type: 'enum', + label: 'Display Mode', + category: 'Advanced', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'Display mode for multi-agent sessions. Currently only "in-process" is supported.', + showInDialog: false, + options: [ + { value: 'in-process', label: 'In-process' }, + // { value: 'tmux', label: 'tmux' }, + // { value: 'iterm2', label: 'iTerm2' }, + ], + }, + arena: { + type: 'object', + label: 'Arena', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Settings for Arena (multi-model competitive execution).', + showInDialog: false, + properties: { + worktreeBaseDir: { + type: 'string', + label: 'Worktree Base Directory', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Custom base directory for Arena worktrees. Defaults to ~/.airiscode/arena.', + showInDialog: false, + }, + preserveArtifacts: { + type: 'boolean', + label: 'Preserve Arena Artifacts', + category: 'Advanced', + requiresRestart: false, + default: false, + description: + 'When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.', + showInDialog: true, + }, + maxRoundsPerAgent: { + type: 'number', + label: 'Max Rounds Per Agent', + category: 'Advanced', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Maximum number of rounds (turns) each agent can execute. No limit if unset.', + showInDialog: false, + }, + timeoutSeconds: { + type: 'number', + label: 'Timeout (seconds)', + category: 'Advanced', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Total timeout in seconds for the Arena session. No limit if unset.', + showInDialog: false, + }, + }, + }, + team: { + type: 'object', + label: 'Team', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Settings for Agent Team (role-based collaborative execution). Reserved for future use.', + showInDialog: false, + }, + swarm: { + type: 'object', + label: 'Swarm', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Settings for Agent Swarm (parallel sub-agent execution). Reserved for future use.', + showInDialog: false, + }, + }, + }, + + disableAllHooks: { + type: 'boolean', + label: 'Disable All Hooks', + category: 'Advanced', + requiresRestart: true, // Future enhancement: consider supporting mid-session toggle for better UX + default: false, + description: + 'Temporarily disable all hooks without deleting configurations. Default is false (hooks enabled).', + showInDialog: false, + }, + + hooks: { + type: 'object', + label: 'Hooks', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Hook event configurations for extending CLI behavior at various lifecycle points.', + showInDialog: false, + properties: { + UserPromptSubmit: { + type: 'array', + label: 'Before Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before agent processing. Can modify prompts or inject context.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + Stop: { + type: 'array', + label: 'After Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after agent processing. Can post-process responses or log interactions.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + Notification: { + type: 'array', + label: 'Notification Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: 'Hooks that execute when notifications are sent.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + PreToolUse: { + type: 'array', + label: 'Pre Tool Use Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: 'Hooks that execute before tool execution.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + PostToolUse: { + type: 'array', + label: 'Post Tool Use Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: 'Hooks that execute after successful tool execution.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + PostToolUseFailure: { + type: 'array', + label: 'Post Tool Use Failure Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: 'Hooks that execute when tool execution fails. ', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + SessionStart: { + type: 'array', + label: 'Session Start Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: 'Hooks that execute when a new session starts or resumes.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + SessionEnd: { + type: 'array', + label: 'Session End Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: 'Hooks that execute when a session ends.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + PreCompact: { + type: 'array', + label: 'Pre Compact Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: 'Hooks that execute before conversation compaction.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + SubagentStart: { + type: 'array', + label: 'Subagent Start Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute when a subagent (Task tool call) is started.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + SubagentStop: { + type: 'array', + label: 'Subagent Stop Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute right before a subagent (Task tool call) concludes its response.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + PermissionRequest: { + type: 'array', + label: 'Permission Request Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute when a permission dialog is displayed.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, + }, + }, + }, + + experimental: { + type: 'object', + label: 'Experimental', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Settings to enable experimental features.', + showInDialog: false, + properties: { + cron: { + type: 'boolean', + label: 'Enable Cron/Loop Tools', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via AIRISCODE_ENABLE_CRON=1 environment variable.', + showInDialog: true, + }, + }, + }, +} as const satisfies SettingsSchema; + +export type SettingsSchemaType = typeof SETTINGS_SCHEMA; + +export function getSettingsSchema(): SettingsSchemaType { + // Inject dynamic language options + const schema = SETTINGS_SCHEMA as unknown as SettingsSchema; + if (schema['general']?.properties?.['language']) { + ( + schema['general'].properties['language'] as unknown as { + options?: SettingEnumOption[]; + } + ).options = getLanguageSettingsOptions(); + } + return SETTINGS_SCHEMA; +} + +type InferSettings = { + -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } + ? InferSettings + : T[K]['type'] extends 'enum' + ? T[K]['options'] extends readonly SettingEnumOption[] + ? T[K]['options'][number]['value'] + : T[K]['default'] + : T[K]['default'] extends boolean + ? boolean + : T[K]['default']; +}; + +export type Settings = InferSettings; diff --git a/apps/airiscode-cli/src/gemini-base/config/trustedFolders.ts b/apps/airiscode-cli/src/config/trustedFolders.ts similarity index 91% rename from apps/airiscode-cli/src/gemini-base/config/trustedFolders.ts rename to apps/airiscode-cli/src/config/trustedFolders.ts index 91b50963d..d03c87c43 100644 --- a/apps/airiscode-cli/src/gemini-base/config/trustedFolders.ts +++ b/apps/airiscode-cli/src/config/trustedFolders.ts @@ -12,22 +12,20 @@ import { getErrorMessage, isWithinRoot, ideContextStore, - GEMINI_DIR, -} from '@airiscode/gemini-cli-core'; +} from '@airiscode/core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; - -export function getUserSettingsDir(): string { - return path.join(homedir(), GEMINI_DIR); -} +export const SETTINGS_DIRECTORY_NAME = '.airiscode'; +export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); export function getTrustedFoldersPath(): string { - if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) { - return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; + if (process.env['AIRISCODE_TRUSTED_FOLDERS_PATH']) { + return process.env['AIRISCODE_TRUSTED_FOLDERS_PATH']; } - return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME); + return path.join(USER_SETTINGS_DIR, TRUSTED_FOLDERS_FILENAME); } export enum TrustLevel { @@ -187,7 +185,8 @@ export function saveTrustedFolders( { encoding: 'utf-8', mode: 0o600 }, ); } catch (error) { - console.error('Error saving trusted folders file:', error); + writeStderrLine('Error saving trusted folders file.'); + writeStderrLine(error instanceof Error ? error.message : String(error)); } } diff --git a/apps/airiscode-cli/src/config/webSearch.ts b/apps/airiscode-cli/src/config/webSearch.ts new file mode 100644 index 000000000..4d6eb4d95 --- /dev/null +++ b/apps/airiscode-cli/src/config/webSearch.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WebSearchProviderConfig } from '@airiscode/core'; +import type { Settings } from './settings.js'; + +/** + * CLI arguments related to web search configuration + */ +export interface WebSearchCliArgs { + tavilyApiKey?: string; + googleApiKey?: string; + googleSearchEngineId?: string; + webSearchDefault?: string; +} + +/** + * Web search configuration structure + */ +export interface WebSearchConfig { + provider: WebSearchProviderConfig[]; + default: string; +} + +/** + * Build webSearch configuration from multiple sources with priority: + * 1. settings.json (new format) - highest priority + * 2. Command line args + environment variables + * 3. Legacy tavilyApiKey (backward compatibility) + * + * @param argv - Command line arguments + * @param settings - User settings from settings.json + * @param _authType - Authentication type (kept for API compatibility) + * @returns WebSearch configuration or undefined if no providers available + */ +export function buildWebSearchConfig( + argv: WebSearchCliArgs, + settings: Settings, + _authType?: string, +): WebSearchConfig | undefined { + // Step 1: Collect providers from settings or command line/env + let providers: WebSearchProviderConfig[] = []; + let userDefault: string | undefined; + + if (settings.webSearch) { + // Use providers from settings.json + providers = [...settings.webSearch.provider] as WebSearchProviderConfig[]; + userDefault = settings.webSearch.default; + } else { + // Build providers from command line args and environment variables + const tavilyKey = + argv.tavilyApiKey || + settings.advanced?.tavilyApiKey || + process.env['TAVILY_API_KEY']; + if (tavilyKey) { + providers.push({ + type: 'tavily', + apiKey: tavilyKey, + } as WebSearchProviderConfig); + } + + const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; + const googleEngineId = + argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; + if (googleKey && googleEngineId) { + providers.push({ + type: 'google', + apiKey: googleKey, + searchEngineId: googleEngineId, + } as WebSearchProviderConfig); + } + } + + // Step 2: If no providers available, return undefined + if (providers.length === 0) { + return undefined; + } + + // Step 4: Determine default provider + // Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope) + const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [ + 'tavily', + 'google', + 'dashscope', + ]; + + // Determine default provider based on availability + let defaultProvider = userDefault || argv.webSearchDefault; + if (!defaultProvider) { + // Find first available provider by priority order + for (const providerType of providerPriority) { + if (providers.some((p) => p.type === providerType)) { + defaultProvider = providerType; + break; + } + } + // Fallback to first available provider if none found in priority list + if (!defaultProvider) { + defaultProvider = providers[0]?.type || 'dashscope'; + } + } + + return { + provider: providers, + default: defaultProvider, + }; +} diff --git a/apps/airiscode-cli/src/constants/alibabaStandardApiKey.ts b/apps/airiscode-cli/src/constants/alibabaStandardApiKey.ts new file mode 100644 index 000000000..cb1c6170c --- /dev/null +++ b/apps/airiscode-cli/src/constants/alibabaStandardApiKey.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type AlibabaStandardRegion = + | 'cn-beijing' + | 'sg-singapore' + | 'us-virginia' + | 'cn-hongkong'; + +export const DASHSCOPE_STANDARD_API_KEY_ENV_KEY = 'DASHSCOPE_API_KEY'; + +export const ALIBABA_STANDARD_API_KEY_ENDPOINTS: Record< + AlibabaStandardRegion, + string +> = { + 'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'sg-singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + 'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + 'cn-hongkong': + 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', +}; diff --git a/apps/airiscode-cli/src/constants/codingPlan.ts b/apps/airiscode-cli/src/constants/codingPlan.ts new file mode 100644 index 000000000..fb207a0b5 --- /dev/null +++ b/apps/airiscode-cli/src/constants/codingPlan.ts @@ -0,0 +1,345 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { ProviderModelConfig as ModelConfig } from '@airiscode/core'; + +/** + * Coding plan regions + */ +export enum CodingPlanRegion { + CHINA = 'china', + GLOBAL = 'global', +} + +/** + * Coding plan template - array of model configurations + * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key + */ +export type CodingPlanTemplate = ModelConfig[]; + +/** + * Environment variable key for storing the coding plan API key. + * Unified key for both regions since they are mutually exclusive. + */ +export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; + +/** + * Computes the version hash for the coding plan template. + * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @param template - The template to compute version for + * @returns Hexadecimal string representing the template version + */ +export function computeCodingPlanVersion(template: CodingPlanTemplate): string { + const templateString = JSON.stringify(template); + return createHash('sha256').update(templateString).digest('hex'); +} + +/** + * Generate the complete coding plan template for a specific region. + * China region uses legacy description to maintain backward compatibility. + * Global region uses new description with region indicator. + * @param region - The region to generate template for + * @returns Complete model configuration array for the region + */ +export function generateCodingPlanTemplate( + region: CodingPlanRegion, +): CodingPlanTemplate { + if (region === CodingPlanRegion.CHINA) { + // China region uses legacy fields to maintain backward compatibility + // This ensures existing users don't get prompted for unnecessary updates + return [ + { + id: 'qwen3.6-plus', + name: '[ModelStudio Coding Plan] qwen3.6-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3.5-plus', + name: '[ModelStudio Coding Plan] qwen3.5-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, + { + id: 'glm-5', + name: '[ModelStudio Coding Plan] glm-5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'kimi-k2.5', + name: '[ModelStudio Coding Plan] kimi-k2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[ModelStudio Coding Plan] MiniMax-M2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 196608, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[ModelStudio Coding Plan] qwen3-coder-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[ModelStudio Coding Plan] qwen3-coder-next', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[ModelStudio Coding Plan] qwen3-max-2026-01-23', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[ModelStudio Coding Plan] glm-4.7', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + ]; + } + + // Global region uses ModelStudio Coding Plan branding for Global/Intl + return [ + { + id: 'qwen3.6-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3.6-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3.5-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3.5-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-next', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-max-2026-01-23', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[ModelStudio Coding Plan for Global/Intl] glm-4.7', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'glm-5', + name: '[ModelStudio Coding Plan for Global/Intl] glm-5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[ModelStudio Coding Plan for Global/Intl] MiniMax-M2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 196608, + }, + }, + { + id: 'kimi-k2.5', + name: '[ModelStudio Coding Plan for Global/Intl] kimi-k2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + ]; +} + +/** + * Get the complete configuration for a specific region. + * @param region - The region to use + * @returns Object containing template, baseUrl, and version + */ +export function getCodingPlanConfig(region: CodingPlanRegion) { + const template = generateCodingPlanTemplate(region); + const baseUrl = + region === CodingPlanRegion.CHINA + ? 'https://coding.dashscope.aliyuncs.com/v1' + : 'https://coding-intl.dashscope.aliyuncs.com/v1'; + return { + template, + baseUrl, + version: computeCodingPlanVersion(template), + }; +} + +/** + * Get all unique base URLs for coding plan (used for filtering/config detection). + * @returns Array of base URLs + */ +export function getCodingPlanBaseUrls(): string[] { + return [ + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://coding-intl.dashscope.aliyuncs.com/v1', + ]; +} + +/** + * Check if a config belongs to Coding Plan (any region). + * Returns the region if matched, or false if not a Coding Plan config. + * @param baseUrl - The baseUrl to check + * @param envKey - The envKey to check + * @returns The region if matched, false otherwise + */ +export function isCodingPlanConfig( + baseUrl: string | undefined, + envKey: string | undefined, +): CodingPlanRegion | false { + if (!baseUrl || !envKey) { + return false; + } + + // Must use the unified envKey + if (envKey !== CODING_PLAN_ENV_KEY) { + return false; + } + + // Check which region's baseUrl matches + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return false; +} + +/** + * Get region from baseUrl. + * @param baseUrl - The baseUrl to check + * @returns The region if matched, null otherwise + */ +export function getRegionFromBaseUrl( + baseUrl: string | undefined, +): CodingPlanRegion | null { + if (!baseUrl) return null; + + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return null; +} diff --git a/apps/airiscode-cli/src/contexts/KeypressContext.tsx b/apps/airiscode-cli/src/contexts/KeypressContext.tsx deleted file mode 100644 index c57b91839..000000000 --- a/apps/airiscode-cli/src/contexts/KeypressContext.tsx +++ /dev/null @@ -1,631 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// Debug logger stub (removed gemini-cli-core dependency) -const debugLogger = { - log: (..._args: any[]) => { - // Optional: enable for debugging - // console.log('[KeypressContext]', ...args); - }, - debug: (..._args: any[]) => { - // Optional: enable for debugging - }, -}; - -// Config interface stub -export interface Config { - get(key: string): any; -} -import { useStdin } from 'ink'; -import type React from 'react'; -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, -} from 'react'; - -import { ESC } from '../utils/input.js'; -import { parseMouseEvent } from '../utils/mouse.js'; -import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; - -export const BACKSLASH_ENTER_TIMEOUT = 5; -export const ESC_TIMEOUT = 50; -export const PASTE_TIMEOUT = 50; - -// Parse the key itself -const KEY_INFO_MAP: Record< - string, - { name: string; shift?: boolean; ctrl?: boolean } -> = { - '[200~': { name: 'paste-start' }, - '[201~': { name: 'paste-end' }, - '[[A': { name: 'f1' }, - '[[B': { name: 'f2' }, - '[[C': { name: 'f3' }, - '[[D': { name: 'f4' }, - '[[E': { name: 'f5' }, - '[1~': { name: 'home' }, - '[2~': { name: 'insert' }, - '[3~': { name: 'delete' }, - '[4~': { name: 'end' }, - '[5~': { name: 'pageup' }, - '[6~': { name: 'pagedown' }, - '[7~': { name: 'home' }, - '[8~': { name: 'end' }, - '[11~': { name: 'f1' }, - '[12~': { name: 'f2' }, - '[13~': { name: 'f3' }, - '[14~': { name: 'f4' }, - '[15~': { name: 'f5' }, - '[17~': { name: 'f6' }, - '[18~': { name: 'f7' }, - '[19~': { name: 'f8' }, - '[20~': { name: 'f9' }, - '[21~': { name: 'f10' }, - '[23~': { name: 'f11' }, - '[24~': { name: 'f12' }, - '[A': { name: 'up' }, - '[B': { name: 'down' }, - '[C': { name: 'right' }, - '[D': { name: 'left' }, - '[E': { name: 'clear' }, - '[F': { name: 'end' }, - '[H': { name: 'home' }, - '[P': { name: 'f1' }, - '[Q': { name: 'f2' }, - '[R': { name: 'f3' }, - '[S': { name: 'f4' }, - OA: { name: 'up' }, - OB: { name: 'down' }, - OC: { name: 'right' }, - OD: { name: 'left' }, - OE: { name: 'clear' }, - OF: { name: 'end' }, - OH: { name: 'home' }, - OP: { name: 'f1' }, - OQ: { name: 'f2' }, - OR: { name: 'f3' }, - OS: { name: 'f4' }, - '[[5~': { name: 'pageup' }, - '[[6~': { name: 'pagedown' }, - '[9u': { name: 'tab' }, - '[13u': { name: 'return' }, - '[27u': { name: 'escape' }, - '[127u': { name: 'backspace' }, - '[57414u': { name: 'return' }, // Numpad Enter - '[a': { name: 'up', shift: true }, - '[b': { name: 'down', shift: true }, - '[c': { name: 'right', shift: true }, - '[d': { name: 'left', shift: true }, - '[e': { name: 'clear', shift: true }, - '[2$': { name: 'insert', shift: true }, - '[3$': { name: 'delete', shift: true }, - '[5$': { name: 'pageup', shift: true }, - '[6$': { name: 'pagedown', shift: true }, - '[7$': { name: 'home', shift: true }, - '[8$': { name: 'end', shift: true }, - '[Z': { name: 'tab', shift: true }, - Oa: { name: 'up', ctrl: true }, - Ob: { name: 'down', ctrl: true }, - Oc: { name: 'right', ctrl: true }, - Od: { name: 'left', ctrl: true }, - Oe: { name: 'clear', ctrl: true }, - '[2^': { name: 'insert', ctrl: true }, - '[3^': { name: 'delete', ctrl: true }, - '[5^': { name: 'pageup', ctrl: true }, - '[6^': { name: 'pagedown', ctrl: true }, - '[7^': { name: 'home', ctrl: true }, - '[8^': { name: 'end', ctrl: true }, -}; - -const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 -function charLengthAt(str: string, i: number): number { - if (str.length <= i) { - // Pretend to move to the right. This is necessary to autocomplete while - // moving to the right. - return 1; - } - const code = str.codePointAt(i); - return code !== undefined && code >= kUTF16SurrogateThreshold ? 2 : 1; -} - -const MAC_ALT_KEY_CHARACTER_MAP: Record = { - '\u222B': 'b', // "∫" back one word - '\u0192': 'f', // "ƒ" forward one word - '\u00B5': 'm', // "µ" toggle markup view -}; - -function nonKeyboardEventFilter( - keypressHandler: KeypressHandler, -): KeypressHandler { - return (key: Key) => { - if ( - !parseMouseEvent(key.sequence) && - key.sequence !== FOCUS_IN && - key.sequence !== FOCUS_OUT - ) { - keypressHandler(key); - } - }; -} - -/** - * Buffers "/" keys to see if they are followed return. - * Will flush the buffer if no data is received for DRAG_COMPLETION_TIMEOUT_MS - * or when a null key is received. - */ -function bufferBackslashEnter( - keypressHandler: KeypressHandler, -): (key: Key | null) => void { - const bufferer = (function* (): Generator { - while (true) { - const key = yield; - - if (key == null) { - continue; - } else if (key.sequence !== '\\') { - keypressHandler(key); - continue; - } - - const timeoutId = setTimeout( - () => bufferer.next(null), - BACKSLASH_ENTER_TIMEOUT, - ); - const nextKey = yield; - clearTimeout(timeoutId); - - if (nextKey === null) { - keypressHandler(key); - } else if (nextKey.name === 'return') { - keypressHandler({ - ...nextKey, - shift: true, - sequence: '\r', // Corrected escaping for newline - }); - } else { - keypressHandler(key); - keypressHandler(nextKey); - } - } - })(); - - bufferer.next(); // prime the generator so it starts listening. - - return (key: Key | null) => bufferer.next(key); -} - -/** - * Buffers paste events between paste-start and paste-end sequences. - * Will flush the buffer if no data is received for PASTE_TIMEOUT ms or - * when a null key is received. - */ -function bufferPaste( - keypressHandler: KeypressHandler, -): (key: Key | null) => void { - const bufferer = (function* (): Generator { - while (true) { - let key = yield; - - if (key === null) { - continue; - } else if (key.name !== 'paste-start') { - keypressHandler(key); - continue; - } - - let buffer = ''; - while (true) { - const timeoutId = setTimeout(() => bufferer.next(null), PASTE_TIMEOUT); - key = yield; - clearTimeout(timeoutId); - - if (key === null || key.name === 'paste-end') { - break; - } - buffer += key.sequence; - } - - if (buffer.length > 0) { - keypressHandler({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - insertable: true, - sequence: buffer, - }); - } - } - })(); - bufferer.next(); // prime the generator so it starts listening. - - return (key: Key | null) => bufferer.next(key); -} - -/** - * Turns raw data strings into keypress events sent to the provided handler. - * Buffers escape sequences until a full sequence is received or - * until a timeout occurs. - */ -function createDataListener(keypressHandler: KeypressHandler) { - const parser = emitKeys(keypressHandler); - parser.next(); // prime the generator so it starts listening. - - let timeoutId: NodeJS.Timeout; - return (data: string) => { - clearTimeout(timeoutId); - for (const char of data) { - parser.next(char); - } - if (data.length !== 0) { - timeoutId = setTimeout(() => parser.next(''), ESC_TIMEOUT); - } - }; -} - -/** - * Translates raw keypress characters into key events. - * Buffers escape sequences until a full sequence is received or - * until an empty string is sent to indicate a timeout. - */ -function* emitKeys( - keypressHandler: KeypressHandler, -): Generator { - while (true) { - let ch = yield; - let sequence = ch; - let escaped = false; - - let name = undefined; - let ctrl = false; - let meta = false; - let shift = false; - let code = undefined; - let insertable = false; - - if (ch === ESC) { - escaped = true; - ch = yield; - sequence += ch; - - if (ch === ESC) { - ch = yield; - sequence += ch; - } - } - - if (escaped && (ch === 'O' || ch === '[')) { - // ANSI escape sequence - code = ch; - let modifier = 0; - - if (ch === 'O') { - // ESC O letter - // ESC O modifier letter - ch = yield; - sequence += ch; - - if (ch >= '0' && ch <= '9') { - modifier = parseInt(ch, 10) - 1; - ch = yield; - sequence += ch; - } - - code += ch; - } else if (ch === '[') { - // ESC [ letter - // ESC [ modifier letter - // ESC [ [ modifier letter - // ESC [ [ num char - ch = yield; - sequence += ch; - - if (ch === '[') { - // \x1b[[A - // ^--- escape codes might have a second bracket - code += ch; - ch = yield; - sequence += ch; - } - - /* - * Here and later we try to buffer just enough data to get - * a complete ascii sequence. - * - * We have basically two classes of ascii characters to process: - * - * - * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } - * - * This particular example is featuring Ctrl+F12 in xterm. - * - * - `;5` part is optional, e.g. it could be `\x1b[24~` - * - first part can contain one or two digits - * - there is also special case when there can be 3 digits - * but without modifier. They are the case of paste bracket mode - * - * So the generic regexp is like /^(?:\d\d?(;\d)?[~^$]|\d{3}~)$/ - * - * - * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } - * - * This particular example is featuring Ctrl+Home in xterm. - * - * - `1;5` part is optional, e.g. it could be `\x1b[H` - * - `1;` part is optional, e.g. it could be `\x1b[5H` - * - * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ - * - */ - const cmdStart = sequence.length - 1; - - // collect as many digits as possible - while (ch >= '0' && ch <= '9') { - ch = yield; - sequence += ch; - } - - // skip modifier - if (ch === ';') { - ch = yield; - sequence += ch; - - // collect as many digits as possible - while (ch >= '0' && ch <= '9') { - ch = yield; - sequence += ch; - } - } else if (ch === '<') { - // SGR mouse mode - ch = yield; - sequence += ch; - // Don't skip on empty string here to avoid timeouts on slow events. - while (ch === '' || ch === ';' || (ch >= '0' && ch <= '9')) { - ch = yield; - sequence += ch; - } - } else if (ch === 'M') { - // X11 mouse mode - // three characters after 'M' - ch = yield; - sequence += ch; - ch = yield; - sequence += ch; - ch = yield; - sequence += ch; - } - - /* - * We buffered enough data, now trying to extract code - * and modifier from it - */ - const cmd = sequence.slice(cmdStart); - let match; - - if ((match = /^(\d+)(?:;(\d+))?([~^$u])$/.exec(cmd))) { - code += match[1] + match[3]; - // Defaults to '1' if no modifier exists, resulting in a 0 modifier value - modifier = parseInt(match[2] ?? '1', 10) - 1; - } else if ((match = /^((\d;)?(\d))?([A-Za-z])$/.exec(cmd))) { - code += match[4]; - modifier = parseInt(match[3] ?? '1', 10) - 1; - } else { - code += cmd; - } - } - - // Parse the key modifier - ctrl = !!(modifier & 4); - meta = !!(modifier & 10); // use 10 to catch both alt (2) and meta (8). - shift = !!(modifier & 1); - - const keyInfo = KEY_INFO_MAP[code]; - if (keyInfo) { - name = keyInfo.name; - if (keyInfo.shift) { - shift = true; - } - if (keyInfo.ctrl) { - ctrl = true; - } - } else { - name = 'undefined'; - if ((ctrl || meta) && (code.endsWith('u') || code.endsWith('~'))) { - // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) - const codeNumber = parseInt(code.slice(1, -1), 10); - if ( - codeNumber >= 'a'.charCodeAt(0) && - codeNumber <= 'z'.charCodeAt(0) - ) { - name = String.fromCharCode(codeNumber); - } - } - } - } else if (ch === '\r') { - // carriage return - name = 'return'; - meta = escaped; - } else if (ch === '\n') { - // Enter, should have been called linefeed - name = 'enter'; - meta = escaped; - } else if (ch === '\t') { - // tab - name = 'tab'; - meta = escaped; - } else if (ch === '\b' || ch === '\x7f') { - // backspace or ctrl+h - name = 'backspace'; - meta = escaped; - } else if (ch === ESC) { - // escape key - name = 'escape'; - meta = escaped; - } else if (ch === ' ') { - name = 'space'; - meta = escaped; - insertable = true; - } else if (!escaped && ch <= '\x1a') { - // ctrl+letter - name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1); - ctrl = true; - } else if (/^[0-9A-Za-z]$/.exec(ch) !== null) { - // Letter, number, shift+letter - name = ch.toLowerCase(); - shift = /^[A-Z]$/.exec(ch) !== null; - meta = escaped; - insertable = true; - } else if (MAC_ALT_KEY_CHARACTER_MAP[ch] && process.platform === 'darwin') { - name = MAC_ALT_KEY_CHARACTER_MAP[ch]; - meta = true; - } else if (sequence === `${ESC}${ESC}`) { - // Double escape - name = 'escape'; - meta = true; - - // Emit first escape key here, then continue processing - keypressHandler({ - name: 'escape', - ctrl, - meta, - shift, - paste: false, - insertable: false, - sequence: ESC, - }); - } else if (escaped) { - // Escape sequence timeout - name = ch.length ? undefined : 'escape'; - meta = true; - } else { - // Any other character is considered printable. - insertable = true; - } - - if ( - (sequence.length !== 0 && (name !== undefined || escaped)) || - charLengthAt(sequence, 0) === sequence.length - ) { - keypressHandler({ - name: name || '', - ctrl, - meta, - shift, - paste: false, - insertable, - sequence, - }); - } - // Unrecognized or broken escape sequence, don't emit anything - } -} - -export interface Key { - name: string; - ctrl: boolean; - meta: boolean; - shift: boolean; - paste: boolean; - insertable: boolean; - sequence: string; -} - -export type KeypressHandler = (key: Key) => void; - -interface KeypressContextValue { - subscribe: (handler: KeypressHandler) => void; - unsubscribe: (handler: KeypressHandler) => void; -} - -const KeypressContext = createContext( - undefined, -); - -export function useKeypressContext() { - const context = useContext(KeypressContext); - if (!context) { - throw new Error( - 'useKeypressContext must be used within a KeypressProvider', - ); - } - return context; -} - -export function KeypressProvider({ - children, - config, - debugKeystrokeLogging, -}: { - children: React.ReactNode; - config?: Config; - debugKeystrokeLogging?: boolean; -}) { - const { stdin, setRawMode } = useStdin(); - - const subscribers = useRef>(new Set()).current; - const subscribe = useCallback( - (handler: KeypressHandler) => subscribers.add(handler), - [subscribers], - ); - const unsubscribe = useCallback( - (handler: KeypressHandler) => subscribers.delete(handler), - [subscribers], - ); - const broadcast = useCallback( - (key: Key) => subscribers.forEach((handler) => handler(key)), - [subscribers], - ); - - useEffect(() => { - const wasRaw = stdin.isRaw; - if (wasRaw === false) { - setRawMode(true); - } - - process.stdin.setEncoding('utf8'); // Make data events emit strings - - const mouseFilterer = nonKeyboardEventFilter(broadcast); - const backslashBufferer = bufferBackslashEnter(mouseFilterer); - const pasteBufferer = bufferPaste(backslashBufferer); - let dataListener = createDataListener(pasteBufferer); - - if (debugKeystrokeLogging) { - const old = dataListener; - dataListener = (data: string) => { - if (data.length > 0) { - debugLogger.log(`[DEBUG] Raw StdIn: ${JSON.stringify(data)}`); - } - old(data); - }; - } - - stdin.on('data', dataListener); - - return () => { - // flush buffers by sending null key - backslashBufferer(null); - pasteBufferer(null); - // flush by sending empty string to the data listener - dataListener(''); - stdin.removeListener('data', dataListener); - - // Restore the terminal to its original state. - if (wasRaw === false) { - setRawMode(false); - } - }; - }, [stdin, setRawMode, config, debugKeystrokeLogging, broadcast]); - - return ( - - {children} - - ); -} diff --git a/apps/airiscode-cli/src/contexts/MCPContext.tsx b/apps/airiscode-cli/src/contexts/MCPContext.tsx deleted file mode 100644 index 78afb1f2a..000000000 --- a/apps/airiscode-cli/src/contexts/MCPContext.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from "react"; -import { MCPGatewayClient } from "@airiscode/mcp-gateway-client"; -import type { ToolDescription, ServerInfo } from "@airiscode/mcp-gateway-client"; - -export interface MCPContextType { - client: MCPGatewayClient | null; - connected: boolean; - tools: ToolDescription[]; - servers: ServerInfo[]; - connect: (baseURL: string, apiKey?: string) => Promise; - disconnect: () => void; - invokeTool: (serverName: string, toolName: string, args: Record) => Promise; - refreshTools: () => Promise; - error: string | null; -} - -const MCPContext = createContext(undefined); - -export interface MCPProviderProps { - autoConnect?: boolean; - defaultBaseURL?: string; - children: React.ReactNode; -} - -export const MCPProvider: React.FC = ({ - autoConnect = false, - defaultBaseURL = "http://localhost:3000", - children, -}) => { - const [client, setClient] = useState(null); - const [connected, setConnected] = useState(false); - const [tools, setTools] = useState([]); - const [servers, setServers] = useState([]); - const [error, setError] = useState(null); - - const connect = useCallback(async (baseURL: string, apiKey?: string) => { - try { - setError(null); - const newClient = new MCPGatewayClient({ baseURL, apiKey }); - - // Test connection - const status = await newClient.getStatus(); - - setClient(newClient); - setConnected(true); - setServers(status.servers); - - // Get always-on tools - const alwaysOnTools = await newClient.getAlwaysOnTools(); - setTools(alwaysOnTools); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setConnected(false); - throw err; - } - }, []); - - const disconnect = useCallback(() => { - setClient(null); - setConnected(false); - setTools([]); - setServers([]); - setError(null); - }, []); - - const invokeTool = useCallback( - async (serverName: string, toolName: string, args: Record) => { - if (!client) { - throw new Error("MCP Gateway not connected"); - } - - try { - setError(null); - return await client.invokeTool(serverName, toolName, args); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - setError(errorMsg); - throw err; - } - }, - [client] - ); - - const refreshTools = useCallback(async () => { - if (!client) { - throw new Error("MCP Gateway not connected"); - } - - try { - setError(null); - const status = await client.getStatus(); - setServers(status.servers); - - const alwaysOnTools = await client.getAlwaysOnTools(); - setTools(alwaysOnTools); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - throw err; - } - }, [client]); - - // Auto-connect on mount if enabled - useEffect(() => { - if (autoConnect) { - connect(defaultBaseURL).catch(() => { - // Silently fail - MCP Gateway is optional - }); - } - }, [autoConnect, defaultBaseURL, connect]); - - const value = useMemo( - () => ({ - client, - connected, - tools, - servers, - connect, - disconnect, - invokeTool, - refreshTools, - error, - }), - [client, connected, tools, servers, connect, disconnect, invokeTool, refreshTools, error] - ); - - return {children}; -}; - -export const useMCP = (): MCPContextType => { - const context = useContext(MCPContext); - if (!context) { - throw new Error("useMCP must be used within an MCPProvider"); - } - return context; -}; diff --git a/apps/airiscode-cli/src/contexts/SessionContext.tsx b/apps/airiscode-cli/src/contexts/SessionContext.tsx deleted file mode 100644 index 08fe27577..000000000 --- a/apps/airiscode-cli/src/contexts/SessionContext.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import React, { createContext, useContext, useState, useCallback, useMemo } from "react"; -import type { Message } from "../sessionStorage.js"; - -export interface SessionStats { - messageCount: number; - totalTokens?: number; - startTime: number; -} - -export interface SessionContextType { - sessionId: string; - messages: Message[]; - stats: SessionStats; - addMessage: (message: Message) => void; - clearMessages: () => void; - updateStats: (stats: Partial) => void; -} - -const SessionContext = createContext(undefined); - -export interface SessionProviderProps { - sessionId: string; - initialMessages?: Message[]; - children: React.ReactNode; -} - -export const SessionProvider: React.FC = ({ - sessionId, - initialMessages = [], - children, -}) => { - const [messages, setMessages] = useState(initialMessages); - const [stats, setStats] = useState({ - messageCount: initialMessages.length, - startTime: Date.now(), - }); - - const addMessage = useCallback((message: Message) => { - setMessages((prev) => [...prev, message]); - setStats((prev) => ({ ...prev, messageCount: prev.messageCount + 1 })); - }, []); - - const clearMessages = useCallback(() => { - setMessages([]); - setStats({ messageCount: 0, startTime: Date.now() }); - }, []); - - const updateStats = useCallback((newStats: Partial) => { - setStats((prev) => ({ ...prev, ...newStats })); - }, []); - - const value = useMemo( - () => ({ - sessionId, - messages, - stats, - addMessage, - clearMessages, - updateStats, - }), - [sessionId, messages, stats, addMessage, clearMessages, updateStats] - ); - - return {children}; -}; - -export const useSession = (): SessionContextType => { - const context = useContext(SessionContext); - if (!context) { - throw new Error("useSession must be used within a SessionProvider"); - } - return context; -}; diff --git a/apps/airiscode-cli/src/contexts/UIStateContext.tsx b/apps/airiscode-cli/src/contexts/UIStateContext.tsx deleted file mode 100644 index f37f984ba..000000000 --- a/apps/airiscode-cli/src/contexts/UIStateContext.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import React, { createContext, useContext, useState, useCallback, useMemo } from "react"; - -export interface StreamingState { - isStreaming: boolean; - currentContent: string; -} - -export interface UIState { - isLoading: boolean; - error: string | null; - streaming: StreamingState; - inputDisabled: boolean; -} - -export interface UIStateContextType { - state: UIState; - setLoading: (loading: boolean) => void; - setError: (error: string | null) => void; - setStreaming: (streaming: Partial) => void; - setInputDisabled: (disabled: boolean) => void; - resetState: () => void; -} - -const initialUIState: UIState = { - isLoading: false, - error: null, - streaming: { - isStreaming: false, - currentContent: "", - }, - inputDisabled: false, -}; - -const UIStateContext = createContext(undefined); - -export interface UIStateProviderProps { - children: React.ReactNode; -} - -export const UIStateProvider: React.FC = ({ children }) => { - const [state, setState] = useState(initialUIState); - - const setLoading = useCallback((loading: boolean) => { - setState((prev) => ({ ...prev, isLoading: loading })); - }, []); - - const setError = useCallback((error: string | null) => { - setState((prev) => ({ ...prev, error })); - }, []); - - const setStreaming = useCallback((streaming: Partial) => { - setState((prev) => ({ - ...prev, - streaming: { ...prev.streaming, ...streaming }, - })); - }, []); - - const setInputDisabled = useCallback((disabled: boolean) => { - setState((prev) => ({ ...prev, inputDisabled: disabled })); - }, []); - - const resetState = useCallback(() => { - setState(initialUIState); - }, []); - - const value = useMemo( - () => ({ - state, - setLoading, - setError, - setStreaming, - setInputDisabled, - resetState, - }), - [state, setLoading, setError, setStreaming, setInputDisabled, resetState] - ); - - return {children}; -}; - -export const useUIState = (): UIStateContextType => { - const context = useContext(UIStateContext); - if (!context) { - throw new Error("useUIState must be used within a UIStateProvider"); - } - return context; -}; diff --git a/apps/airiscode-cli/src/core/auth.ts b/apps/airiscode-cli/src/core/auth.ts new file mode 100644 index 000000000..9c1a8bd94 --- /dev/null +++ b/apps/airiscode-cli/src/core/auth.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type AuthType, + type Config, + getErrorMessage, + logAuth, + AuthEvent, +} from '@airiscode/core'; + +/** + * Handles the initial authentication flow. + * @param config The application config. + * @param authType The selected auth type. + * @returns An error message if authentication fails, otherwise null. + */ +export async function performInitialAuth( + config: Config, + authType: AuthType | undefined, +): Promise { + if (!authType) { + return null; + } + + try { + await config.refreshAuth(authType, true); + // The console.log is intentionally left out here. + // We can add a dedicated startup message later if needed. + + // Log authentication success + const authEvent = new AuthEvent(authType, 'auto', 'success'); + logAuth(config, authEvent); + } catch (e) { + const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`; + + // Log authentication failure + const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage); + logAuth(config, authEvent); + + return errorMessage; + } + + return null; +} diff --git a/apps/airiscode-cli/src/core/initializer.ts b/apps/airiscode-cli/src/core/initializer.ts new file mode 100644 index 000000000..bc08e61d7 --- /dev/null +++ b/apps/airiscode-cli/src/core/initializer.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IdeClient, + IdeConnectionEvent, + IdeConnectionType, + logIdeConnection, + type Config, +} from '@airiscode/core'; +import { type LoadedSettings } from '../config/settings.js'; +import { performInitialAuth } from './auth.js'; +import { validateTheme } from './theme.js'; +import { initializeI18n, type SupportedLanguage } from '../i18n/index.js'; + +export interface InitializationResult { + authError: string | null; + themeError: string | null; + shouldOpenAuthDialog: boolean; + geminiMdFileCount: number; +} + +/** + * Orchestrates the application's startup initialization. + * This runs BEFORE the React UI is rendered. + * @param config The application config. + * @param settings The loaded application settings. + * @returns The results of the initialization. + */ +export async function initializeApp( + config: Config, + settings: LoadedSettings, +): Promise { + // Initialize i18n system + const languageSetting = + process.env['AIRISCODE_LANG'] || + (settings.merged.general?.language as string) || + 'auto'; + await initializeI18n(languageSetting as SupportedLanguage | 'auto'); + + // Use authType from modelsConfig which respects CLI --auth-type argument + // over settings.security.auth.selectedType + const authType = config.getModelsConfig().getCurrentAuthType(); + const authError = await performInitialAuth(config, authType); + + const themeError = validateTheme(settings); + + const shouldOpenAuthDialog = + !config.getModelsConfig().wasAuthTypeExplicitlyProvided() || !!authError; + + if (config.getIdeMode()) { + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); + } + + return { + authError, + themeError, + shouldOpenAuthDialog, + geminiMdFileCount: config.getGeminiMdFileCount(), + }; +} diff --git a/apps/airiscode-cli/src/core/theme.ts b/apps/airiscode-cli/src/core/theme.ts new file mode 100644 index 000000000..7acb4abd2 --- /dev/null +++ b/apps/airiscode-cli/src/core/theme.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { themeManager } from '../ui/themes/theme-manager.js'; +import { type LoadedSettings } from '../config/settings.js'; +import { t } from '../i18n/index.js'; + +/** + * Validates the configured theme. + * @param settings The loaded application settings. + * @returns An error message if the theme is not found, otherwise null. + */ +export function validateTheme(settings: LoadedSettings): string | null { + const effectiveTheme = settings.merged.ui?.theme; + if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { + return t('Theme "{{themeName}}" not found.', { + themeName: effectiveTheme, + }); + } + return null; +} diff --git a/apps/airiscode-cli/src/events/emitter.ts.skip b/apps/airiscode-cli/src/events/emitter.ts.skip deleted file mode 100644 index b947890c6..000000000 --- a/apps/airiscode-cli/src/events/emitter.ts.skip +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Event Emitter - Unified output for TUI and JSON Lines - * - * This module provides a single interface for emitting structured events - * that can be consumed by either: - * 1. TUI components (Ink-based UI updates) - * 2. JSON Lines output (--json mode for CI/CD) - */ - -import { Event, EventKind } from '@airiscode/api/gen/ts/airiscode/v1/events'; - -export type OutputFormat = 'tui' | 'json'; - -export interface EventHandler { - onDiffReady?: (data: unknown) => void; - onTestResult?: (data: unknown) => void; - onGuardBlock?: (summary: string) => void; - onError?: (summary: string) => void; - onInfo?: (summary: string) => void; -} - -export class EventEmitter { - private format: OutputFormat; - private handlers: EventHandler; - - constructor(format: OutputFormat, handlers: EventHandler = {}) { - this.format = format; - this.handlers = handlers; - } - - /** - * Emit a structured event - */ - emit(event: Event): void { - if (this.format === 'json') { - this.emitJson(event); - } else { - this.emitTui(event); - } - } - - /** - * JSON Lines output (one event per line) - */ - private emitJson(event: Event): void { - const jsonEvent = { - kind: EventKind[event.kind], - ts: this.formatTimestamp(event.ts), - session_id: event.session_id?.value, - actor: event.actor, - summary: event.summary, - data: event.data ? JSON.parse(Buffer.from(event.data).toString()) : null, - }; - process.stdout.write(JSON.stringify(jsonEvent) + '\n'); - } - - /** - * TUI output (route to UI handlers) - */ - private emitTui(event: Event): void { - switch (event.kind) { - case EventKind.EVENT_DIFF_READY: - this.handlers.onDiffReady?.(this.parseData(event.data)); - break; - - case EventKind.EVENT_TEST_RESULT: - this.handlers.onTestResult?.(this.parseData(event.data)); - break; - - case EventKind.EVENT_GUARD_BLOCK: - this.handlers.onGuardBlock?.(event.summary); - break; - - case EventKind.EVENT_ERROR: - this.handlers.onError?.(event.summary); - break; - - default: - this.handlers.onInfo?.(event.summary); - } - } - - /** - * Parse event data payload - */ - private parseData(data?: Uint8Array): unknown { - if (!data) return null; - try { - return JSON.parse(Buffer.from(data).toString()); - } catch { - return null; - } - } - - /** - * Format timestamp for JSON output - */ - private formatTimestamp(ts?: { seconds: bigint; nanos: number }): string { - if (!ts) return new Date().toISOString(); - const seconds = Number(ts.seconds); - const nanos = ts.nanos || 0; - const ms = Math.floor(nanos / 1_000_000); - return new Date(seconds * 1000 + ms).toISOString(); - } - - /** - * Helper: Create event with current timestamp - */ - static createEvent( - kind: EventKind, - sessionId: string, - actor: string, - summary: string, - data?: unknown - ): Event { - const now = Date.now(); - const seconds = Math.floor(now / 1000); - const nanos = (now % 1000) * 1_000_000; - - return { - kind, - ts: { seconds: BigInt(seconds), nanos }, - session_id: { value: sessionId }, - actor, - summary, - data: data ? Buffer.from(JSON.stringify(data)) : undefined, - }; - } -} diff --git a/apps/airiscode-cli/src/gemini-base/__snapshots__/nonInteractiveCli.test.ts.snap b/apps/airiscode-cli/src/gemini-base/__snapshots__/nonInteractiveCli.test.ts.snap deleted file mode 100644 index 5d41472b8..000000000 --- a/apps/airiscode-cli/src/gemini-base/__snapshots__/nonInteractiveCli.test.ts.snap +++ /dev/null @@ -1,8 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = ` -"Use mock tool -Use mock tool again -Finished. -" -`; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/disable.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/disable.ts deleted file mode 100644 index 757c1aa64..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/disable.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type CommandModule } from 'yargs'; -import { loadSettings, SettingScope } from '../../config/settings.js'; -import { getErrorMessage } from '../../utils/errors.js'; -import { debugLogger } from '@airiscode/gemini-cli-core'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -interface DisableArgs { - name: string; - scope?: string; -} - -export async function handleDisable(args: DisableArgs) { - const workspaceDir = process.cwd(); - const extensionManager = new ExtensionManager({ - workspaceDir, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - settings: loadSettings(workspaceDir).merged, - }); - await extensionManager.loadExtensions(); - - try { - if (args.scope?.toLowerCase() === 'workspace') { - await extensionManager.disableExtension( - args.name, - SettingScope.Workspace, - ); - } else { - await extensionManager.disableExtension(args.name, SettingScope.User); - } - debugLogger.log( - `Extension "${args.name}" successfully disabled for scope "${args.scope}".`, - ); - } catch (error) { - debugLogger.error(getErrorMessage(error)); - process.exit(1); - } -} - -export const disableCommand: CommandModule = { - command: 'disable [--scope] ', - describe: 'Disables an extension.', - builder: (yargs) => - yargs - .positional('name', { - describe: 'The name of the extension to disable.', - type: 'string', - }) - .option('scope', { - describe: 'The scope to disable the extension in.', - type: 'string', - default: SettingScope.User, - }) - .check((argv) => { - if ( - argv.scope && - !Object.values(SettingScope) - .map((s) => s.toLowerCase()) - .includes((argv.scope as string).toLowerCase()) - ) { - throw new Error( - `Invalid scope: ${argv.scope}. Please use one of ${Object.values( - SettingScope, - ) - .map((s) => s.toLowerCase()) - .join(', ')}.`, - ); - } - return true; - }), - handler: async (argv) => { - await handleDisable({ - name: argv['name'] as string, - scope: argv['scope'] as string, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/enable.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/enable.ts deleted file mode 100644 index e90322936..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/enable.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type CommandModule } from 'yargs'; -import { loadSettings, SettingScope } from '../../config/settings.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { - debugLogger, - FatalConfigError, - getErrorMessage, -} from '@airiscode/gemini-cli-core'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -interface EnableArgs { - name: string; - scope?: string; -} - -export async function handleEnable(args: EnableArgs) { - const workingDir = process.cwd(); - const extensionManager = new ExtensionManager({ - workspaceDir: workingDir, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - settings: loadSettings(workingDir).merged, - }); - await extensionManager.loadExtensions(); - - try { - if (args.scope?.toLowerCase() === 'workspace') { - await extensionManager.enableExtension(args.name, SettingScope.Workspace); - } else { - await extensionManager.enableExtension(args.name, SettingScope.User); - } - if (args.scope) { - debugLogger.log( - `Extension "${args.name}" successfully enabled for scope "${args.scope}".`, - ); - } else { - debugLogger.log( - `Extension "${args.name}" successfully enabled in all scopes.`, - ); - } - } catch (error) { - throw new FatalConfigError(getErrorMessage(error)); - } -} - -export const enableCommand: CommandModule = { - command: 'enable [--scope] ', - describe: 'Enables an extension.', - builder: (yargs) => - yargs - .positional('name', { - describe: 'The name of the extension to enable.', - type: 'string', - }) - .option('scope', { - describe: - 'The scope to enable the extension in. If not set, will be enabled in all scopes.', - type: 'string', - }) - .check((argv) => { - if ( - argv.scope && - !Object.values(SettingScope) - .map((s) => s.toLowerCase()) - .includes((argv.scope as string).toLowerCase()) - ) { - throw new Error( - `Invalid scope: ${argv.scope}. Please use one of ${Object.values( - SettingScope, - ) - .map((s) => s.toLowerCase()) - .join(', ')}.`, - ); - } - return true; - }), - handler: async (argv) => { - await handleEnable({ - name: argv['name'] as string, - scope: argv['scope'] as string, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/context/GEMINI.md b/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/context/GEMINI.md deleted file mode 100644 index 0e8179625..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/context/GEMINI.md +++ /dev/null @@ -1,14 +0,0 @@ -# Ink Library Screen Reader Guidance - -When building custom components, it's important to keep accessibility in mind. -While Ink provides the building blocks, ensuring your components are accessible -will make your CLIs usable by a wider audience. - -## General Principles - -Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to -detect if a screen reader is active. You can then render a more descriptive -output for screen reader users. Leverage ARIA props: For components that have a -specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and -aria-label props on and to provide semantic meaning to screen -readers. diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml b/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml deleted file mode 100644 index 87d957542..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml +++ /dev/null @@ -1,6 +0,0 @@ -prompt = """ -Please summarize the findings for the pattern `{{args}}`. - -Search Results: -!{grep -r {{args}} .} -""" diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/custom-commands/gemini-extension.json b/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/custom-commands/gemini-extension.json deleted file mode 100644 index d973ab8fe..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/custom-commands/gemini-extension.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "custom-commands", - "version": "1.0.0" -} diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/exclude-tools/gemini-extension.json b/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/exclude-tools/gemini-extension.json deleted file mode 100644 index 5023fb7ad..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/exclude-tools/gemini-extension.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "excludeTools", - "version": "1.0.0", - "excludeTools": ["run_shell_command(rm -rf)"] -} diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/package.json b/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/package.json deleted file mode 100644 index d38f7ee99..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/examples/mcp-server/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "mcp-server-example", - "version": "1.0.0", - "description": "Example MCP Server for Gemini CLI Extension", - "type": "module", - "main": "example.js", - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "~5.4.5", - "@types/node": "^20.11.25" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", - "zod": "^3.22.4" - } -} diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/install.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/install.ts deleted file mode 100644 index 53cf1e6b9..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/install.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { - debugLogger, - type ExtensionInstallMetadata, -} from '@airiscode/gemini-cli-core'; -import { getErrorMessage } from '../../utils/errors.js'; -import { stat } from 'node:fs/promises'; -import { - INSTALL_WARNING_MESSAGE, - requestConsentNonInteractive, -} from '../../config/extensions/consent.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { loadSettings } from '../../config/settings.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -interface InstallArgs { - source: string; - ref?: string; - autoUpdate?: boolean; - allowPreRelease?: boolean; - consent?: boolean; -} - -export async function handleInstall(args: InstallArgs) { - try { - let installMetadata: ExtensionInstallMetadata; - const { source } = args; - if ( - source.startsWith('http://') || - source.startsWith('https://') || - source.startsWith('git@') || - source.startsWith('sso://') - ) { - installMetadata = { - source, - type: 'git', - ref: args.ref, - autoUpdate: args.autoUpdate, - allowPreRelease: args.allowPreRelease, - }; - } else { - if (args.ref || args.autoUpdate) { - throw new Error( - '--ref and --auto-update are not applicable for local extensions.', - ); - } - try { - await stat(source); - installMetadata = { - source, - type: 'local', - }; - } catch { - throw new Error('Install source not found.'); - } - } - - const requestConsent = args.consent - ? () => Promise.resolve(true) - : requestConsentNonInteractive; - if (args.consent) { - debugLogger.log('You have consented to the following:'); - debugLogger.log(INSTALL_WARNING_MESSAGE); - } - - const workspaceDir = process.cwd(); - const extensionManager = new ExtensionManager({ - workspaceDir, - requestConsent, - requestSetting: promptForSetting, - settings: loadSettings(workspaceDir).merged, - }); - await extensionManager.loadExtensions(); - const extension = - await extensionManager.installOrUpdateExtension(installMetadata); - debugLogger.log( - `Extension "${extension.name}" installed successfully and enabled.`, - ); - } catch (error) { - debugLogger.error(getErrorMessage(error)); - process.exit(1); - } -} - -export const installCommand: CommandModule = { - command: 'install [--auto-update] [--pre-release]', - describe: 'Installs an extension from a git repository URL or a local path.', - builder: (yargs) => - yargs - .positional('source', { - describe: 'The github URL or local path of the extension to install.', - type: 'string', - demandOption: true, - }) - .option('ref', { - describe: 'The git ref to install from.', - type: 'string', - }) - .option('auto-update', { - describe: 'Enable auto-update for this extension.', - type: 'boolean', - }) - .option('pre-release', { - describe: 'Enable pre-release versions for this extension.', - type: 'boolean', - }) - .option('consent', { - describe: - 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', - type: 'boolean', - default: false, - }) - .check((argv) => { - if (!argv.source) { - throw new Error('The source argument must be provided.'); - } - return true; - }), - handler: async (argv) => { - await handleInstall({ - source: argv['source'] as string, - ref: argv['ref'] as string | undefined, - autoUpdate: argv['auto-update'] as boolean | undefined, - allowPreRelease: argv['pre-release'] as boolean | undefined, - consent: argv['consent'] as boolean | undefined, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/link.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/link.ts deleted file mode 100644 index 5db4751a6..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/link.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { - debugLogger, - type ExtensionInstallMetadata, -} from '@airiscode/gemini-cli-core'; - -import { getErrorMessage } from '../../utils/errors.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { loadSettings } from '../../config/settings.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -interface InstallArgs { - path: string; -} - -export async function handleLink(args: InstallArgs) { - try { - const installMetadata: ExtensionInstallMetadata = { - source: args.path, - type: 'link', - }; - const workspaceDir = process.cwd(); - const extensionManager = new ExtensionManager({ - workspaceDir, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - settings: loadSettings(workspaceDir).merged, - }); - await extensionManager.loadExtensions(); - const extension = - await extensionManager.installOrUpdateExtension(installMetadata); - debugLogger.log( - `Extension "${extension.name}" linked successfully and enabled.`, - ); - } catch (error) { - debugLogger.error(getErrorMessage(error)); - process.exit(1); - } -} - -export const linkCommand: CommandModule = { - command: 'link ', - describe: - 'Links an extension from a local path. Updates made to the local path will always be reflected.', - builder: (yargs) => - yargs - .positional('path', { - describe: 'The name of the extension to link.', - type: 'string', - }) - .check((_) => true), - handler: async (argv) => { - await handleLink({ - path: argv['path'] as string, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/list.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/list.ts deleted file mode 100644 index 17c6f5e2e..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/list.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { getErrorMessage } from '../../utils/errors.js'; -import { debugLogger } from '@airiscode/gemini-cli-core'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { loadSettings } from '../../config/settings.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -export async function handleList() { - try { - const workspaceDir = process.cwd(); - const extensionManager = new ExtensionManager({ - workspaceDir, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - settings: loadSettings(workspaceDir).merged, - }); - const extensions = await extensionManager.loadExtensions(); - if (extensions.length === 0) { - debugLogger.log('No extensions installed.'); - return; - } - debugLogger.log( - extensions - .map((extension, _): string => - extensionManager.toOutputString(extension), - ) - .join('\n\n'), - ); - } catch (error) { - debugLogger.error(getErrorMessage(error)); - process.exit(1); - } -} - -export const listCommand: CommandModule = { - command: 'list', - describe: 'Lists installed extensions.', - builder: (yargs) => yargs, - handler: async () => { - await handleList(); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/new.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/new.ts deleted file mode 100644 index 3e835877d..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/new.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises'; -import { join, dirname, basename } from 'node:path'; -import type { CommandModule } from 'yargs'; -import { fileURLToPath } from 'node:url'; -import { debugLogger } from '@airiscode/gemini-cli-core'; - -interface NewArgs { - path: string; - template?: string; -} - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const EXAMPLES_PATH = join(__dirname, 'examples'); - -async function pathExists(path: string) { - try { - await access(path); - return true; - } catch (_e) { - return false; - } -} - -async function createDirectory(path: string) { - if (await pathExists(path)) { - throw new Error(`Path already exists: ${path}`); - } - await mkdir(path, { recursive: true }); -} - -async function copyDirectory(template: string, path: string) { - await createDirectory(path); - - const examplePath = join(EXAMPLES_PATH, template); - const entries = await readdir(examplePath, { withFileTypes: true }); - for (const entry of entries) { - const srcPath = join(examplePath, entry.name); - const destPath = join(path, entry.name); - await cp(srcPath, destPath, { recursive: true }); - } -} - -async function handleNew(args: NewArgs) { - if (args.template) { - await copyDirectory(args.template, args.path); - debugLogger.log( - `Successfully created new extension from template "${args.template}" at ${args.path}.`, - ); - } else { - await createDirectory(args.path); - const extensionName = basename(args.path); - const manifest = { - name: extensionName, - version: '1.0.0', - }; - await writeFile( - join(args.path, 'gemini-extension.json'), - JSON.stringify(manifest, null, 2), - ); - debugLogger.log(`Successfully created new extension at ${args.path}.`); - } - debugLogger.log( - `You can install this using "gemini extensions link ${args.path}" to test it out.`, - ); -} - -async function getBoilerplateChoices() { - const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); -} - -export const newCommand: CommandModule = { - command: 'new [template]', - describe: 'Create a new extension from a boilerplate example.', - builder: async (yargs) => { - const choices = await getBoilerplateChoices(); - return yargs - .positional('path', { - describe: 'The path to create the extension in.', - type: 'string', - }) - .positional('template', { - describe: 'The boilerplate template to use.', - type: 'string', - choices, - }); - }, - handler: async (args) => { - await handleNew({ - path: args['path'] as string, - template: args['template'] as string | undefined, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/uninstall.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/uninstall.ts deleted file mode 100644 index a055cf805..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/uninstall.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { getErrorMessage } from '../../utils/errors.js'; -import { debugLogger } from '@airiscode/gemini-cli-core'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { loadSettings } from '../../config/settings.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -interface UninstallArgs { - name: string; // can be extension name or source URL. -} - -export async function handleUninstall(args: UninstallArgs) { - try { - const workspaceDir = process.cwd(); - const extensionManager = new ExtensionManager({ - workspaceDir, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - settings: loadSettings(workspaceDir).merged, - }); - await extensionManager.loadExtensions(); - await extensionManager.uninstallExtension(args.name, false); - debugLogger.log(`Extension "${args.name}" successfully uninstalled.`); - } catch (error) { - debugLogger.error(getErrorMessage(error)); - process.exit(1); - } -} - -export const uninstallCommand: CommandModule = { - command: 'uninstall ', - describe: 'Uninstalls an extension.', - builder: (yargs) => - yargs - .positional('name', { - describe: 'The name or source path of the extension to uninstall.', - type: 'string', - }) - .check((argv) => { - if (!argv.name) { - throw new Error( - 'Please include the name of the extension to uninstall as a positional argument.', - ); - } - return true; - }), - handler: async (argv) => { - await handleUninstall({ - name: argv['name'] as string, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/update.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/update.ts deleted file mode 100644 index b52185e31..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/update.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { - updateAllUpdatableExtensions, - type ExtensionUpdateInfo, - checkForAllExtensionUpdates, - updateExtension, -} from '../../config/extensions/update.js'; -import { checkForExtensionUpdate } from '../../config/extensions/github.js'; -import { getErrorMessage } from '../../utils/errors.js'; -import { ExtensionUpdateState } from '../../ui/state/extensions.js'; -import { debugLogger } from '@airiscode/gemini-cli-core'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { loadSettings } from '../../config/settings.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -interface UpdateArgs { - name?: string; - all?: boolean; -} - -const updateOutput = (info: ExtensionUpdateInfo) => - `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; - -export async function handleUpdate(args: UpdateArgs) { - const workspaceDir = process.cwd(); - const settings = loadSettings(workspaceDir).merged; - const extensionManager = new ExtensionManager({ - workspaceDir, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - settings, - }); - - const extensions = await extensionManager.loadExtensions(); - if (args.name) { - try { - const extension = extensions.find( - (extension) => extension.name === args.name, - ); - if (!extension) { - debugLogger.log(`Extension "${args.name}" not found.`); - return; - } - if (!extension.installMetadata) { - debugLogger.log( - `Unable to install extension "${args.name}" due to missing install metadata`, - ); - return; - } - const updateState = await checkForExtensionUpdate( - extension, - extensionManager, - ); - if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { - debugLogger.log(`Extension "${args.name}" is already up to date.`); - return; - } - // TODO(chrstnb): we should list extensions if the requested extension is not installed. - const updatedExtensionInfo = (await updateExtension( - extension, - extensionManager, - updateState, - () => {}, - settings.experimental?.extensionReloading, - ))!; - if ( - updatedExtensionInfo.originalVersion !== - updatedExtensionInfo.updatedVersion - ) { - debugLogger.log( - `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, - ); - } else { - debugLogger.log(`Extension "${args.name}" is already up to date.`); - } - } catch (error) { - debugLogger.error(getErrorMessage(error)); - } - } - if (args.all) { - try { - const extensionState = new Map(); - await checkForAllExtensionUpdates( - extensions, - extensionManager, - (action) => { - if (action.type === 'SET_STATE') { - extensionState.set(action.payload.name, { - status: action.payload.state, - }); - } - }, - ); - let updateInfos = await updateAllUpdatableExtensions( - extensions, - extensionState, - extensionManager, - () => {}, - ); - updateInfos = updateInfos.filter( - (info) => info.originalVersion !== info.updatedVersion, - ); - if (updateInfos.length === 0) { - debugLogger.log('No extensions to update.'); - return; - } - debugLogger.log(updateInfos.map((info) => updateOutput(info)).join('\n')); - } catch (error) { - debugLogger.error(getErrorMessage(error)); - } - } -} - -export const updateCommand: CommandModule = { - command: 'update [] [--all]', - describe: - 'Updates all extensions or a named extension to the latest version.', - builder: (yargs) => - yargs - .positional('name', { - describe: 'The name of the extension to update.', - type: 'string', - }) - .option('all', { - describe: 'Update all extensions.', - type: 'boolean', - }) - .conflicts('name', 'all') - .check((argv) => { - if (!argv.all && !argv.name) { - throw new Error('Either an extension name or --all must be provided'); - } - return true; - }), - handler: async (argv) => { - await handleUpdate({ - name: argv['name'] as string | undefined, - all: argv['all'] as boolean | undefined, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/extensions/validate.ts b/apps/airiscode-cli/src/gemini-base/commands/extensions/validate.ts deleted file mode 100644 index 0d517af6b..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/extensions/validate.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { debugLogger } from '@airiscode/gemini-cli-core'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import semver from 'semver'; -import { getErrorMessage } from '../../utils/errors.js'; -import type { ExtensionConfig } from '../../config/extension.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; -import { loadSettings } from '../../config/settings.js'; - -interface ValidateArgs { - path: string; -} - -export async function handleValidate(args: ValidateArgs) { - try { - await validateExtension(args); - debugLogger.log(`Extension ${args.path} has been successfully validated.`); - } catch (error) { - debugLogger.error(getErrorMessage(error)); - process.exit(1); - } -} - -async function validateExtension(args: ValidateArgs) { - const workspaceDir = process.cwd(); - const extensionManager = new ExtensionManager({ - workspaceDir, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - settings: loadSettings(workspaceDir).merged, - }); - const absoluteInputPath = path.resolve(args.path); - const extensionConfig: ExtensionConfig = - extensionManager.loadExtensionConfig(absoluteInputPath); - const warnings: string[] = []; - const errors: string[] = []; - - if (extensionConfig.contextFileName) { - const contextFileNames = Array.isArray(extensionConfig.contextFileName) - ? extensionConfig.contextFileName - : [extensionConfig.contextFileName]; - - const missingContextFiles: string[] = []; - for (const contextFilePath of contextFileNames) { - const contextFileAbsolutePath = path.resolve( - absoluteInputPath, - contextFilePath, - ); - if (!fs.existsSync(contextFileAbsolutePath)) { - missingContextFiles.push(contextFilePath); - } - } - if (missingContextFiles.length > 0) { - errors.push( - `The following context files referenced in gemini-extension.json are missing: ${missingContextFiles}`, - ); - } - } - - if (!semver.valid(extensionConfig.version)) { - warnings.push( - `Warning: Version '${extensionConfig.version}' does not appear to be standard semver (e.g., 1.0.0).`, - ); - } - - if (warnings.length > 0) { - debugLogger.warn('Validation warnings:'); - for (const warning of warnings) { - debugLogger.warn(` - ${warning}`); - } - } - - if (errors.length > 0) { - debugLogger.error('Validation failed with the following errors:'); - for (const error of errors) { - debugLogger.error(` - ${error}`); - } - throw new Error('Extension validation failed.'); - } -} - -export const validateCommand: CommandModule = { - command: 'validate ', - describe: 'Validates an extension from a local path.', - builder: (yargs) => - yargs.positional('path', { - describe: 'The path of the extension to validate.', - type: 'string', - demandOption: true, - }), - handler: async (args) => { - await handleValidate({ - path: args['path'] as string, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/mcp/list.ts b/apps/airiscode-cli/src/gemini-base/commands/mcp/list.ts deleted file mode 100644 index 12209bbae..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/mcp/list.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// File for 'gemini mcp list' command -import type { CommandModule } from 'yargs'; -import { loadSettings } from '../../config/settings.js'; -import type { MCPServerConfig } from '@airiscode/gemini-cli-core'; -import { - MCPServerStatus, - createTransport, - debugLogger, -} from '@airiscode/gemini-cli-core'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; - -const COLOR_GREEN = '\u001b[32m'; -const COLOR_YELLOW = '\u001b[33m'; -const COLOR_RED = '\u001b[31m'; -const RESET_COLOR = '\u001b[0m'; - -async function getMcpServersFromConfig(): Promise< - Record -> { - const settings = loadSettings(); - const extensionManager = new ExtensionManager({ - settings: settings.merged, - workspaceDir: process.cwd(), - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - }); - const extensions = await extensionManager.loadExtensions(); - const mcpServers = { ...(settings.merged.mcpServers || {}) }; - for (const extension of extensions) { - Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { - if (mcpServers[key]) { - return; - } - mcpServers[key] = { - ...server, - extension, - }; - }); - } - return mcpServers; -} - -async function testMCPConnection( - serverName: string, - config: MCPServerConfig, -): Promise { - const client = new Client({ - name: 'mcp-test-client', - version: '0.0.1', - }); - - let transport; - try { - // Use the same transport creation logic as core - transport = await createTransport(serverName, config, false); - } catch (_error) { - await client.close(); - return MCPServerStatus.DISCONNECTED; - } - - try { - // Attempt actual MCP connection with short timeout - await client.connect(transport, { timeout: 5000 }); // 5s timeout - - // Test basic MCP protocol by pinging the server - await client.ping(); - - await client.close(); - return MCPServerStatus.CONNECTED; - } catch (_error) { - await transport.close(); - return MCPServerStatus.DISCONNECTED; - } -} - -async function getServerStatus( - serverName: string, - server: MCPServerConfig, -): Promise { - // Test all server types by attempting actual connection - return await testMCPConnection(serverName, server); -} - -export async function listMcpServers(): Promise { - const mcpServers = await getMcpServersFromConfig(); - const serverNames = Object.keys(mcpServers); - - if (serverNames.length === 0) { - debugLogger.log('No MCP servers configured.'); - return; - } - - debugLogger.log('Configured MCP servers:\n'); - - for (const serverName of serverNames) { - const server = mcpServers[serverName]; - - const status = await getServerStatus(serverName, server); - - let statusIndicator = ''; - let statusText = ''; - switch (status) { - case MCPServerStatus.CONNECTED: - statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR; - statusText = 'Connected'; - break; - case MCPServerStatus.CONNECTING: - statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR; - statusText = 'Connecting'; - break; - case MCPServerStatus.DISCONNECTED: - default: - statusIndicator = COLOR_RED + '✗' + RESET_COLOR; - statusText = 'Disconnected'; - break; - } - - let serverInfo = - serverName + - (server.extension?.name ? ` (from ${server.extension.name})` : '') + - ': '; - if (server.httpUrl) { - serverInfo += `${server.httpUrl} (http)`; - } else if (server.url) { - serverInfo += `${server.url} (sse)`; - } else if (server.command) { - serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; - } - - debugLogger.log(`${statusIndicator} ${serverInfo} - ${statusText}`); - } -} - -export const listCommand: CommandModule = { - command: 'list', - describe: 'List all configured MCP servers', - handler: async () => { - await listMcpServers(); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/commands/mcp/remove.ts b/apps/airiscode-cli/src/gemini-base/commands/mcp/remove.ts deleted file mode 100644 index 510323a4c..000000000 --- a/apps/airiscode-cli/src/gemini-base/commands/mcp/remove.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// File for 'gemini mcp remove' command -import type { CommandModule } from 'yargs'; -import { loadSettings, SettingScope } from '../../config/settings.js'; -import { debugLogger } from '@airiscode/gemini-cli-core'; - -async function removeMcpServer( - name: string, - options: { - scope: string; - }, -) { - const { scope } = options; - const settingsScope = - scope === 'user' ? SettingScope.User : SettingScope.Workspace; - const settings = loadSettings(); - - const existingSettings = settings.forScope(settingsScope).settings; - const mcpServers = existingSettings.mcpServers || {}; - - if (!mcpServers[name]) { - debugLogger.log(`Server "${name}" not found in ${scope} settings.`); - return; - } - - delete mcpServers[name]; - - settings.setValue(settingsScope, 'mcpServers', mcpServers); - - debugLogger.log(`Server "${name}" removed from ${scope} settings.`); -} - -export const removeCommand: CommandModule = { - command: 'remove ', - describe: 'Remove a server', - builder: (yargs) => - yargs - .usage('Usage: gemini mcp remove [options] ') - .positional('name', { - describe: 'Name of the server', - type: 'string', - demandOption: true, - }) - .option('scope', { - alias: 's', - describe: 'Configuration scope (user or project)', - type: 'string', - default: 'project', - choices: ['user', 'project'], - }), - handler: async (argv) => { - await removeMcpServer(argv['name'] as string, { - scope: argv['scope'] as string, - }); - }, -}; diff --git a/apps/airiscode-cli/src/gemini-base/config/auth.ts b/apps/airiscode-cli/src/gemini-base/config/auth.ts deleted file mode 100644 index f66ffd968..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/auth.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { AuthType } from '@airiscode/gemini-cli-core'; -import { loadEnvironment, loadSettings } from './settings.js'; - -export function validateAuthMethod(authMethod: string): string | null { - loadEnvironment(loadSettings().merged); - if ( - authMethod === AuthType.LOGIN_WITH_GOOGLE || - authMethod === AuthType.CLOUD_SHELL - ) { - return null; - } - - if (authMethod === AuthType.USE_GEMINI) { - return null; - } - - if (authMethod === AuthType.USE_VERTEX_AI) { - const hasVertexProjectLocationConfig = - !!process.env['GOOGLE_CLOUD_PROJECT'] && - !!process.env['GOOGLE_CLOUD_LOCATION']; - const hasGoogleApiKey = !!process.env['GOOGLE_API_KEY']; - if (!hasVertexProjectLocationConfig && !hasGoogleApiKey) { - return ( - 'When using Vertex AI, you must specify either:\n' + - '• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' + - '• GOOGLE_API_KEY environment variable (if using express mode).\n' + - 'Update your environment and try again (no reload needed if using .env)!' - ); - } - return null; - } - - return 'Invalid auth method selected.'; -} diff --git a/apps/airiscode-cli/src/gemini-base/config/config.ts b/apps/airiscode-cli/src/gemini-base/config/config.ts deleted file mode 100755 index cfc9d4da2..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/config.ts +++ /dev/null @@ -1,675 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import yargs from 'yargs/yargs'; -import { hideBin } from 'yargs/helpers'; -import process from 'node:process'; -import { mcpCommand } from '../commands/mcp.js'; -import type { OutputFormat } from '@airiscode/gemini-cli-core'; -import { extensionsCommand } from '../commands/extensions.js'; -import { - Config, - setGeminiMdFilename as setServerGeminiMdFilename, - getCurrentGeminiMdFilename, - ApprovalMode, - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, - DEFAULT_GEMINI_EMBEDDING_MODEL, - DEFAULT_FILE_FILTERING_OPTIONS, - DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, - FileDiscoveryService, - WRITE_FILE_TOOL_NAME, - SHELL_TOOL_NAMES, - SHELL_TOOL_NAME, - resolveTelemetrySettings, - FatalConfigError, - getPty, - EDIT_TOOL_NAME, - debugLogger, - loadServerHierarchicalMemory, -} from '@airiscode/gemini-cli-core'; -import type { Settings } from './settings.js'; - -import { getCliVersion } from '../utils/version.js'; -import { loadSandboxConfig } from './sandboxConfig.js'; -import { resolvePath } from '../utils/resolvePath.js'; -import { appEvents } from '../utils/events.js'; - -import { isWorkspaceTrusted } from './trustedFolders.js'; -import { createPolicyEngineConfig } from './policy.js'; -import { ExtensionManager } from './extension-manager.js'; -import type { ExtensionEvents } from '@airiscode/gemini-cli-core'; -import { requestConsentNonInteractive } from './extensions/consent.js'; -import { promptForSetting } from './extensions/extensionSettings.js'; -import type { EventEmitter } from 'node:stream'; - -export interface CliArgs { - query: string | undefined; - model: string | undefined; - sandbox: boolean | string | undefined; - debug: boolean | undefined; - prompt: string | undefined; - promptInteractive: string | undefined; - - yolo: boolean | undefined; - approvalMode: string | undefined; - allowedMcpServerNames: string[] | undefined; - allowedTools: string[] | undefined; - experimentalAcp: boolean | undefined; - extensions: string[] | undefined; - listExtensions: boolean | undefined; - resume: string | 'latest' | undefined; - listSessions: boolean | undefined; - deleteSession: string | undefined; - includeDirectories: string[] | undefined; - screenReader: boolean | undefined; - useSmartEdit: boolean | undefined; - useWriteTodos: boolean | undefined; - outputFormat: string | undefined; - fakeResponses: string | undefined; - recordResponses: string | undefined; -} - -export async function parseArguments(settings: Settings): Promise { - const rawArgv = hideBin(process.argv); - const yargsInstance = yargs(rawArgv) - .locale('en') - .scriptName('gemini') - .usage( - 'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode', - ) - - .option('debug', { - alias: 'd', - type: 'boolean', - description: 'Run in debug mode?', - default: false, - }) - .command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) => - yargsInstance - .positional('query', { - description: - 'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.', - }) - .option('model', { - alias: 'm', - type: 'string', - nargs: 1, - description: `Model`, - }) - .option('prompt', { - alias: 'p', - type: 'string', - nargs: 1, - description: 'Prompt. Appended to input on stdin (if any).', - }) - .option('prompt-interactive', { - alias: 'i', - type: 'string', - nargs: 1, - description: - 'Execute the provided prompt and continue in interactive mode', - }) - .option('sandbox', { - alias: 's', - type: 'boolean', - description: 'Run in sandbox?', - }) - - .option('yolo', { - alias: 'y', - type: 'boolean', - description: - 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', - default: false, - }) - .option('approval-mode', { - type: 'string', - nargs: 1, - choices: ['default', 'auto_edit', 'yolo'], - description: - 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)', - }) - .option('experimental-acp', { - type: 'boolean', - description: 'Starts the agent in ACP mode', - }) - .option('allowed-mcp-server-names', { - type: 'array', - string: true, - nargs: 1, - description: 'Allowed MCP server names', - coerce: (mcpServerNames: string[]) => - // Handle comma-separated values - mcpServerNames.flatMap((mcpServerName) => - mcpServerName.split(',').map((m) => m.trim()), - ), - }) - .option('allowed-tools', { - type: 'array', - string: true, - nargs: 1, - description: 'Tools that are allowed to run without confirmation', - coerce: (tools: string[]) => - // Handle comma-separated values - tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), - }) - .option('extensions', { - alias: 'e', - type: 'array', - string: true, - nargs: 1, - description: - 'A list of extensions to use. If not provided, all extensions are used.', - coerce: (extensions: string[]) => - // Handle comma-separated values - extensions.flatMap((extension) => - extension.split(',').map((e) => e.trim()), - ), - }) - .option('list-extensions', { - alias: 'l', - type: 'boolean', - description: 'List all available extensions and exit.', - }) - .option('resume', { - alias: 'r', - type: 'string', - // `skipValidation` so that we can distinguish between it being passed with a value, without - // one, and not being passed at all. - skipValidation: true, - description: - 'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)', - coerce: (value: string): string => { - // When --resume passed with a value (`gemini --resume 123`): value = "123" (string) - // When --resume passed without a value (`gemini --resume`): value = "" (string) - // When --resume not passed at all: this `coerce` function is not called at all, and - // `yargsInstance.argv.resume` is undefined. - if (value === '') { - return 'latest'; - } - return value; - }, - }) - .option('list-sessions', { - type: 'boolean', - description: - 'List available sessions for the current project and exit.', - }) - .option('delete-session', { - type: 'string', - description: - 'Delete a session by index number (use --list-sessions to see available sessions).', - }) - .option('include-directories', { - type: 'array', - string: true, - nargs: 1, - description: - 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', - coerce: (dirs: string[]) => - // Handle comma-separated values - dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), - }) - .option('screen-reader', { - type: 'boolean', - description: 'Enable screen reader mode for accessibility.', - }) - .option('output-format', { - alias: 'o', - type: 'string', - nargs: 1, - description: 'The format of the CLI output.', - choices: ['text', 'json', 'stream-json'], - }) - .option('fake-responses', { - type: 'string', - description: 'Path to a file with fake model responses for testing.', - hidden: true, - }) - .option('record-responses', { - type: 'string', - description: 'Path to a file to record model responses for testing.', - hidden: true, - }) - .deprecateOption( - 'prompt', - 'Use the positional prompt instead. This flag will be removed in a future version.', - ) - // Ensure validation flows through .fail() for clean UX - .fail((msg, err, yargs) => { - debugLogger.error(msg || err?.message || 'Unknown error'); - yargs.showHelp(); - process.exit(1); - }) - .check((argv) => { - // The 'query' positional can be a string (for one arg) or string[] (for multiple). - // This guard safely checks if any positional argument was provided. - const query = argv['query'] as string | string[] | undefined; - const hasPositionalQuery = Array.isArray(query) - ? query.length > 0 - : !!query; - - if (argv['prompt'] && hasPositionalQuery) { - return 'Cannot use both a positional prompt and the --prompt (-p) flag together'; - } - if (argv['prompt'] && argv['promptInteractive']) { - return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together'; - } - if (argv.resume && !argv.prompt && !process.stdin.isTTY) { - throw new Error( - 'When resuming a session, you must provide a message via --prompt (-p) or stdin', - ); - } - if (argv.yolo && argv['approvalMode']) { - return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; - } - return true; - }), - ) - // Register MCP subcommands - .command(mcpCommand); - - if (settings?.experimental?.extensionManagement ?? true) { - yargsInstance.command(extensionsCommand); - } - - yargsInstance - .version(await getCliVersion()) // This will enable the --version flag based on package.json - .alias('v', 'version') - .help() - .alias('h', 'help') - .strict() - .demandCommand(0, 0); // Allow base command to run with no subcommands - - yargsInstance.wrap(yargsInstance.terminalWidth()); - const result = await yargsInstance.parse(); - - // If yargs handled --help/--version it will have exited; nothing to do here. - - // Handle case where MCP subcommands are executed - they should exit the process - // and not return to main CLI logic - if ( - result._.length > 0 && - (result._[0] === 'mcp' || result._[0] === 'extensions') - ) { - // MCP commands handle their own execution and process exit - process.exit(0); - } - - // Normalize query args: handle both quoted "@path file" and unquoted @path file - const queryArg = (result as { query?: string | string[] | undefined }).query; - const q: string | undefined = Array.isArray(queryArg) - ? queryArg.join(' ') - : queryArg; - - // Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands) - if (q && !result['prompt']) { - const hasExplicitInteractive = - result['promptInteractive'] === '' || !!result['promptInteractive']; - if (hasExplicitInteractive) { - result['promptInteractive'] = q; - } else { - result['prompt'] = q; - } - } - - // Keep CliArgs.query as a string for downstream typing - (result as Record)['query'] = q || undefined; - - // The import format is now only controlled by settings.memoryImportFormat - // We no longer accept it as a CLI argument - return result as unknown as CliArgs; -} - -/** - * Creates a filter function to determine if a tool should be excluded. - * - * In non-interactive mode, we want to disable tools that require user - * interaction to prevent the CLI from hanging. This function creates a predicate - * that returns `true` if a tool should be excluded. - * - * A tool is excluded if it's not in the `allowedToolsSet`. The shell tool - * has a special case: it's not excluded if any of its subcommands - * are in the `allowedTools` list. - * - * @param allowedTools A list of explicitly allowed tool names. - * @param allowedToolsSet A set of explicitly allowed tool names for quick lookups. - * @returns A function that takes a tool name and returns `true` if it should be excluded. - */ -function createToolExclusionFilter( - allowedTools: string[], - allowedToolsSet: Set, -) { - return (tool: string): boolean => { - if (tool === SHELL_TOOL_NAME) { - // If any of the allowed tools is ShellTool (even with subcommands), don't exclude it. - return !allowedTools.some((allowed) => - SHELL_TOOL_NAMES.some((shellName) => allowed.startsWith(shellName)), - ); - } - return !allowedToolsSet.has(tool); - }; -} - -export function isDebugMode(argv: CliArgs): boolean { - return ( - argv.debug || - [process.env['DEBUG'], process.env['DEBUG_MODE']].some( - (v) => v === 'true' || v === '1', - ) - ); -} - -export async function loadCliConfig( - settings: Settings, - sessionId: string, - argv: CliArgs, - cwd: string = process.cwd(), -): Promise { - const debugMode = isDebugMode(argv); - - if (argv.sandbox) { - process.env['GEMINI_SANDBOX'] = 'true'; - } - - const memoryImportFormat = settings.context?.importFormat || 'tree'; - - const ideMode = settings.ide?.enabled ?? false; - - const folderTrust = settings.security?.folderTrust?.enabled ?? false; - const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true; - - // Set the context filename in the server's memoryTool module BEFORE loading memory - // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed - // directly to the Config constructor in core, and have core handle setGeminiMdFilename. - // However, loadHierarchicalGeminiMemory is called *before* createServerConfig. - if (settings.context?.fileName) { - setServerGeminiMdFilename(settings.context.fileName); - } else { - // Reset to default if not provided in settings. - setServerGeminiMdFilename(getCurrentGeminiMdFilename()); - } - - const fileService = new FileDiscoveryService(cwd); - - const memoryFileFiltering = { - ...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, - ...settings.context?.fileFiltering, - }; - - const fileFiltering = { - ...DEFAULT_FILE_FILTERING_OPTIONS, - ...settings.context?.fileFiltering, - }; - - const includeDirectories = (settings.context?.includeDirectories || []) - .map(resolvePath) - .concat((argv.includeDirectories || []).map(resolvePath)); - - const extensionManager = new ExtensionManager({ - settings, - requestConsent: requestConsentNonInteractive, - requestSetting: promptForSetting, - workspaceDir: cwd, - enabledExtensionOverrides: argv.extensions, - eventEmitter: appEvents as EventEmitter, - }); - await extensionManager.loadExtensions(); - - // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version - const { memoryContent, fileCount, filePaths } = - await loadServerHierarchicalMemory( - cwd, - settings.context?.loadMemoryFromIncludeDirectories - ? includeDirectories - : [], - debugMode, - fileService, - extensionManager, - trustedFolder, - memoryImportFormat, - memoryFileFiltering, - settings.context?.discoveryMaxDirs, - ); - - const question = argv.promptInteractive || argv.prompt || ''; - - // Determine approval mode with backward compatibility - let approvalMode: ApprovalMode; - if (argv.approvalMode) { - // New --approval-mode flag takes precedence - switch (argv.approvalMode) { - case 'yolo': - approvalMode = ApprovalMode.YOLO; - break; - case 'auto_edit': - approvalMode = ApprovalMode.AUTO_EDIT; - break; - case 'default': - approvalMode = ApprovalMode.DEFAULT; - break; - default: - throw new Error( - `Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`, - ); - } - } else { - // Fallback to legacy --yolo flag behavior - approvalMode = - argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; - } - - // Override approval mode if disableYoloMode is set. - if (settings.security?.disableYoloMode) { - if (approvalMode === ApprovalMode.YOLO) { - debugLogger.error('YOLO mode is disabled by the "disableYolo" setting.'); - throw new FatalConfigError( - 'Cannot start in YOLO mode when it is disabled by settings', - ); - } - approvalMode = ApprovalMode.DEFAULT; - } else if (approvalMode === ApprovalMode.YOLO) { - debugLogger.warn( - 'YOLO mode is enabled. All tool calls will be automatically approved.', - ); - } - - // Force approval mode to default if the folder is not trusted. - if (!trustedFolder && approvalMode !== ApprovalMode.DEFAULT) { - debugLogger.warn( - `Approval mode overridden to "default" because the current folder is not trusted.`, - ); - approvalMode = ApprovalMode.DEFAULT; - } - - let telemetrySettings; - try { - telemetrySettings = await resolveTelemetrySettings({ - env: process.env as unknown as Record, - settings: settings.telemetry, - }); - } catch (err) { - if (err instanceof FatalConfigError) { - throw new FatalConfigError( - `Invalid telemetry configuration: ${err.message}.`, - ); - } - throw err; - } - - const policyEngineConfig = await createPolicyEngineConfig( - settings, - approvalMode, - ); - - const enableMessageBusIntegration = - settings.tools?.enableMessageBusIntegration ?? false; - - const allowedTools = argv.allowedTools || settings.tools?.allowed || []; - const allowedToolsSet = new Set(allowedTools); - - // Interactive mode: explicit -i flag or (TTY + no args + no -p flag) - const hasQuery = !!argv.query; - const interactive = - !!argv.promptInteractive || - (process.stdin.isTTY && !hasQuery && !argv.prompt); - // In non-interactive mode, exclude tools that require a prompt. - const extraExcludes: string[] = []; - if (!interactive && !argv.experimentalAcp) { - const defaultExcludes = [ - SHELL_TOOL_NAME, - EDIT_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - ]; - const autoEditExcludes = [SHELL_TOOL_NAME]; - - const toolExclusionFilter = createToolExclusionFilter( - allowedTools, - allowedToolsSet, - ); - - switch (approvalMode) { - case ApprovalMode.DEFAULT: - // In default non-interactive mode, all tools that require approval are excluded. - extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.AUTO_EDIT: - // In auto-edit non-interactive mode, only tools that still require a prompt are excluded. - extraExcludes.push(...autoEditExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.YOLO: - // No extra excludes for YOLO mode. - break; - default: - // This should never happen due to validation earlier, but satisfies the linter - break; - } - } - - const excludeTools = mergeExcludeTools( - settings, - extraExcludes.length > 0 ? extraExcludes : undefined, - ); - - const useModelRouter = settings.experimental?.useModelRouter ?? true; - const defaultModel = useModelRouter - ? DEFAULT_GEMINI_MODEL_AUTO - : DEFAULT_GEMINI_MODEL; - const resolvedModel: string = - argv.model || - process.env['GEMINI_MODEL'] || - settings.model?.name || - defaultModel; - - const sandboxConfig = await loadSandboxConfig(settings, argv); - const screenReader = - argv.screenReader !== undefined - ? argv.screenReader - : (settings.ui?.accessibility?.screenReader ?? false); - - const ptyInfo = await getPty(); - - return new Config({ - sessionId, - embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, - sandbox: sandboxConfig, - targetDir: cwd, - includeDirectories, - loadMemoryFromIncludeDirectories: - settings.context?.loadMemoryFromIncludeDirectories || false, - debugMode, - question, - - coreTools: settings.tools?.core || undefined, - allowedTools: allowedTools.length > 0 ? allowedTools : undefined, - policyEngineConfig, - excludeTools, - toolDiscoveryCommand: settings.tools?.discoveryCommand, - toolCallCommand: settings.tools?.callCommand, - mcpServerCommand: settings.mcp?.serverCommand, - mcpServers: settings.mcpServers, - allowedMcpServers: argv.allowedMcpServerNames ?? settings.mcp?.allowed, - blockedMcpServers: argv.allowedMcpServerNames - ? [] // explicitly allowed servers overrides everything - : settings.mcp?.excluded, - userMemory: memoryContent, - geminiMdFileCount: fileCount, - geminiMdFilePaths: filePaths, - approvalMode, - disableYoloMode: settings.security?.disableYoloMode, - showMemoryUsage: settings.ui?.showMemoryUsage || false, - accessibility: { - ...settings.ui?.accessibility, - screenReader, - }, - telemetry: telemetrySettings, - usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, - fileFiltering, - checkpointing: settings.general?.checkpointing?.enabled, - proxy: - process.env['HTTPS_PROXY'] || - process.env['https_proxy'] || - process.env['HTTP_PROXY'] || - process.env['http_proxy'], - cwd, - fileDiscoveryService: fileService, - bugCommand: settings.advanced?.bugCommand, - model: resolvedModel, - maxSessionTurns: settings.model?.maxSessionTurns ?? -1, - experimentalZedIntegration: argv.experimentalAcp || false, - listExtensions: argv.listExtensions || false, - listSessions: argv.listSessions || false, - deleteSession: argv.deleteSession, - enabledExtensions: argv.extensions, - extensionLoader: extensionManager, - enableExtensionReloading: settings.experimental?.extensionReloading, - noBrowser: !!process.env['NO_BROWSER'], - summarizeToolOutput: settings.model?.summarizeToolOutput, - ideMode, - compressionThreshold: settings.model?.compressionThreshold, - folderTrust, - interactive, - trustedFolder, - useRipgrep: settings.tools?.useRipgrep, - enableInteractiveShell: - settings.tools?.shell?.enableInteractiveShell ?? true, - skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, - enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, - truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, - truncateToolOutputLines: settings.tools?.truncateToolOutputLines, - enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, - eventEmitter: appEvents, - useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, - useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, - output: { - format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, - }, - useModelRouter, - enableMessageBusIntegration, - codebaseInvestigatorSettings: - settings.experimental?.codebaseInvestigatorSettings, - fakeResponses: argv.fakeResponses, - recordResponses: argv.recordResponses, - retryFetchErrors: settings.general?.retryFetchErrors ?? false, - ptyInfo: ptyInfo?.name, - modelConfigServiceConfig: settings.modelConfigs, - // TODO: loading of hooks based on workspace trust - enableHooks: settings.tools?.enableHooks ?? false, - hooks: settings.hooks || {}, - }); -} - -function mergeExcludeTools( - settings: Settings, - extraExcludes?: string[] | undefined, -): string[] { - const allExcludeTools = new Set([ - ...(settings.tools?.exclude || []), - ...(extraExcludes || []), - ]); - return [...allExcludeTools]; -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extension-manager.ts b/apps/airiscode-cli/src/gemini-base/config/extension-manager.ts deleted file mode 100644 index 5914ac929..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extension-manager.ts +++ /dev/null @@ -1,750 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import chalk from 'chalk'; -import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; -import { type Settings, SettingScope } from './settings.js'; -import { createHash, randomUUID } from 'node:crypto'; -import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; -import { - isWorkspaceTrusted, - loadTrustedFolders, - TrustLevel, -} from './trustedFolders.js'; -import { - cloneFromGit, - downloadFromGitHubRelease, - tryParseGithubUrl, -} from './extensions/github.js'; -import { - Config, - debugLogger, - ExtensionDisableEvent, - ExtensionEnableEvent, - ExtensionInstallEvent, - ExtensionLoader, - ExtensionUninstallEvent, - ExtensionUpdateEvent, - getErrorMessage, - logExtensionDisable, - logExtensionEnable, - logExtensionInstallEvent, - logExtensionUninstall, - logExtensionUpdateEvent, - type ExtensionEvents, - type MCPServerConfig, - type ExtensionInstallMetadata, - type GeminiCLIExtension, -} from '@airiscode/gemini-cli-core'; -import { maybeRequestConsentOrFail } from './extensions/consent.js'; -import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; -import { ExtensionStorage } from './extensions/storage.js'; -import { - EXTENSIONS_CONFIG_FILENAME, - INSTALL_METADATA_FILENAME, - recursivelyHydrateStrings, - type JsonObject, -} from './extensions/variables.js'; -import { - getEnvContents, - maybePromptForSettings, - type ExtensionSetting, -} from './extensions/extensionSettings.js'; -import type { EventEmitter } from 'node:stream'; - -interface ExtensionManagerParams { - enabledExtensionOverrides?: string[]; - settings: Settings; - requestConsent: (consent: string) => Promise; - requestSetting: ((setting: ExtensionSetting) => Promise) | null; - workspaceDir: string; - eventEmitter?: EventEmitter; -} - -/** - * Actual implementation of an ExtensionLoader. - * - * You must call `loadExtensions` prior to calling other methods on this class. - */ -export class ExtensionManager extends ExtensionLoader { - private extensionEnablementManager: ExtensionEnablementManager; - private settings: Settings; - private requestConsent: (consent: string) => Promise; - private requestSetting: - | ((setting: ExtensionSetting) => Promise) - | undefined; - private telemetryConfig: Config; - private workspaceDir: string; - private loadedExtensions: GeminiCLIExtension[] | undefined; - - constructor(options: ExtensionManagerParams) { - super(options.eventEmitter); - this.workspaceDir = options.workspaceDir; - this.extensionEnablementManager = new ExtensionEnablementManager( - options.enabledExtensionOverrides, - ); - this.settings = options.settings; - this.telemetryConfig = new Config({ - telemetry: options.settings.telemetry, - interactive: false, - sessionId: randomUUID(), - targetDir: options.workspaceDir, - cwd: options.workspaceDir, - model: '', - debugMode: false, - }); - this.requestConsent = options.requestConsent; - this.requestSetting = options.requestSetting ?? undefined; - } - - setRequestConsent( - requestConsent: (consent: string) => Promise, - ): void { - this.requestConsent = requestConsent; - } - - setRequestSetting( - requestSetting?: (setting: ExtensionSetting) => Promise, - ): void { - this.requestSetting = requestSetting; - } - - getExtensions(): GeminiCLIExtension[] { - if (!this.loadedExtensions) { - throw new Error( - 'Extensions not yet loaded, must call `loadExtensions` first', - ); - } - return this.loadedExtensions!; - } - - async installOrUpdateExtension( - installMetadata: ExtensionInstallMetadata, - previousExtensionConfig?: ExtensionConfig, - ): Promise { - if ( - (installMetadata.type === 'git' || - installMetadata.type === 'github-release') && - this.settings.security?.blockGitExtensions - ) { - throw new Error( - 'Installing extensions from remote sources is disallowed by your current settings.', - ); - } - const isUpdate = !!previousExtensionConfig; - let newExtensionConfig: ExtensionConfig | null = null; - let localSourcePath: string | undefined; - let extension: GeminiCLIExtension | null; - try { - if (!isWorkspaceTrusted(this.settings).isTrusted) { - if ( - await this.requestConsent( - `The current workspace at "${this.workspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, - ) - ) { - const trustedFolders = loadTrustedFolders(); - trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER); - } else { - throw new Error( - `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, - ); - } - } - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); - await fs.promises.mkdir(extensionsDir, { recursive: true }); - - if ( - !path.isAbsolute(installMetadata.source) && - (installMetadata.type === 'local' || installMetadata.type === 'link') - ) { - installMetadata.source = path.resolve( - this.workspaceDir, - installMetadata.source, - ); - } - - let tempDir: string | undefined; - - if ( - installMetadata.type === 'git' || - installMetadata.type === 'github-release' - ) { - tempDir = await ExtensionStorage.createTmpDir(); - const parsedGithubParts = tryParseGithubUrl(installMetadata.source); - if (!parsedGithubParts) { - await cloneFromGit(installMetadata, tempDir); - installMetadata.type = 'git'; - } else { - const result = await downloadFromGitHubRelease( - installMetadata, - tempDir, - parsedGithubParts, - ); - if (result.success) { - installMetadata.type = result.type; - installMetadata.releaseTag = result.tagName; - } else if ( - // This repo has no github releases, and wasn't explicitly installed - // from a github release, unconditionally just clone it. - (result.failureReason === 'no release data' && - installMetadata.type === 'git') || - // Otherwise ask the user if they would like to try a git clone. - (await this.requestConsent( - `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`, - )) - ) { - await cloneFromGit(installMetadata, tempDir); - installMetadata.type = 'git'; - } else { - throw new Error( - `Failed to install extension ${installMetadata.source}: ${result.errorMessage}`, - ); - } - } - localSourcePath = tempDir; - } else if ( - installMetadata.type === 'local' || - installMetadata.type === 'link' - ) { - localSourcePath = installMetadata.source; - } else { - throw new Error(`Unsupported install type: ${installMetadata.type}`); - } - - try { - newExtensionConfig = this.loadExtensionConfig(localSourcePath); - - if (isUpdate && installMetadata.autoUpdate) { - const oldSettings = new Set( - previousExtensionConfig.settings?.map((s) => s.name) || [], - ); - const newSettings = new Set( - newExtensionConfig.settings?.map((s) => s.name) || [], - ); - - const settingsAreEqual = - oldSettings.size === newSettings.size && - [...oldSettings].every((value) => newSettings.has(value)); - - if (!settingsAreEqual && installMetadata.autoUpdate) { - throw new Error( - `Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`, - ); - } - } - - const newExtensionName = newExtensionConfig.name; - const previous = this.getExtensions().find( - (installed) => installed.name === newExtensionName, - ); - if (isUpdate && !previous) { - throw new Error( - `Extension "${newExtensionName}" was not already installed, cannot update it.`, - ); - } else if (!isUpdate && previous) { - throw new Error( - `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, - ); - } - - await maybeRequestConsentOrFail( - newExtensionConfig, - this.requestConsent, - previousExtensionConfig, - ); - const extensionId = getExtensionId(newExtensionConfig, installMetadata); - const destinationPath = new ExtensionStorage( - newExtensionName, - ).getExtensionDir(); - let previousSettings: Record | undefined; - if (isUpdate) { - previousSettings = await getEnvContents( - previousExtensionConfig, - extensionId, - ); - await this.uninstallExtension(newExtensionName, isUpdate); - } - - await fs.promises.mkdir(destinationPath, { recursive: true }); - if (this.requestSetting) { - if (isUpdate) { - await maybePromptForSettings( - newExtensionConfig, - extensionId, - this.requestSetting, - previousExtensionConfig, - previousSettings, - ); - } else { - await maybePromptForSettings( - newExtensionConfig, - extensionId, - this.requestSetting, - ); - } - } - - if ( - installMetadata.type === 'local' || - installMetadata.type === 'git' || - installMetadata.type === 'github-release' - ) { - await copyExtension(localSourcePath, destinationPath); - } - - const metadataString = JSON.stringify(installMetadata, null, 2); - const metadataPath = path.join( - destinationPath, - INSTALL_METADATA_FILENAME, - ); - await fs.promises.writeFile(metadataPath, metadataString); - - // TODO: Gracefully handle this call failing, we should back up the old - // extension prior to overwriting it and then restore and restart it. - extension = await this.loadExtension(destinationPath)!; - if (!extension) { - throw new Error(`Extension not found`); - } - if (isUpdate) { - await logExtensionUpdateEvent( - this.telemetryConfig, - new ExtensionUpdateEvent( - hashValue(newExtensionConfig.name), - getExtensionId(newExtensionConfig, installMetadata), - newExtensionConfig.version, - previousExtensionConfig.version, - installMetadata.type, - 'success', - ), - ); - } else { - await logExtensionInstallEvent( - this.telemetryConfig, - new ExtensionInstallEvent( - hashValue(newExtensionConfig.name), - getExtensionId(newExtensionConfig, installMetadata), - newExtensionConfig.version, - installMetadata.type, - 'success', - ), - ); - this.enableExtension(newExtensionConfig.name, SettingScope.User); - } - } finally { - if (tempDir) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - } - } - return extension; - } catch (error) { - // Attempt to load config from the source path even if installation fails - // to get the name and version for logging. - if (!newExtensionConfig && localSourcePath) { - try { - newExtensionConfig = this.loadExtensionConfig(localSourcePath); - } catch { - // Ignore error, this is just for logging. - } - } - const config = newExtensionConfig ?? previousExtensionConfig; - const extensionId = config - ? getExtensionId(config, installMetadata) - : undefined; - if (isUpdate) { - await logExtensionUpdateEvent( - this.telemetryConfig, - new ExtensionUpdateEvent( - hashValue(config?.name ?? ''), - extensionId ?? '', - newExtensionConfig?.version ?? '', - previousExtensionConfig.version, - installMetadata.type, - 'error', - ), - ); - } else { - await logExtensionInstallEvent( - this.telemetryConfig, - new ExtensionInstallEvent( - hashValue(newExtensionConfig?.name ?? ''), - extensionId ?? '', - newExtensionConfig?.version ?? '', - installMetadata.type, - 'error', - ), - ); - } - throw error; - } - } - - async uninstallExtension( - extensionIdentifier: string, - isUpdate: boolean, - ): Promise { - const installedExtensions = this.getExtensions(); - const extension = installedExtensions.find( - (installed) => - installed.name.toLowerCase() === extensionIdentifier.toLowerCase() || - installed.installMetadata?.source.toLowerCase() === - extensionIdentifier.toLowerCase(), - ); - if (!extension) { - throw new Error(`Extension not found.`); - } - await this.unloadExtension(extension); - const storage = new ExtensionStorage( - extension.installMetadata?.type === 'link' - ? extension.name - : path.basename(extension.path), - ); - - await fs.promises.rm(storage.getExtensionDir(), { - recursive: true, - force: true, - }); - - // The rest of the cleanup below here is only for true uninstalls, not - // uninstalls related to updates. - if (isUpdate) return; - - this.extensionEnablementManager.remove(extension.name); - - await logExtensionUninstall( - this.telemetryConfig, - new ExtensionUninstallEvent( - hashValue(extension.name), - extension.id, - 'success', - ), - ); - } - - /** - * Loads all installed extensions, should only be called once. - */ - async loadExtensions(): Promise { - if (this.loadedExtensions) { - throw new Error('Extensions already loaded, only load extensions once.'); - } - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); - this.loadedExtensions = []; - if (!fs.existsSync(extensionsDir)) { - return this.loadedExtensions; - } - for (const subdir of fs.readdirSync(extensionsDir)) { - const extensionDir = path.join(extensionsDir, subdir); - await this.loadExtension(extensionDir); - } - return this.loadedExtensions; - } - - /** - * Adds `extension` to the list of extensions and starts it if appropriate. - */ - private async loadExtension( - extensionDir: string, - ): Promise { - this.loadedExtensions ??= []; - if (!fs.statSync(extensionDir).isDirectory()) { - return null; - } - - const installMetadata = loadInstallMetadata(extensionDir); - let effectiveExtensionPath = extensionDir; - if ( - (installMetadata?.type === 'git' || - installMetadata?.type === 'github-release') && - this.settings.security?.blockGitExtensions - ) { - return null; - } - - if (installMetadata?.type === 'link') { - effectiveExtensionPath = installMetadata.source; - } - - try { - let config = this.loadExtensionConfig(effectiveExtensionPath); - if ( - this.getExtensions().find((extension) => extension.name === config.name) - ) { - throw new Error( - `Extension with name ${config.name} already was loaded.`, - ); - } - - const customEnv = await getEnvContents( - config, - getExtensionId(config, installMetadata), - ); - config = resolveEnvVarsInObject(config, customEnv); - - if (config.mcpServers) { - config.mcpServers = Object.fromEntries( - Object.entries(config.mcpServers).map(([key, value]) => [ - key, - filterMcpConfig(value), - ]), - ); - } - - const contextFiles = getContextFileNames(config) - .map((contextFileName) => - path.join(effectiveExtensionPath, contextFileName), - ) - .filter((contextFilePath) => fs.existsSync(contextFilePath)); - - const extension = { - name: config.name, - version: config.version, - path: effectiveExtensionPath, - contextFiles, - installMetadata, - mcpServers: config.mcpServers, - excludeTools: config.excludeTools, - isActive: this.extensionEnablementManager.isEnabled( - config.name, - this.workspaceDir, - ), - id: getExtensionId(config, installMetadata), - }; - this.loadedExtensions = [...this.loadedExtensions, extension]; - - await this.maybeStartExtension(extension); - return extension; - } catch (e) { - debugLogger.error( - `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( - e, - )}`, - ); - return null; - } - } - - /** - * Removes `extension` from the list of extensions and stops it if - * appropriate. - */ - private unloadExtension( - extension: GeminiCLIExtension, - ): Promise | undefined { - this.loadedExtensions = this.getExtensions().filter( - (entry) => extension !== entry, - ); - return this.maybeStopExtension(extension); - } - - loadExtensionConfig(extensionDir: string): ExtensionConfig { - const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); - if (!fs.existsSync(configFilePath)) { - throw new Error(`Configuration file not found at ${configFilePath}`); - } - try { - const configContent = fs.readFileSync(configFilePath, 'utf-8'); - const rawConfig = JSON.parse(configContent) as ExtensionConfig; - if (!rawConfig.name || !rawConfig.version) { - throw new Error( - `Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`, - ); - } - const config = recursivelyHydrateStrings( - rawConfig as unknown as JsonObject, - { - extensionPath: extensionDir, - workspacePath: this.workspaceDir, - '/': path.sep, - pathSeparator: path.sep, - }, - ) as unknown as ExtensionConfig; - - validateName(config.name); - return config; - } catch (e) { - throw new Error( - `Failed to load extension config from ${configFilePath}: ${getErrorMessage( - e, - )}`, - ); - } - } - - toOutputString(extension: GeminiCLIExtension): string { - const userEnabled = this.extensionEnablementManager.isEnabled( - extension.name, - os.homedir(), - ); - const workspaceEnabled = this.extensionEnablementManager.isEnabled( - extension.name, - this.workspaceDir, - ); - - const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); - let output = `${status} ${extension.name} (${extension.version})`; - output += `\n ID: ${extension.id}`; - output += `\n name: ${hashValue(extension.name)}`; - - output += `\n Path: ${extension.path}`; - if (extension.installMetadata) { - output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; - if (extension.installMetadata.ref) { - output += `\n Ref: ${extension.installMetadata.ref}`; - } - if (extension.installMetadata.releaseTag) { - output += `\n Release tag: ${extension.installMetadata.releaseTag}`; - } - } - output += `\n Enabled (User): ${userEnabled}`; - output += `\n Enabled (Workspace): ${workspaceEnabled}`; - if (extension.contextFiles.length > 0) { - output += `\n Context files:`; - extension.contextFiles.forEach((contextFile) => { - output += `\n ${contextFile}`; - }); - } - if (extension.mcpServers) { - output += `\n MCP servers:`; - Object.keys(extension.mcpServers).forEach((key) => { - output += `\n ${key}`; - }); - } - if (extension.excludeTools) { - output += `\n Excluded tools:`; - extension.excludeTools.forEach((tool) => { - output += `\n ${tool}`; - }); - } - return output; - } - - async disableExtension(name: string, scope: SettingScope) { - if ( - scope === SettingScope.System || - scope === SettingScope.SystemDefaults - ) { - throw new Error('System and SystemDefaults scopes are not supported.'); - } - const extension = this.getExtensions().find( - (extension) => extension.name === name, - ); - if (!extension) { - throw new Error(`Extension with name ${name} does not exist.`); - } - - if (scope !== SettingScope.Session) { - const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); - this.extensionEnablementManager.disable(name, true, scopePath); - } - await logExtensionDisable( - this.telemetryConfig, - new ExtensionDisableEvent(hashValue(name), extension.id, scope), - ); - if (!this.config || this.config.getEnableExtensionReloading()) { - // Only toggle the isActive state if we are actually going to disable it - // in the current session, or we haven't been initialized yet. - extension.isActive = false; - } - await this.maybeStopExtension(extension); - } - - /** - * Enables an existing extension for a given scope, and starts it if - * appropriate. - */ - async enableExtension(name: string, scope: SettingScope) { - if ( - scope === SettingScope.System || - scope === SettingScope.SystemDefaults - ) { - throw new Error('System and SystemDefaults scopes are not supported.'); - } - const extension = this.getExtensions().find( - (extension) => extension.name === name, - ); - if (!extension) { - throw new Error(`Extension with name ${name} does not exist.`); - } - - if (scope !== SettingScope.Session) { - const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); - this.extensionEnablementManager.enable(name, true, scopePath); - } - await logExtensionEnable( - this.telemetryConfig, - new ExtensionEnableEvent(hashValue(name), extension.id, scope), - ); - if (!this.config || this.config.getEnableExtensionReloading()) { - // Only toggle the isActive state if we are actually going to disable it - // in the current session, or we haven't been initialized yet. - extension.isActive = true; - } - await this.maybeStartExtension(extension); - } -} - -function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { trust, ...rest } = original; - return Object.freeze(rest); -} - -export async function copyExtension( - source: string, - destination: string, -): Promise { - await fs.promises.cp(source, destination, { recursive: true }); -} - -function getContextFileNames(config: ExtensionConfig): string[] { - if (!config.contextFileName) { - return ['GEMINI.md']; - } else if (!Array.isArray(config.contextFileName)) { - return [config.contextFileName]; - } - return config.contextFileName; -} - -function validateName(name: string) { - if (!/^[a-zA-Z0-9-]+$/.test(name)) { - throw new Error( - `Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`, - ); - } -} - -export function getExtensionId( - config: ExtensionConfig, - installMetadata?: ExtensionInstallMetadata, -): string { - // IDs are created by hashing details of the installation source in order to - // deduplicate extensions with conflicting names and also obfuscate any - // potentially sensitive information such as private git urls, system paths, - // or project names. - let idValue = config.name; - const githubUrlParts = - installMetadata && - (installMetadata.type === 'git' || - installMetadata.type === 'github-release') - ? tryParseGithubUrl(installMetadata.source) - : null; - if (githubUrlParts) { - // For github repos, we use the https URI to the repo as the ID. - idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`; - } else { - idValue = installMetadata?.source ?? config.name; - } - return hashValue(idValue); -} - -export function hashValue(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extension.ts b/apps/airiscode-cli/src/gemini-base/config/extension.ts deleted file mode 100644 index 2de86d334..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extension.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - MCPServerConfig, - ExtensionInstallMetadata, -} from '@airiscode/gemini-cli-core'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { INSTALL_METADATA_FILENAME } from './extensions/variables.js'; -import type { ExtensionSetting } from './extensions/extensionSettings.js'; - -/** - * Extension definition as written to disk in gemini-extension.json files. - * This should *not* be referenced outside of the logic for reading files. - * If information is required for manipulating extensions (load, unload, update) - * outside of the loading process that data needs to be stored on the - * GeminiCLIExtension class defined in Core. - */ -export interface ExtensionConfig { - name: string; - version: string; - mcpServers?: Record; - contextFileName?: string | string[]; - excludeTools?: string[]; - settings?: ExtensionSetting[]; -} - -export interface ExtensionUpdateInfo { - name: string; - originalVersion: string; - updatedVersion: string; -} - -export function loadInstallMetadata( - extensionDir: string, -): ExtensionInstallMetadata | undefined { - const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); - try { - const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); - const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; - return metadata; - } catch (_e) { - return undefined; - } -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/consent.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/consent.ts deleted file mode 100644 index 8cf80de73..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/consent.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { debugLogger } from '@airiscode/gemini-cli-core'; - -import type { ConfirmationRequest } from '../../ui/types.js'; -import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js'; -import type { ExtensionConfig } from '../extension.js'; - -export const INSTALL_WARNING_MESSAGE = - '**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**'; - -/** - * Requests consent from the user to perform an action, by reading a Y/n - * character from stdin. - * - * This should not be called from interactive mode as it will break the CLI. - * - * @param consentDescription The description of the thing they will be consenting to. - * @returns boolean, whether they consented or not. - */ -export async function requestConsentNonInteractive( - consentDescription: string, -): Promise { - debugLogger.log(consentDescription); - const result = await promptForConsentNonInteractive( - 'Do you want to continue? [Y/n]: ', - ); - return result; -} - -/** - * Requests consent from the user to perform an action, in interactive mode. - * - * This should not be called from non-interactive mode as it will not work. - * - * @param consentDescription The description of the thing they will be consenting to. - * @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI. - * @returns boolean, whether they consented or not. - */ -export async function requestConsentInteractive( - consentDescription: string, - addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, -): Promise { - return await promptForConsentInteractive( - consentDescription + '\n\nDo you want to continue?', - addExtensionUpdateConfirmationRequest, - ); -} - -/** - * Asks users a prompt and awaits for a y/n response on stdin. - * - * This should not be called from interactive mode as it will break the CLI. - * - * @param prompt A yes/no prompt to ask the user - * @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter. - */ -async function promptForConsentNonInteractive( - prompt: string, -): Promise { - const readline = await import('node:readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(prompt, (answer) => { - rl.close(); - resolve(['y', ''].includes(answer.trim().toLowerCase())); - }); - }); -} - -/** - * Asks users an interactive yes/no prompt. - * - * This should not be called from non-interactive mode as it will break the CLI. - * - * @param prompt A markdown prompt to ask the user - * @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request. - * @returns Whether or not the user answers yes. - */ -async function promptForConsentInteractive( - prompt: string, - addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, -): Promise { - return await new Promise((resolve) => { - addExtensionUpdateConfirmationRequest({ - prompt, - onConfirm: (resolvedConfirmed) => { - resolve(resolvedConfirmed); - }, - }); - }); -} - -/** - * Builds a consent string for installing an extension based on it's - * extensionConfig. - */ -function extensionConsentString(extensionConfig: ExtensionConfig): string { - const sanitizedConfig = escapeAnsiCtrlCodes( - extensionConfig, - ) as ExtensionConfig; - const output: string[] = []; - const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {}); - output.push(`Installing extension "${sanitizedConfig.name}".`); - output.push(INSTALL_WARNING_MESSAGE); - - if (mcpServerEntries.length) { - output.push('This extension will run the following MCP servers:'); - for (const [key, mcpServer] of mcpServerEntries) { - const isLocal = !!mcpServer.command; - const source = - mcpServer.httpUrl ?? - `${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`; - output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`); - } - } - if (sanitizedConfig.contextFileName) { - output.push( - `This extension will append info to your gemini.md context using ${sanitizedConfig.contextFileName}`, - ); - } - if (sanitizedConfig.excludeTools) { - output.push( - `This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`, - ); - } - return output.join('\n'); -} - -/** - * Requests consent from the user to install an extension (extensionConfig), if - * there is any difference between the consent string for `extensionConfig` and - * `previousExtensionConfig`. - * - * Always requests consent if previousExtensionConfig is null. - * - * Throws if the user does not consent. - */ -export async function maybeRequestConsentOrFail( - extensionConfig: ExtensionConfig, - requestConsent: (consent: string) => Promise, - previousExtensionConfig?: ExtensionConfig, -) { - const extensionConsent = extensionConsentString(extensionConfig); - if (previousExtensionConfig) { - const previousExtensionConsent = extensionConsentString( - previousExtensionConfig, - ); - if (previousExtensionConsent === extensionConsent) { - return; - } - } - if (!(await requestConsent(extensionConsent))) { - throw new Error(`Installation cancelled for "${extensionConfig.name}".`); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/extensionEnablement.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/extensionEnablement.ts deleted file mode 100644 index e445f9f4d..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/extensionEnablement.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import { coreEvents, type GeminiCLIExtension } from '@airiscode/gemini-cli-core'; -import { ExtensionStorage } from './storage.js'; - -export interface ExtensionEnablementConfig { - overrides: string[]; -} - -export interface AllExtensionsEnablementConfig { - [extensionName: string]: ExtensionEnablementConfig; -} - -export class Override { - constructor( - public baseRule: string, - public isDisable: boolean, - public includeSubdirs: boolean, - ) {} - - static fromInput(inputRule: string, includeSubdirs: boolean): Override { - const isDisable = inputRule.startsWith('!'); - let baseRule = isDisable ? inputRule.substring(1) : inputRule; - baseRule = ensureLeadingAndTrailingSlash(baseRule); - return new Override(baseRule, isDisable, includeSubdirs); - } - - static fromFileRule(fileRule: string): Override { - const isDisable = fileRule.startsWith('!'); - let baseRule = isDisable ? fileRule.substring(1) : fileRule; - const includeSubdirs = baseRule.endsWith('*'); - baseRule = includeSubdirs - ? baseRule.substring(0, baseRule.length - 1) - : baseRule; - return new Override(baseRule, isDisable, includeSubdirs); - } - - conflictsWith(other: Override): boolean { - if (this.baseRule === other.baseRule) { - return ( - this.includeSubdirs !== other.includeSubdirs || - this.isDisable !== other.isDisable - ); - } - return false; - } - - isEqualTo(other: Override): boolean { - return ( - this.baseRule === other.baseRule && - this.includeSubdirs === other.includeSubdirs && - this.isDisable === other.isDisable - ); - } - - asRegex(): RegExp { - return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`); - } - - isChildOf(parent: Override) { - if (!parent.includeSubdirs) { - return false; - } - return parent.asRegex().test(this.baseRule); - } - - output(): string { - return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`; - } - - matchesPath(path: string) { - return this.asRegex().test(path); - } -} - -const ensureLeadingAndTrailingSlash = function (dirPath: string): string { - // Normalize separators to forward slashes for consistent matching across platforms. - let result = dirPath.replace(/\\/g, '/'); - if (result.charAt(0) !== '/') { - result = '/' + result; - } - if (result.charAt(result.length - 1) !== '/') { - result = result + '/'; - } - return result; -}; - -/** - * Converts a glob pattern to a RegExp object. - * This is a simplified implementation that supports `*`. - * - * @param glob The glob pattern to convert. - * @returns A RegExp object. - */ -function globToRegex(glob: string): RegExp { - const regexString = glob - .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters - .replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group - - return new RegExp(`^${regexString}$`); -} - -export class ExtensionEnablementManager { - private configFilePath: string; - private configDir: string; - // If non-empty, this overrides all other extension configuration and enables - // only the ones in this list. - private enabledExtensionNamesOverride: string[]; - - constructor(enabledExtensionNames?: string[]) { - this.configDir = ExtensionStorage.getUserExtensionsDir(); - this.configFilePath = path.join( - this.configDir, - 'extension-enablement.json', - ); - this.enabledExtensionNamesOverride = - enabledExtensionNames?.map((name) => name.toLowerCase()) ?? []; - } - - validateExtensionOverrides(extensions: GeminiCLIExtension[]) { - for (const name of this.enabledExtensionNamesOverride) { - if (name === 'none') continue; - if ( - !extensions.some((ext) => ext.name.toLowerCase() === name.toLowerCase()) - ) { - coreEvents.emitFeedback('error', `Extension not found: ${name}`); - } - } - } - - /** - * Determines if an extension is enabled based on its name and the current - * path. The last matching rule in the overrides list wins. - * - * @param extensionName The name of the extension. - * @param currentPath The absolute path of the current working directory. - * @returns True if the extension is enabled, false otherwise. - */ - isEnabled(extensionName: string, currentPath: string): boolean { - // If we have a single override called 'none', this disables all extensions. - // Typically, this comes from the user passing `-e none`. - if ( - this.enabledExtensionNamesOverride.length === 1 && - this.enabledExtensionNamesOverride[0] === 'none' - ) { - return false; - } - - // If we have explicit overrides, only enable those extensions. - if (this.enabledExtensionNamesOverride.length > 0) { - // When checking against overrides ONLY, we use a case insensitive match. - // The override names are already lowercased in the constructor. - return this.enabledExtensionNamesOverride.includes( - extensionName.toLocaleLowerCase(), - ); - } - - // Otherwise, we use the configuration settings - const config = this.readConfig(); - const extensionConfig = config[extensionName]; - // Extensions are enabled by default. - let enabled = true; - const allOverrides = extensionConfig?.overrides ?? []; - for (const rule of allOverrides) { - const override = Override.fromFileRule(rule); - if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) { - enabled = !override.isDisable; - } - } - return enabled; - } - - readConfig(): AllExtensionsEnablementConfig { - try { - const content = fs.readFileSync(this.configFilePath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { - return {}; - } - coreEvents.emitFeedback( - 'error', - 'Failed to read extension enablement config.', - error, - ); - return {}; - } - } - - writeConfig(config: AllExtensionsEnablementConfig): void { - fs.mkdirSync(this.configDir, { recursive: true }); - fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2)); - } - - enable( - extensionName: string, - includeSubdirs: boolean, - scopePath: string, - ): void { - const config = this.readConfig(); - if (!config[extensionName]) { - config[extensionName] = { overrides: [] }; - } - const override = Override.fromInput(scopePath, includeSubdirs); - const overrides = config[extensionName].overrides.filter((rule) => { - const fileOverride = Override.fromFileRule(rule); - if ( - fileOverride.conflictsWith(override) || - fileOverride.isEqualTo(override) - ) { - return false; // Remove conflicts and equivalent values. - } - return !fileOverride.isChildOf(override); - }); - overrides.push(override.output()); - config[extensionName].overrides = overrides; - this.writeConfig(config); - } - - disable( - extensionName: string, - includeSubdirs: boolean, - scopePath: string, - ): void { - this.enable(extensionName, includeSubdirs, `!${scopePath}`); - } - - remove(extensionName: string): void { - const config = this.readConfig(); - if (config[extensionName]) { - delete config[extensionName]; - this.writeConfig(config); - } - } -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/extensionSettings.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/extensionSettings.ts deleted file mode 100644 index b8065fc16..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/extensionSettings.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as fsSync from 'node:fs'; -import * as dotenv from 'dotenv'; - -import { ExtensionStorage } from './storage.js'; -import type { ExtensionConfig } from '../extension.js'; - -import prompts from 'prompts'; -import { KeychainTokenStorage } from '@airiscode/gemini-cli-core'; - -export interface ExtensionSetting { - name: string; - description: string; - envVar: string; - // NOTE: If no value is set, this setting will be considered NOT sensitive. - sensitive?: boolean; -} - -const getKeychainStorageName = ( - extensionName: string, - extensionId: string, -): string => `Gemini CLI Extensions ${extensionName} ${extensionId}`; - -export async function maybePromptForSettings( - extensionConfig: ExtensionConfig, - extensionId: string, - requestSetting: (setting: ExtensionSetting) => Promise, - previousExtensionConfig?: ExtensionConfig, - previousSettings?: Record, -): Promise { - const { name: extensionName, settings } = extensionConfig; - if ( - (!settings || settings.length === 0) && - (!previousExtensionConfig?.settings || - previousExtensionConfig.settings.length === 0) - ) { - return; - } - const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); - const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionName, extensionId), - ); - - if (!settings || settings.length === 0) { - await clearSettings(envFilePath, keychain); - return; - } - - const settingsChanges = getSettingsChanges( - settings, - previousExtensionConfig?.settings ?? [], - ); - - const allSettings: Record = { ...(previousSettings ?? {}) }; - - for (const removedEnvSetting of settingsChanges.removeEnv) { - delete allSettings[removedEnvSetting.envVar]; - } - - for (const removedSensitiveSetting of settingsChanges.removeSensitive) { - await keychain.deleteSecret(removedSensitiveSetting.envVar); - } - - for (const setting of settingsChanges.promptForSensitive.concat( - settingsChanges.promptForEnv, - )) { - const answer = await requestSetting(setting); - allSettings[setting.envVar] = answer; - } - - const nonSensitiveSettings: Record = {}; - for (const setting of settings) { - const value = allSettings[setting.envVar]; - if (value === undefined) { - continue; - } - if (setting.sensitive) { - await keychain.setSecret(setting.envVar, value); - } else { - nonSensitiveSettings[setting.envVar] = value; - } - } - - let envContent = ''; - for (const [key, value] of Object.entries(nonSensitiveSettings)) { - envContent += `${key}=${value}\n`; - } - - await fs.writeFile(envFilePath, envContent); -} - -export async function promptForSetting( - setting: ExtensionSetting, -): Promise { - const response = await prompts({ - type: setting.sensitive ? 'password' : 'text', - name: 'value', - message: `${setting.name}\n${setting.description}`, - }); - return response.value; -} - -export async function getEnvContents( - extensionConfig: ExtensionConfig, - extensionId: string, -): Promise> { - if (!extensionConfig.settings || extensionConfig.settings.length === 0) { - return Promise.resolve({}); - } - const extensionStorage = new ExtensionStorage(extensionConfig.name); - const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionConfig.name, extensionId), - ); - let customEnv: Record = {}; - if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { - const envFile = fsSync.readFileSync( - extensionStorage.getEnvFilePath(), - 'utf-8', - ); - customEnv = dotenv.parse(envFile); - } - - if (extensionConfig.settings) { - for (const setting of extensionConfig.settings) { - if (setting.sensitive) { - const secret = await keychain.getSecret(setting.envVar); - if (secret) { - customEnv[setting.envVar] = secret; - } - } - } - } - return customEnv; -} - -interface settingsChanges { - promptForSensitive: ExtensionSetting[]; - removeSensitive: ExtensionSetting[]; - promptForEnv: ExtensionSetting[]; - removeEnv: ExtensionSetting[]; -} -function getSettingsChanges( - settings: ExtensionSetting[], - oldSettings: ExtensionSetting[], -): settingsChanges { - const isSameSetting = (a: ExtensionSetting, b: ExtensionSetting) => - a.envVar === b.envVar && (a.sensitive ?? false) === (b.sensitive ?? false); - - const sensitiveOld = oldSettings.filter((s) => s.sensitive ?? false); - const sensitiveNew = settings.filter((s) => s.sensitive ?? false); - const envOld = oldSettings.filter((s) => !(s.sensitive ?? false)); - const envNew = settings.filter((s) => !(s.sensitive ?? false)); - - return { - promptForSensitive: sensitiveNew.filter( - (s) => !sensitiveOld.some((old) => isSameSetting(s, old)), - ), - removeSensitive: sensitiveOld.filter( - (s) => !sensitiveNew.some((neu) => isSameSetting(s, neu)), - ), - promptForEnv: envNew.filter( - (s) => !envOld.some((old) => isSameSetting(s, old)), - ), - removeEnv: envOld.filter( - (s) => !envNew.some((neu) => isSameSetting(s, neu)), - ), - }; -} - -async function clearSettings( - envFilePath: string, - keychain: KeychainTokenStorage, -) { - if (fsSync.existsSync(envFilePath)) { - await fs.writeFile(envFilePath, ''); - } - if (!keychain.isAvailable()) { - return; - } - const secrets = await keychain.listSecrets(); - for (const secret of secrets) { - await keychain.deleteSecret(secret); - } - return; -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/github.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/github.ts deleted file mode 100644 index 655e1f704..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/github.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { simpleGit } from 'simple-git'; -import { getErrorMessage } from '../../utils/errors.js'; -import { - debugLogger, - type ExtensionInstallMetadata, - type GeminiCLIExtension, -} from '@airiscode/gemini-cli-core'; -import { ExtensionUpdateState } from '../../ui/state/extensions.js'; -import * as os from 'node:os'; -import * as https from 'node:https'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as tar from 'tar'; -import extract from 'extract-zip'; -import { fetchJson, getGitHubToken } from './github_fetch.js'; -import type { ExtensionManager } from '../extension-manager.js'; -import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; - -/** - * Clones a Git repository to a specified local path. - * @param installMetadata The metadata for the extension to install. - * @param destination The destination path to clone the repository to. - */ -export async function cloneFromGit( - installMetadata: ExtensionInstallMetadata, - destination: string, -): Promise { - try { - const git = simpleGit(destination); - let sourceUrl = installMetadata.source; - const token = getGitHubToken(); - if (token) { - try { - const parsedUrl = new URL(sourceUrl); - if ( - parsedUrl.protocol === 'https:' && - parsedUrl.hostname === 'github.com' - ) { - if (!parsedUrl.username) { - parsedUrl.username = token; - } - sourceUrl = parsedUrl.toString(); - } - } catch { - // If source is not a valid URL, we don't inject the token. - // We let git handle the source as is. - } - } - await git.clone(sourceUrl, './', ['--depth', '1']); - - const remotes = await git.getRemotes(true); - if (remotes.length === 0) { - throw new Error( - `Unable to find any remotes for repo ${installMetadata.source}`, - ); - } - - const refToFetch = installMetadata.ref || 'HEAD'; - - await git.fetch(remotes[0].name, refToFetch); - - // After fetching, checkout FETCH_HEAD to get the content of the fetched ref. - // This results in a detached HEAD state, which is fine for this purpose. - await git.checkout('FETCH_HEAD'); - } catch (error) { - throw new Error( - `Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`, - { - cause: error, - }, - ); - } -} - -export interface GithubRepoInfo { - owner: string; - repo: string; -} - -export function tryParseGithubUrl(source: string): GithubRepoInfo | null { - // First step in normalizing a github ssh URI to the https form. - if (source.startsWith('git@github.com:')) { - source = source.replace('git@github.com:', ''); - } - // Default to a github repo path, so `source` can be just an org/repo - const parsedUrl = URL.parse(source, 'https://github.com'); - if (!parsedUrl) { - throw new Error(`Invalid repo URL: ${source}`); - } - if (parsedUrl?.host !== 'github.com') { - return null; - } - // The pathname should be "/owner/repo". - const parts = parsedUrl?.pathname - .split('/') - // Remove the empty segments, fixes trailing and leading slashes - .filter((part) => part !== ''); - - if (parts?.length !== 2) { - throw new Error( - `Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`, - ); - } - const owner = parts[0]; - const repo = parts[1].replace('.git', ''); - - return { - owner, - repo, - }; -} - -export async function fetchReleaseFromGithub( - owner: string, - repo: string, - ref?: string, - allowPreRelease?: boolean, -): Promise { - if (ref) { - return await fetchJson( - `https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`, - ); - } - - if (!allowPreRelease) { - // Grab the release that is tagged as the "latest", github does not allow - // this to be a pre-release so we can blindly grab it. - try { - return await fetchJson( - `https://api.github.com/repos/${owner}/${repo}/releases/latest`, - ); - } catch (_) { - // This can fail if there is no release marked latest. In that case - // we want to just try the pre-release logic below. - } - } - - // If pre-releases are allowed, we just grab the most recent release. - const releases = await fetchJson( - `https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`, - ); - if (releases.length === 0) { - return null; - } - return releases[0]; -} - -export async function checkForExtensionUpdate( - extension: GeminiCLIExtension, - extensionManager: ExtensionManager, -): Promise { - const installMetadata = extension.installMetadata; - if (installMetadata?.type === 'local') { - const latestConfig = extensionManager.loadExtensionConfig( - installMetadata.source, - ); - if (!latestConfig) { - debugLogger.error( - `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, - ); - return ExtensionUpdateState.ERROR; - } - if (latestConfig.version !== extension.version) { - return ExtensionUpdateState.UPDATE_AVAILABLE; - } - return ExtensionUpdateState.UP_TO_DATE; - } - if ( - !installMetadata || - (installMetadata.type !== 'git' && - installMetadata.type !== 'github-release') - ) { - return ExtensionUpdateState.NOT_UPDATABLE; - } - try { - if (installMetadata.type === 'git') { - const git = simpleGit(extension.path); - const remotes = await git.getRemotes(true); - if (remotes.length === 0) { - debugLogger.error('No git remotes found.'); - return ExtensionUpdateState.ERROR; - } - const remoteUrl = remotes[0].refs.fetch; - if (!remoteUrl) { - debugLogger.error( - `No fetch URL found for git remote ${remotes[0].name}.`, - ); - return ExtensionUpdateState.ERROR; - } - - // Determine the ref to check on the remote. - const refToCheck = installMetadata.ref || 'HEAD'; - - const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); - - if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { - debugLogger.error(`Git ref ${refToCheck} not found.`); - return ExtensionUpdateState.ERROR; - } - - const remoteHash = lsRemoteOutput.split('\t')[0]; - const localHash = await git.revparse(['HEAD']); - - if (!remoteHash) { - debugLogger.error( - `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, - ); - return ExtensionUpdateState.ERROR; - } - if (remoteHash === localHash) { - return ExtensionUpdateState.UP_TO_DATE; - } - return ExtensionUpdateState.UPDATE_AVAILABLE; - } else { - const { source, releaseTag } = installMetadata; - if (!source) { - debugLogger.error(`No "source" provided for extension.`); - return ExtensionUpdateState.ERROR; - } - const repoInfo = tryParseGithubUrl(source); - if (!repoInfo) { - debugLogger.error( - `Source is not a valid GitHub repository for release checks: ${source}`, - ); - return ExtensionUpdateState.ERROR; - } - const { owner, repo } = repoInfo; - - const releaseData = await fetchReleaseFromGithub( - owner, - repo, - installMetadata.ref, - installMetadata.allowPreRelease, - ); - if (!releaseData) { - return ExtensionUpdateState.ERROR; - } - if (releaseData.tag_name !== releaseTag) { - return ExtensionUpdateState.UPDATE_AVAILABLE; - } - return ExtensionUpdateState.UP_TO_DATE; - } - } catch (error) { - debugLogger.error( - `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, - ); - return ExtensionUpdateState.ERROR; - } -} - -export type GitHubDownloadResult = - | { - tagName?: string; - type: 'git' | 'github-release'; - success: false; - failureReason: - | 'failed to fetch release data' - | 'no release data' - | 'no release asset found' - | 'failed to download asset' - | 'failed to extract asset' - | 'unknown'; - errorMessage: string; - } - | { - tagName?: string; - type: 'git' | 'github-release'; - success: true; - }; -export async function downloadFromGitHubRelease( - installMetadata: ExtensionInstallMetadata, - destination: string, - githubRepoInfo: GithubRepoInfo, -): Promise { - const { ref, allowPreRelease: preRelease } = installMetadata; - const { owner, repo } = githubRepoInfo; - let releaseData: GithubReleaseData | null = null; - - try { - try { - releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease); - if (!releaseData) { - return { - failureReason: 'no release data', - success: false, - type: 'github-release', - errorMessage: `No release data found for ${owner}/${repo} at tag ${ref}`, - }; - } - } catch (error) { - return { - failureReason: 'failed to fetch release data', - success: false, - type: 'github-release', - errorMessage: `Failed to fetch release data for ${owner}/${repo} at tag ${ref}: ${getErrorMessage(error)}`, - }; - } - - const asset = findReleaseAsset(releaseData.assets); - let archiveUrl: string | undefined; - let isTar = false; - let isZip = false; - let fileName: string | undefined; - - if (asset) { - archiveUrl = asset.url; - fileName = asset.name; - } else { - if (releaseData.tarball_url) { - archiveUrl = releaseData.tarball_url; - isTar = true; - } else if (releaseData.zipball_url) { - archiveUrl = releaseData.zipball_url; - isZip = true; - } - } - if (!archiveUrl) { - return { - failureReason: 'no release asset found', - success: false, - type: 'github-release', - tagName: releaseData.tag_name, - errorMessage: `No assets found for release with tag ${releaseData.tag_name}`, - }; - } - if (!fileName) { - fileName = path.basename(new URL(archiveUrl).pathname); - } - let downloadedAssetPath = path.join(destination, fileName); - if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) { - downloadedAssetPath += '.tar.gz'; - } else if (isZip && !downloadedAssetPath.endsWith('.zip')) { - downloadedAssetPath += '.zip'; - } - - try { - await downloadFile(archiveUrl, downloadedAssetPath); - } catch (error) { - return { - failureReason: 'failed to download asset', - success: false, - type: 'github-release', - tagName: releaseData.tag_name, - errorMessage: `Failed to download asset from ${archiveUrl}: ${getErrorMessage(error)}`, - }; - } - - try { - await extractFile(downloadedAssetPath, destination); - } catch (error) { - return { - failureReason: 'failed to extract asset', - success: false, - type: 'github-release', - tagName: releaseData.tag_name, - errorMessage: `Failed to extract asset from ${downloadedAssetPath}: ${getErrorMessage(error)}`, - }; - } - - // For regular github releases, the repository is put inside of a top level - // directory. In this case we should see exactly two file in the destination - // dir, the archive and the directory. If we see that, validate that the - // dir has a gemini extension configuration file and then move all files - // from the directory up one level into the destination directory. - const entries = await fs.promises.readdir(destination, { - withFileTypes: true, - }); - if (entries.length === 2) { - const lonelyDir = entries.find((entry) => entry.isDirectory()); - if ( - lonelyDir && - fs.existsSync( - path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME), - ) - ) { - const dirPathToExtract = path.join(destination, lonelyDir.name); - const extractedDirFiles = await fs.promises.readdir(dirPathToExtract); - for (const file of extractedDirFiles) { - await fs.promises.rename( - path.join(dirPathToExtract, file), - path.join(destination, file), - ); - } - await fs.promises.rmdir(dirPathToExtract); - } - } - - await fs.promises.unlink(downloadedAssetPath); - return { - tagName: releaseData.tag_name, - type: 'github-release', - success: true, - }; - } catch (error) { - return { - failureReason: 'unknown', - success: false, - type: 'github-release', - tagName: releaseData?.tag_name, - errorMessage: `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`, - }; - } -} - -interface GithubReleaseData { - assets: Asset[]; - tag_name: string; - tarball_url?: string; - zipball_url?: string; -} - -interface Asset { - name: string; - url: string; -} - -export function findReleaseAsset(assets: Asset[]): Asset | undefined { - const platform = os.platform(); - const arch = os.arch(); - - const platformArchPrefix = `${platform}.${arch}.`; - const platformPrefix = `${platform}.`; - - // Check for platform + architecture specific asset - const platformArchAsset = assets.find((asset) => - asset.name.toLowerCase().startsWith(platformArchPrefix), - ); - if (platformArchAsset) { - return platformArchAsset; - } - - // Check for platform specific asset - const platformAsset = assets.find((asset) => - asset.name.toLowerCase().startsWith(platformPrefix), - ); - if (platformAsset) { - return platformAsset; - } - - // Check for generic asset if only one is available - const genericAsset = assets.find( - (asset) => - !asset.name.toLowerCase().includes('darwin') && - !asset.name.toLowerCase().includes('linux') && - !asset.name.toLowerCase().includes('win32'), - ); - if (assets.length === 1) { - return genericAsset; - } - - return undefined; -} - -async function downloadFile(url: string, dest: string): Promise { - const headers: { - 'User-agent': string; - Accept: string; - Authorization?: string; - } = { - 'User-agent': 'gemini-cli', - Accept: 'application/octet-stream', - }; - const token = getGitHubToken(); - if (token) { - headers.Authorization = `token ${token}`; - } - return new Promise((resolve, reject) => { - https - .get(url, { headers }, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - downloadFile(res.headers.location!, dest).then(resolve).catch(reject); - return; - } - if (res.statusCode !== 200) { - return reject( - new Error(`Request failed with status code ${res.statusCode}`), - ); - } - const file = fs.createWriteStream(dest); - res.pipe(file); - file.on('finish', () => file.close(resolve as () => void)); - }) - .on('error', reject); - }); -} - -export async function extractFile(file: string, dest: string): Promise { - if (file.endsWith('.tar.gz')) { - await tar.x({ - file, - cwd: dest, - }); - } else if (file.endsWith('.zip')) { - await extract(file, { dir: dest }); - } else { - throw new Error(`Unsupported file extension for extraction: ${file}`); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/github_fetch.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/github_fetch.ts deleted file mode 100644 index a4f9d29b7..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/github_fetch.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as https from 'node:https'; - -export function getGitHubToken(): string | undefined { - return process.env['GITHUB_TOKEN']; -} - -export async function fetchJson( - url: string, - redirectCount: number = 0, -): Promise { - const headers: { 'User-Agent': string; Authorization?: string } = { - 'User-Agent': 'gemini-cli', - }; - const token = getGitHubToken(); - if (token) { - headers.Authorization = `token ${token}`; - } - return new Promise((resolve, reject) => { - https - .get(url, { headers }, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - if (redirectCount >= 10) { - return reject(new Error('Too many redirects')); - } - if (!res.headers.location) { - return reject(new Error('No location header in redirect response')); - } - fetchJson(res.headers.location!, redirectCount++) - .then(resolve) - .catch(reject); - return; - } - if (res.statusCode !== 200) { - return reject( - new Error(`Request failed with status code ${res.statusCode}`), - ); - } - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - const data = Buffer.concat(chunks).toString(); - resolve(JSON.parse(data) as T); - }); - }) - .on('error', reject); - }); -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/storage.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/storage.ts deleted file mode 100644 index 72eda7819..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/storage.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as path from 'node:path'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import { - EXTENSION_SETTINGS_FILENAME, - EXTENSIONS_CONFIG_FILENAME, -} from './variables.js'; -import { Storage } from '@airiscode/gemini-cli-core'; - -export class ExtensionStorage { - private readonly extensionName: string; - - constructor(extensionName: string) { - this.extensionName = extensionName; - } - - getExtensionDir(): string { - return path.join( - ExtensionStorage.getUserExtensionsDir(), - this.extensionName, - ); - } - - getConfigPath(): string { - return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); - } - - getEnvFilePath(): string { - return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME); - } - - static getUserExtensionsDir(): string { - return new Storage(os.homedir()).getExtensionsDir(); - } - - static async createTmpDir(): Promise { - return await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'gemini-extension'), - ); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/update.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/update.ts deleted file mode 100644 index 7545cd1c2..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/update.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - type ExtensionUpdateAction, - ExtensionUpdateState, - type ExtensionUpdateStatus, -} from '../../ui/state/extensions.js'; -import { loadInstallMetadata } from '../extension.js'; -import { checkForExtensionUpdate } from './github.js'; -import { debugLogger, type GeminiCLIExtension } from '@airiscode/gemini-cli-core'; -import * as fs from 'node:fs'; -import { getErrorMessage } from '../../utils/errors.js'; -import { copyExtension, type ExtensionManager } from '../extension-manager.js'; -import { ExtensionStorage } from './storage.js'; - -export interface ExtensionUpdateInfo { - name: string; - originalVersion: string; - updatedVersion: string; -} - -export async function updateExtension( - extension: GeminiCLIExtension, - extensionManager: ExtensionManager, - currentState: ExtensionUpdateState, - dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void, - enableExtensionReloading?: boolean, -): Promise { - if (currentState === ExtensionUpdateState.UPDATING) { - return undefined; - } - dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extension.name, state: ExtensionUpdateState.UPDATING }, - }); - const installMetadata = loadInstallMetadata(extension.path); - - if (!installMetadata?.type) { - dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, - }); - throw new Error( - `Extension ${extension.name} cannot be updated, type is unknown.`, - ); - } - if (installMetadata?.type === 'link') { - dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extension.name, state: ExtensionUpdateState.UP_TO_DATE }, - }); - throw new Error(`Extension is linked so does not need to be updated`); - } - const originalVersion = extension.version; - - const tempDir = await ExtensionStorage.createTmpDir(); - try { - const previousExtensionConfig = extensionManager.loadExtensionConfig( - extension.path, - ); - let updatedExtension: GeminiCLIExtension; - try { - updatedExtension = await extensionManager.installOrUpdateExtension( - installMetadata, - previousExtensionConfig, - ); - } catch (e) { - dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, - }); - throw new Error( - `Updated extension not found after installation, got error:\n${e}`, - ); - } - const updatedVersion = updatedExtension.version; - dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { - name: extension.name, - state: enableExtensionReloading - ? ExtensionUpdateState.UPDATED - : ExtensionUpdateState.UPDATED_NEEDS_RESTART, - }, - }); - return { - name: extension.name, - originalVersion, - updatedVersion, - }; - } catch (e) { - debugLogger.error( - `Error updating extension, rolling back. ${getErrorMessage(e)}`, - ); - dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, - }); - await copyExtension(tempDir, extension.path); - throw e; - } finally { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - } -} - -export async function updateAllUpdatableExtensions( - extensions: GeminiCLIExtension[], - extensionsState: Map, - extensionManager: ExtensionManager, - dispatch: (action: ExtensionUpdateAction) => void, - enableExtensionReloading?: boolean, -): Promise { - return ( - await Promise.all( - extensions - .filter( - (extension) => - extensionsState.get(extension.name)?.status === - ExtensionUpdateState.UPDATE_AVAILABLE, - ) - .map((extension) => - updateExtension( - extension, - extensionManager, - extensionsState.get(extension.name)!.status, - dispatch, - enableExtensionReloading, - ), - ), - ) - ).filter((updateInfo) => !!updateInfo); -} - -export interface ExtensionUpdateCheckResult { - state: ExtensionUpdateState; - error?: string; -} - -export async function checkForAllExtensionUpdates( - extensions: GeminiCLIExtension[], - extensionManager: ExtensionManager, - dispatch: (action: ExtensionUpdateAction) => void, -): Promise { - dispatch({ type: 'BATCH_CHECK_START' }); - try { - const promises: Array> = []; - for (const extension of extensions) { - if (!extension.installMetadata) { - dispatch({ - type: 'SET_STATE', - payload: { - name: extension.name, - state: ExtensionUpdateState.NOT_UPDATABLE, - }, - }); - continue; - } - dispatch({ - type: 'SET_STATE', - payload: { - name: extension.name, - state: ExtensionUpdateState.CHECKING_FOR_UPDATES, - }, - }); - promises.push( - checkForExtensionUpdate(extension, extensionManager).then((state) => - dispatch({ - type: 'SET_STATE', - payload: { name: extension.name, state }, - }), - ), - ); - } - await Promise.all(promises); - } finally { - dispatch({ type: 'BATCH_CHECK_END' }); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/config/extensions/variables.ts b/apps/airiscode-cli/src/gemini-base/config/extensions/variables.ts deleted file mode 100644 index ddc689671..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/extensions/variables.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as path from 'node:path'; -import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; -import { GEMINI_DIR } from '@airiscode/gemini-cli-core'; - -export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); -export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; -export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; -export const EXTENSION_SETTINGS_FILENAME = '.env'; - -export type JsonObject = { [key: string]: JsonValue }; -export type JsonArray = JsonValue[]; -export type JsonValue = - | string - | number - | boolean - | null - | JsonObject - | JsonArray; - -export type VariableContext = { - [key in keyof typeof VARIABLE_SCHEMA]?: string; -}; - -export function validateVariables( - variables: VariableContext, - schema: VariableSchema, -) { - for (const key in schema) { - const definition = schema[key]; - if (definition.required && !variables[key as keyof VariableContext]) { - throw new Error(`Missing required variable: ${key}`); - } - } -} - -export function hydrateString(str: string, context: VariableContext): string { - validateVariables(context, VARIABLE_SCHEMA); - const regex = /\${(.*?)}/g; - return str.replace(regex, (match, key) => - context[key as keyof VariableContext] == null - ? match - : (context[key as keyof VariableContext] as string), - ); -} - -export function recursivelyHydrateStrings( - obj: JsonValue, - values: VariableContext, -): JsonValue { - if (typeof obj === 'string') { - return hydrateString(obj, values); - } - if (Array.isArray(obj)) { - return obj.map((item) => recursivelyHydrateStrings(item, values)); - } - if (typeof obj === 'object' && obj !== null) { - const newObj: JsonObject = {}; - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - newObj[key] = recursivelyHydrateStrings(obj[key], values); - } - } - return newObj; - } - return obj; -} diff --git a/apps/airiscode-cli/src/gemini-base/config/keyBindings.ts b/apps/airiscode-cli/src/gemini-base/config/keyBindings.ts deleted file mode 100644 index f166d0427..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/keyBindings.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Command enum for all available keyboard shortcuts - */ -export enum Command { - // Basic bindings - RETURN = 'return', - ESCAPE = 'escape', - - // Cursor movement - HOME = 'home', - END = 'end', - - // Text deletion - KILL_LINE_RIGHT = 'killLineRight', - KILL_LINE_LEFT = 'killLineLeft', - CLEAR_INPUT = 'clearInput', - DELETE_WORD_BACKWARD = 'deleteWordBackward', - - // Screen control - CLEAR_SCREEN = 'clearScreen', - - // History navigation - HISTORY_UP = 'historyUp', - HISTORY_DOWN = 'historyDown', - NAVIGATION_UP = 'navigationUp', - NAVIGATION_DOWN = 'navigationDown', - - // Dialog navigation - DIALOG_NAVIGATION_UP = 'dialogNavigationUp', - DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown', - - // Auto-completion - ACCEPT_SUGGESTION = 'acceptSuggestion', - COMPLETION_UP = 'completionUp', - COMPLETION_DOWN = 'completionDown', - - // Text input - SUBMIT = 'submit', - NEWLINE = 'newline', - - // External tools - OPEN_EXTERNAL_EDITOR = 'openExternalEditor', - PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage', - - // App level bindings - SHOW_ERROR_DETAILS = 'showErrorDetails', - SHOW_FULL_TODOS = 'showFullTodos', - TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', - TOGGLE_MARKDOWN = 'toggleMarkdown', - TOGGLE_COPY_MODE = 'toggleCopyMode', - QUIT = 'quit', - EXIT = 'exit', - SHOW_MORE_LINES = 'showMoreLines', - - // Shell commands - REVERSE_SEARCH = 'reverseSearch', - SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', - ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', - TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus', - - // Suggestion expansion - EXPAND_SUGGESTION = 'expandSuggestion', - COLLAPSE_SUGGESTION = 'collapseSuggestion', -} - -/** - * Data-driven key binding structure for user configuration - */ -export interface KeyBinding { - /** The key name (e.g., 'a', 'return', 'tab', 'escape') */ - key?: string; - /** The key sequence (e.g., '\x18' for Ctrl+X) - alternative to key name */ - sequence?: string; - /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - ctrl?: boolean; - /** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - shift?: boolean; - /** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - command?: boolean; - /** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */ - paste?: boolean; -} - -/** - * Configuration type mapping commands to their key bindings - */ -export type KeyBindingConfig = { - readonly [C in Command]: readonly KeyBinding[]; -}; - -/** - * Default key binding configuration - * Matches the original hard-coded logic exactly - */ -export const defaultKeyBindings: KeyBindingConfig = { - // Basic bindings - [Command.RETURN]: [{ key: 'return' }], - [Command.ESCAPE]: [{ key: 'escape' }], - - // Cursor movement - [Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }], - [Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }], - - // Text deletion - [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], - [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], - [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], - // Added command (meta/alt/option) for mac compatibility - [Command.DELETE_WORD_BACKWARD]: [ - { key: 'backspace', ctrl: true }, - { key: 'backspace', command: true }, - ], - - // Screen control - [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], - - // History navigation - [Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }], - [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }], - [Command.NAVIGATION_UP]: [{ key: 'up', shift: false }], - [Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }], - - // Dialog navigation - // Navigation shortcuts appropriate for dialogs where we do not need to accept - // text input. - [Command.DIALOG_NAVIGATION_UP]: [ - { key: 'up', shift: false }, - { key: 'k', shift: false }, - ], - [Command.DIALOG_NAVIGATION_DOWN]: [ - { key: 'down', shift: false }, - { key: 'j', shift: false }, - ], - - // Auto-completion - [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], - // Completion navigation (arrow or Ctrl+P/N) - [Command.COMPLETION_UP]: [ - { key: 'up', shift: false }, - { key: 'p', ctrl: true, shift: false }, - ], - [Command.COMPLETION_DOWN]: [ - { key: 'down', shift: false }, - { key: 'n', ctrl: true, shift: false }, - ], - - // Text input - // Must also exclude shift to allow shift+enter for newline - [Command.SUBMIT]: [ - { - key: 'return', - ctrl: false, - command: false, - paste: false, - shift: false, - }, - ], - // Split into multiple data-driven bindings - // Now also includes shift+enter for multi-line input - [Command.NEWLINE]: [ - { key: 'return', ctrl: true }, - { key: 'return', command: true }, - { key: 'return', paste: true }, - { key: 'return', shift: true }, - { key: 'j', ctrl: true }, - ], - - // External tools - [Command.OPEN_EXTERNAL_EDITOR]: [ - { key: 'x', ctrl: true }, - { sequence: '\x18', ctrl: true }, - ], - [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], - - // App level bindings - [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], - [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], - [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], - [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], - [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], - [Command.QUIT]: [{ key: 'c', ctrl: true }], - [Command.EXIT]: [{ key: 'd', ctrl: true }], - [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], - - // Shell commands - [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], - // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste - [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], - [Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }], - - // Suggestion expansion - [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], - [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], -}; - -interface CommandCategory { - readonly title: string; - readonly commands: readonly Command[]; -} - -/** - * Presentation metadata for grouping commands in documentation or UI. - */ -export const commandCategories: readonly CommandCategory[] = [ - { - title: 'Basic Controls', - commands: [Command.RETURN, Command.ESCAPE], - }, - { - title: 'Cursor Movement', - commands: [Command.HOME, Command.END], - }, - { - title: 'Editing', - commands: [ - Command.KILL_LINE_RIGHT, - Command.KILL_LINE_LEFT, - Command.CLEAR_INPUT, - Command.DELETE_WORD_BACKWARD, - ], - }, - { - title: 'Screen Control', - commands: [Command.CLEAR_SCREEN], - }, - { - title: 'History & Search', - commands: [ - Command.HISTORY_UP, - Command.HISTORY_DOWN, - Command.REVERSE_SEARCH, - Command.SUBMIT_REVERSE_SEARCH, - Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, - ], - }, - { - title: 'Navigation', - commands: [ - Command.NAVIGATION_UP, - Command.NAVIGATION_DOWN, - Command.DIALOG_NAVIGATION_UP, - Command.DIALOG_NAVIGATION_DOWN, - ], - }, - { - title: 'Suggestions & Completions', - commands: [ - Command.ACCEPT_SUGGESTION, - Command.COMPLETION_UP, - Command.COMPLETION_DOWN, - Command.EXPAND_SUGGESTION, - Command.COLLAPSE_SUGGESTION, - ], - }, - { - title: 'Text Input', - commands: [Command.SUBMIT, Command.NEWLINE], - }, - { - title: 'External Tools', - commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD_IMAGE], - }, - { - title: 'App Controls', - commands: [ - Command.SHOW_ERROR_DETAILS, - Command.SHOW_FULL_TODOS, - Command.TOGGLE_IDE_CONTEXT_DETAIL, - Command.TOGGLE_MARKDOWN, - Command.TOGGLE_COPY_MODE, - Command.SHOW_MORE_LINES, - Command.TOGGLE_SHELL_INPUT_FOCUS, - ], - }, - { - title: 'Session Control', - commands: [Command.QUIT, Command.EXIT], - }, -]; - -/** - * Human-readable descriptions for each command, used in docs/tooling. - */ -export const commandDescriptions: Readonly> = { - [Command.RETURN]: 'Confirm the current selection or choice.', - [Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.', - [Command.HOME]: 'Move the cursor to the start of the line.', - [Command.END]: 'Move the cursor to the end of the line.', - [Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.', - [Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.', - [Command.CLEAR_INPUT]: 'Clear all text in the input field.', - [Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.', - [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', - [Command.HISTORY_UP]: 'Show the previous entry in history.', - [Command.HISTORY_DOWN]: 'Show the next entry in history.', - [Command.NAVIGATION_UP]: 'Move selection up in lists.', - [Command.NAVIGATION_DOWN]: 'Move selection down in lists.', - [Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.', - [Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.', - [Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.', - [Command.COMPLETION_UP]: 'Move to the previous completion option.', - [Command.COMPLETION_DOWN]: 'Move to the next completion option.', - [Command.SUBMIT]: 'Submit the current prompt.', - [Command.NEWLINE]: 'Insert a newline without submitting.', - [Command.OPEN_EXTERNAL_EDITOR]: - 'Open the current prompt in an external editor.', - [Command.PASTE_CLIPBOARD_IMAGE]: 'Paste an image from the clipboard.', - [Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.', - [Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.', - [Command.TOGGLE_IDE_CONTEXT_DETAIL]: 'Toggle IDE context details.', - [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', - [Command.TOGGLE_COPY_MODE]: - 'Toggle copy mode when the terminal is using the alternate buffer.', - [Command.QUIT]: 'Cancel the current request or quit the CLI.', - [Command.EXIT]: 'Exit the CLI when the input buffer is empty.', - [Command.SHOW_MORE_LINES]: - 'Expand a height-constrained response to show additional lines.', - [Command.REVERSE_SEARCH]: 'Start reverse search through history.', - [Command.SUBMIT_REVERSE_SEARCH]: 'Insert the selected reverse-search match.', - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: - 'Accept a suggestion while reverse searching.', - [Command.TOGGLE_SHELL_INPUT_FOCUS]: - 'Toggle focus between the shell and Gemini input.', - [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', - [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', -}; diff --git a/apps/airiscode-cli/src/gemini-base/config/policy.ts b/apps/airiscode-cli/src/gemini-base/config/policy.ts deleted file mode 100644 index 565caf2f4..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/policy.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - type PolicyEngineConfig, - type ApprovalMode, - type PolicyEngine, - type MessageBus, - type PolicySettings, - createPolicyEngineConfig as createCorePolicyEngineConfig, - createPolicyUpdater as createCorePolicyUpdater, -} from '@airiscode/gemini-cli-core'; -import { type Settings } from './settings.js'; - -export async function createPolicyEngineConfig( - settings: Settings, - approvalMode: ApprovalMode, -): Promise { - // Explicitly construct PolicySettings from Settings to ensure type safety - // and avoid accidental leakage of other settings properties. - const policySettings: PolicySettings = { - mcp: settings.mcp, - tools: settings.tools, - mcpServers: settings.mcpServers, - }; - - return createCorePolicyEngineConfig(policySettings, approvalMode); -} - -export function createPolicyUpdater( - policyEngine: PolicyEngine, - messageBus: MessageBus, -) { - return createCorePolicyUpdater(policyEngine, messageBus); -} diff --git a/apps/airiscode-cli/src/gemini-base/config/settingPaths.ts b/apps/airiscode-cli/src/gemini-base/config/settingPaths.ts deleted file mode 100644 index 9ba046d9f..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/settingPaths.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -export const SettingPaths = { - General: { - PreferredEditor: 'general.preferredEditor', - }, -} as const; diff --git a/apps/airiscode-cli/src/gemini-base/config/settings.ts b/apps/airiscode-cli/src/gemini-base/config/settings.ts deleted file mode 100644 index e1185a193..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/settings.ts +++ /dev/null @@ -1,836 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { homedir, platform } from 'node:os'; -import * as dotenv from 'dotenv'; -import process from 'node:process'; -import { - debugLogger, - FatalConfigError, - GEMINI_DIR, - getErrorMessage, - Storage, - coreEvents, -} from '@airiscode/gemini-cli-core'; -import stripJsonComments from 'strip-json-comments'; -import { DefaultLight } from '../ui/themes/default-light.js'; -import { DefaultDark } from '../ui/themes/default.js'; -import { isWorkspaceTrusted } from './trustedFolders.js'; -import { - type Settings, - type MemoryImportFormat, - type MergeStrategy, - type SettingsSchema, - type SettingDefinition, - getSettingsSchema, -} from './settingsSchema.js'; -import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; -import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; -import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; -import type { ExtensionManager } from './extension-manager.js'; -import { SettingPaths } from './settingPaths.js'; - -function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { - let current: SettingDefinition | undefined = undefined; - let currentSchema: SettingsSchema | undefined = getSettingsSchema(); - - for (const key of path) { - if (!currentSchema || !currentSchema[key]) { - return undefined; - } - current = currentSchema[key]; - currentSchema = current.properties; - } - - return current?.mergeStrategy; -} - -export type { Settings, MemoryImportFormat }; - -export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); -export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); -export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; - -const MIGRATE_V2_OVERWRITE = true; - -const MIGRATION_MAP: Record = { - accessibility: 'ui.accessibility', - allowedTools: 'tools.allowed', - allowMCPServers: 'mcp.allowed', - autoAccept: 'tools.autoAccept', - autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', - bugCommand: 'advanced.bugCommand', - chatCompression: 'model.compressionThreshold', - checkpointing: 'general.checkpointing', - coreTools: 'tools.core', - contextFileName: 'context.fileName', - customThemes: 'ui.customThemes', - customWittyPhrases: 'ui.customWittyPhrases', - debugKeystrokeLogging: 'general.debugKeystrokeLogging', - disableAutoUpdate: 'general.disableAutoUpdate', - disableUpdateNag: 'general.disableUpdateNag', - dnsResolutionOrder: 'advanced.dnsResolutionOrder', - enableMessageBusIntegration: 'tools.enableMessageBusIntegration', - enableHooks: 'tools.enableHooks', - enablePromptCompletion: 'general.enablePromptCompletion', - enforcedAuthType: 'security.auth.enforcedType', - excludeTools: 'tools.exclude', - excludeMCPServers: 'mcp.excluded', - excludedProjectEnvVars: 'advanced.excludedEnvVars', - extensionManagement: 'experimental.extensionManagement', - extensions: 'extensions', - fileFiltering: 'context.fileFiltering', - folderTrustFeature: 'security.folderTrust.featureEnabled', - folderTrust: 'security.folderTrust.enabled', - hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', - hideWindowTitle: 'ui.hideWindowTitle', - showStatusInTitle: 'ui.showStatusInTitle', - hideTips: 'ui.hideTips', - hideBanner: 'ui.hideBanner', - hideFooter: 'ui.hideFooter', - hideCWD: 'ui.footer.hideCWD', - hideSandboxStatus: 'ui.footer.hideSandboxStatus', - hideModelInfo: 'ui.footer.hideModelInfo', - hideContextSummary: 'ui.hideContextSummary', - showMemoryUsage: 'ui.showMemoryUsage', - showLineNumbers: 'ui.showLineNumbers', - showCitations: 'ui.showCitations', - ideMode: 'ide.enabled', - includeDirectories: 'context.includeDirectories', - loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', - maxSessionTurns: 'model.maxSessionTurns', - mcpServers: 'mcpServers', - mcpServerCommand: 'mcp.serverCommand', - memoryImportFormat: 'context.importFormat', - memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs', - model: 'model.name', - preferredEditor: SettingPaths.General.PreferredEditor, - retryFetchErrors: 'general.retryFetchErrors', - sandbox: 'tools.sandbox', - selectedAuthType: 'security.auth.selectedType', - enableInteractiveShell: 'tools.shell.enableInteractiveShell', - shellPager: 'tools.shell.pager', - shellShowColor: 'tools.shell.showColor', - skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', - summarizeToolOutput: 'model.summarizeToolOutput', - telemetry: 'telemetry', - theme: 'ui.theme', - toolDiscoveryCommand: 'tools.discoveryCommand', - toolCallCommand: 'tools.callCommand', - usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', - useExternalAuth: 'security.auth.useExternal', - useRipgrep: 'tools.useRipgrep', - vimMode: 'general.vimMode', -}; - -export function getSystemSettingsPath(): string { - if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { - return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; - } - if (platform() === 'darwin') { - return '/Library/Application Support/GeminiCli/settings.json'; - } else if (platform() === 'win32') { - return 'C:\\ProgramData\\gemini-cli\\settings.json'; - } else { - return '/etc/gemini-cli/settings.json'; - } -} - -export function getSystemDefaultsPath(): string { - if (process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']) { - return process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']; - } - return path.join( - path.dirname(getSystemSettingsPath()), - 'system-defaults.json', - ); -} - -export type { DnsResolutionOrder } from './settingsSchema.js'; - -export enum SettingScope { - User = 'User', - Workspace = 'Workspace', - System = 'System', - SystemDefaults = 'SystemDefaults', - // Note that this scope is not supported in the settings dialog at this time, - // it is only supported for extensions. - Session = 'Session', -} - -/** - * A type representing the settings scopes that are supported for LoadedSettings. - */ -export type LoadableSettingScope = - | SettingScope.User - | SettingScope.Workspace - | SettingScope.System - | SettingScope.SystemDefaults; - -/** - * The actual values of the loadable settings scopes. - */ -const _loadableSettingScopes = [ - SettingScope.User, - SettingScope.Workspace, - SettingScope.System, - SettingScope.SystemDefaults, -]; - -/** - * A type guard function that checks if `scope` is a loadable settings scope, - * and allows promotion to the `LoadableSettingsScope` type based on the result. - */ -export function isLoadableSettingScope( - scope: SettingScope, -): scope is LoadableSettingScope { - return _loadableSettingScopes.includes(scope); -} - -export interface CheckpointingSettings { - enabled?: boolean; -} - -export interface SummarizeToolOutputSettings { - tokenBudget?: number; -} - -export interface AccessibilitySettings { - disableLoadingPhrases?: boolean; - screenReader?: boolean; -} - -export interface SessionRetentionSettings { - /** Enable automatic session cleanup */ - enabled?: boolean; - - /** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */ - maxAge?: string; - - /** Alternative: Maximum number of sessions to keep (most recent) */ - maxCount?: number; - - /** Minimum retention period (safety limit, defaults to "1d") */ - minRetention?: string; -} - -export interface SettingsError { - message: string; - path: string; -} - -export interface SettingsFile { - settings: Settings; - originalSettings: Settings; - path: string; - rawJson?: string; -} - -function setNestedProperty( - obj: Record, - path: string, - value: unknown, -) { - const keys = path.split('.'); - const lastKey = keys.pop(); - if (!lastKey) return; - - let current: Record = obj; - for (const key of keys) { - if (current[key] === undefined) { - current[key] = {}; - } - const next = current[key]; - if (typeof next === 'object' && next !== null) { - current = next as Record; - } else { - // This path is invalid, so we stop. - return; - } - } - current[lastKey] = value; -} - -export function needsMigration(settings: Record): boolean { - // A file needs migration if it contains any top-level key that is moved to a - // nested location in V2. - const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => { - if (v1Key === v2Path || !(v1Key in settings)) { - return false; - } - // If a key exists that is both a V1 key and a V2 container (like 'model'), - // we need to check the type. If it's an object, it's a V2 container and not - // a V1 key that needs migration. - if ( - KNOWN_V2_CONTAINERS.has(v1Key) && - typeof settings[v1Key] === 'object' && - settings[v1Key] !== null - ) { - return false; - } - return true; - }); - - return hasV1Keys; -} - -function migrateSettingsToV2( - flatSettings: Record, -): Record | null { - if (!needsMigration(flatSettings)) { - return null; - } - - const v2Settings: Record = {}; - const flatKeys = new Set(Object.keys(flatSettings)); - - for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { - if (flatKeys.has(oldKey)) { - setNestedProperty(v2Settings, newPath, flatSettings[oldKey]); - flatKeys.delete(oldKey); - } - } - - // Preserve mcpServers at the top level - if (flatSettings['mcpServers']) { - v2Settings['mcpServers'] = flatSettings['mcpServers']; - flatKeys.delete('mcpServers'); - } - - // Carry over any unrecognized keys - for (const remainingKey of flatKeys) { - const existingValue = v2Settings[remainingKey]; - const newValue = flatSettings[remainingKey]; - - if ( - typeof existingValue === 'object' && - existingValue !== null && - !Array.isArray(existingValue) && - typeof newValue === 'object' && - newValue !== null && - !Array.isArray(newValue) - ) { - const pathAwareGetStrategy = (path: string[]) => - getMergeStrategyForPath([remainingKey, ...path]); - v2Settings[remainingKey] = customDeepMerge( - pathAwareGetStrategy, - {}, - newValue as MergeableObject, - existingValue as MergeableObject, - ); - } else { - v2Settings[remainingKey] = newValue; - } - } - - return v2Settings; -} - -function getNestedProperty( - obj: Record, - path: string, -): unknown { - const keys = path.split('.'); - let current: unknown = obj; - for (const key of keys) { - if (typeof current !== 'object' || current === null || !(key in current)) { - return undefined; - } - current = (current as Record)[key]; - } - return current; -} - -const REVERSE_MIGRATION_MAP: Record = Object.fromEntries( - Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]), -); - -// Dynamically determine the top-level keys from the V2 settings structure. -const KNOWN_V2_CONTAINERS = new Set( - Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), -); - -export function migrateSettingsToV1( - v2Settings: Record, -): Record { - const v1Settings: Record = {}; - const v2Keys = new Set(Object.keys(v2Settings)); - - for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) { - const value = getNestedProperty(v2Settings, newPath); - if (value !== undefined) { - v1Settings[oldKey] = value; - v2Keys.delete(newPath.split('.')[0]); - } - } - - // Preserve mcpServers at the top level - if (v2Settings['mcpServers']) { - v1Settings['mcpServers'] = v2Settings['mcpServers']; - v2Keys.delete('mcpServers'); - } - - // Carry over any unrecognized keys - for (const remainingKey of v2Keys) { - const value = v2Settings[remainingKey]; - if (value === undefined) { - continue; - } - - // Don't carry over empty objects that were just containers for migrated settings. - if ( - KNOWN_V2_CONTAINERS.has(remainingKey) && - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.keys(value).length === 0 - ) { - continue; - } - - v1Settings[remainingKey] = value; - } - - return v1Settings; -} - -function mergeSettings( - system: Settings, - systemDefaults: Settings, - user: Settings, - workspace: Settings, - isTrusted: boolean, -): Settings { - const safeWorkspace = isTrusted ? workspace : ({} as Settings); - - // Settings are merged with the following precedence (last one wins for - // single values): - // 1. System Defaults - // 2. User Settings - // 3. Workspace Settings - // 4. System Settings (as overrides) - return customDeepMerge( - getMergeStrategyForPath, - {}, // Start with an empty object - systemDefaults, - user, - safeWorkspace, - system, - ) as Settings; -} - -export class LoadedSettings { - constructor( - system: SettingsFile, - systemDefaults: SettingsFile, - user: SettingsFile, - workspace: SettingsFile, - isTrusted: boolean, - migratedInMemoryScopes: Set, - ) { - this.system = system; - this.systemDefaults = systemDefaults; - this.user = user; - this.workspace = workspace; - this.isTrusted = isTrusted; - this.migratedInMemoryScopes = migratedInMemoryScopes; - this._merged = this.computeMergedSettings(); - } - - readonly system: SettingsFile; - readonly systemDefaults: SettingsFile; - readonly user: SettingsFile; - readonly workspace: SettingsFile; - readonly isTrusted: boolean; - readonly migratedInMemoryScopes: Set; - - private _merged: Settings; - - get merged(): Settings { - return this._merged; - } - - private computeMergedSettings(): Settings { - return mergeSettings( - this.system.settings, - this.systemDefaults.settings, - this.user.settings, - this.workspace.settings, - this.isTrusted, - ); - } - - forScope(scope: LoadableSettingScope): SettingsFile { - switch (scope) { - case SettingScope.User: - return this.user; - case SettingScope.Workspace: - return this.workspace; - case SettingScope.System: - return this.system; - case SettingScope.SystemDefaults: - return this.systemDefaults; - default: - throw new Error(`Invalid scope: ${scope}`); - } - } - - setValue(scope: LoadableSettingScope, key: string, value: unknown): void { - const settingsFile = this.forScope(scope); - setNestedProperty(settingsFile.settings, key, value); - setNestedProperty(settingsFile.originalSettings, key, value); - this._merged = this.computeMergedSettings(); - saveSettings(settingsFile); - } -} - -function findEnvFile(startDir: string): string | null { - let currentDir = path.resolve(startDir); - while (true) { - // prefer gemini-specific .env under GEMINI_DIR - const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); - if (fs.existsSync(geminiEnvPath)) { - return geminiEnvPath; - } - const envPath = path.join(currentDir, '.env'); - if (fs.existsSync(envPath)) { - return envPath; - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir || !parentDir) { - // check .env under home as fallback, again preferring gemini-specific .env - const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); - if (fs.existsSync(homeGeminiEnvPath)) { - return homeGeminiEnvPath; - } - const homeEnvPath = path.join(homedir(), '.env'); - if (fs.existsSync(homeEnvPath)) { - return homeEnvPath; - } - return null; - } - currentDir = parentDir; - } -} - -export function setUpCloudShellEnvironment(envFilePath: string | null): void { - // Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell: - // Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project - // set by the user using "gcloud config set project" we do not want to - // use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in - // one of the .env files, we set the Cloud Shell-specific default here. - if (envFilePath && fs.existsSync(envFilePath)) { - const envFileContent = fs.readFileSync(envFilePath); - const parsedEnv = dotenv.parse(envFileContent); - if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { - // .env file takes precedence in Cloud Shell - process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; - } else { - // If not in .env, set to default and override global - process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; - } - } else { - // If no .env file, set to default and override global - process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; - } -} - -export function loadEnvironment(settings: Settings): void { - const envFilePath = findEnvFile(process.cwd()); - - if (!isWorkspaceTrusted(settings).isTrusted) { - return; - } - - // Cloud Shell environment variable handling - if (process.env['CLOUD_SHELL'] === 'true') { - setUpCloudShellEnvironment(envFilePath); - } - - if (envFilePath) { - // Manually parse and load environment variables to handle exclusions correctly. - // This avoids modifying environment variables that were already set from the shell. - try { - const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); - const parsedEnv = dotenv.parse(envFileContent); - - const excludedVars = - settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; - const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); - - for (const key in parsedEnv) { - if (Object.hasOwn(parsedEnv, key)) { - // If it's a project .env file, skip loading excluded variables. - if (isProjectEnvFile && excludedVars.includes(key)) { - continue; - } - - // Load variable only if it's not already set in the environment. - if (!Object.hasOwn(process.env, key)) { - process.env[key] = parsedEnv[key]; - } - } - } - } catch (_e) { - // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. - } - } -} - -/** - * Loads settings from user and workspace directories. - * Project settings override user settings. - */ -export function loadSettings( - workspaceDir: string = process.cwd(), -): LoadedSettings { - let systemSettings: Settings = {}; - let systemDefaultSettings: Settings = {}; - let userSettings: Settings = {}; - let workspaceSettings: Settings = {}; - const settingsErrors: SettingsError[] = []; - const systemSettingsPath = getSystemSettingsPath(); - const systemDefaultsPath = getSystemDefaultsPath(); - const migratedInMemoryScopes = new Set(); - - // Resolve paths to their canonical representation to handle symlinks - const resolvedWorkspaceDir = path.resolve(workspaceDir); - const resolvedHomeDir = path.resolve(homedir()); - - let realWorkspaceDir = resolvedWorkspaceDir; - try { - // fs.realpathSync gets the "true" path, resolving any symlinks - realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir); - } catch (_e) { - // This is okay. The path might not exist yet, and that's a valid state. - } - - // We expect homedir to always exist and be resolvable. - const realHomeDir = fs.realpathSync(resolvedHomeDir); - - const workspaceSettingsPath = new Storage( - workspaceDir, - ).getWorkspaceSettingsPath(); - - const loadAndMigrate = ( - filePath: string, - scope: SettingScope, - ): { settings: Settings; rawJson?: string } => { - try { - if (fs.existsSync(filePath)) { - const content = fs.readFileSync(filePath, 'utf-8'); - const rawSettings: unknown = JSON.parse(stripJsonComments(content)); - - if ( - typeof rawSettings !== 'object' || - rawSettings === null || - Array.isArray(rawSettings) - ) { - settingsErrors.push({ - message: 'Settings file is not a valid JSON object.', - path: filePath, - }); - return { settings: {} }; - } - - let settingsObject = rawSettings as Record; - if (needsMigration(settingsObject)) { - const migratedSettings = migrateSettingsToV2(settingsObject); - if (migratedSettings) { - if (MIGRATE_V2_OVERWRITE) { - try { - fs.renameSync(filePath, `${filePath}.orig`); - fs.writeFileSync( - filePath, - JSON.stringify(migratedSettings, null, 2), - 'utf-8', - ); - } catch (e) { - coreEvents.emitFeedback( - 'error', - 'Failed to migrate settings file.', - e, - ); - } - } else { - migratedInMemoryScopes.add(scope); - } - settingsObject = migratedSettings; - } - } - return { settings: settingsObject as Settings, rawJson: content }; - } - } catch (error: unknown) { - settingsErrors.push({ - message: getErrorMessage(error), - path: filePath, - }); - } - return { settings: {} }; - }; - - const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System); - const systemDefaultsResult = loadAndMigrate( - systemDefaultsPath, - SettingScope.SystemDefaults, - ); - const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); - - let workspaceResult: { settings: Settings; rawJson?: string } = { - settings: {} as Settings, - rawJson: undefined, - }; - if (realWorkspaceDir !== realHomeDir) { - workspaceResult = loadAndMigrate( - workspaceSettingsPath, - SettingScope.Workspace, - ); - } - - const systemOriginalSettings = structuredClone(systemResult.settings); - const systemDefaultsOriginalSettings = structuredClone( - systemDefaultsResult.settings, - ); - const userOriginalSettings = structuredClone(userResult.settings); - const workspaceOriginalSettings = structuredClone(workspaceResult.settings); - - // Environment variables for runtime use - systemSettings = resolveEnvVarsInObject(systemResult.settings); - systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings); - userSettings = resolveEnvVarsInObject(userResult.settings); - workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); - - // Support legacy theme names - if (userSettings.ui?.theme === 'VS') { - userSettings.ui.theme = DefaultLight.name; - } else if (userSettings.ui?.theme === 'VS2015') { - userSettings.ui.theme = DefaultDark.name; - } - if (workspaceSettings.ui?.theme === 'VS') { - workspaceSettings.ui.theme = DefaultLight.name; - } else if (workspaceSettings.ui?.theme === 'VS2015') { - workspaceSettings.ui.theme = DefaultDark.name; - } - - // For the initial trust check, we can only use user and system settings. - const initialTrustCheckSettings = customDeepMerge( - getMergeStrategyForPath, - {}, - systemSettings, - userSettings, - ); - const isTrusted = - isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; - - // Create a temporary merged settings object to pass to loadEnvironment. - const tempMergedSettings = mergeSettings( - systemSettings, - systemDefaultSettings, - userSettings, - workspaceSettings, - isTrusted, - ); - - // loadEnvironment depends on settings so we have to create a temp version of - // the settings to avoid a cycle - loadEnvironment(tempMergedSettings); - - // Create LoadedSettings first - - if (settingsErrors.length > 0) { - const errorMessages = settingsErrors.map( - (error) => `Error in ${error.path}: ${error.message}`, - ); - throw new FatalConfigError( - `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, - ); - } - - return new LoadedSettings( - { - path: systemSettingsPath, - settings: systemSettings, - originalSettings: systemOriginalSettings, - rawJson: systemResult.rawJson, - }, - { - path: systemDefaultsPath, - settings: systemDefaultSettings, - originalSettings: systemDefaultsOriginalSettings, - rawJson: systemDefaultsResult.rawJson, - }, - { - path: USER_SETTINGS_PATH, - settings: userSettings, - originalSettings: userOriginalSettings, - rawJson: userResult.rawJson, - }, - { - path: workspaceSettingsPath, - settings: workspaceSettings, - originalSettings: workspaceOriginalSettings, - rawJson: workspaceResult.rawJson, - }, - isTrusted, - migratedInMemoryScopes, - ); -} - -export function migrateDeprecatedSettings( - loadedSettings: LoadedSettings, - extensionManager: ExtensionManager, -): void { - const processScope = (scope: LoadableSettingScope) => { - const settings = loadedSettings.forScope(scope).settings; - if (settings.extensions?.disabled) { - debugLogger.log( - `Migrating deprecated extensions.disabled settings from ${scope} settings...`, - ); - for (const extension of settings.extensions.disabled ?? []) { - extensionManager.disableExtension(extension, scope); - } - - const newExtensionsValue = { ...settings.extensions }; - newExtensionsValue.disabled = undefined; - - loadedSettings.setValue(scope, 'extensions', newExtensionsValue); - } - }; - - processScope(SettingScope.User); - processScope(SettingScope.Workspace); -} - -export function saveSettings(settingsFile: SettingsFile): void { - try { - // Ensure the directory exists - const dirPath = path.dirname(settingsFile.path); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - let settingsToSave = settingsFile.originalSettings; - if (!MIGRATE_V2_OVERWRITE) { - settingsToSave = migrateSettingsToV1( - settingsToSave as Record, - ) as Settings; - } - - // Use the format-preserving update function - updateSettingsFilePreservingFormat( - settingsFile.path, - settingsToSave as Record, - ); - } catch (error) { - coreEvents.emitFeedback( - 'error', - 'There was an error saving your latest settings changes.', - error, - ); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/config/settingsSchema.ts b/apps/airiscode-cli/src/gemini-base/config/settingsSchema.ts deleted file mode 100644 index b122ab65c..000000000 --- a/apps/airiscode-cli/src/gemini-base/config/settingsSchema.ts +++ /dev/null @@ -1,1668 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// -------------------------------------------------------------------------- -// IMPORTANT: After adding or updating settings, run `npm run docs:settings` -// to regenerate the settings reference in `docs/get-started/configuration.md`. -// -------------------------------------------------------------------------- - -import type { - MCPServerConfig, - BugCommandSettings, - TelemetrySettings, - AuthType, - HookDefinition, - HookEventName, -} from '@airiscode/gemini-cli-core'; -import { - DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - DEFAULT_GEMINI_MODEL, - DEFAULT_MODEL_CONFIGS, -} from '@airiscode/gemini-cli-core'; -import type { CustomTheme } from '../ui/themes/theme.js'; -import type { SessionRetentionSettings } from './settings.js'; -import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; - -export type SettingsType = - | 'boolean' - | 'string' - | 'number' - | 'array' - | 'object' - | 'enum'; - -export type SettingsValue = - | boolean - | string - | number - | string[] - | object - | undefined; - -/** - * Setting datatypes that "toggle" through a fixed list of options - * (e.g. an enum or true/false) rather than allowing for free form input - * (like a number or string). - */ -export const TOGGLE_TYPES: ReadonlySet = new Set([ - 'boolean', - 'enum', -]); - -export interface SettingEnumOption { - value: string | number; - label: string; -} - -function oneLine(strings: TemplateStringsArray, ...values: unknown[]): string { - let result = ''; - for (let i = 0; i < strings.length; i++) { - result += strings[i]; - if (i < values.length) { - result += String(values[i]); - } - } - return result.replace(/\s+/g, ' ').trim(); -} - -export interface SettingCollectionDefinition { - type: SettingsType; - description?: string; - properties?: SettingsSchema; - /** Enum type options */ - options?: readonly SettingEnumOption[]; - /** - * Optional reference identifier for generators that emit a `$ref`. - * For example, a JSON schema generator can use this to point to a shared definition. - */ - ref?: string; -} - -export enum MergeStrategy { - // Replace the old value with the new value. This is the default. - REPLACE = 'replace', - // Concatenate arrays. - CONCAT = 'concat', - // Merge arrays, ensuring unique values. - UNION = 'union', - // Shallow merge objects. - SHALLOW_MERGE = 'shallow_merge', -} - -export interface SettingDefinition { - type: SettingsType; - label: string; - category: string; - requiresRestart: boolean; - default: SettingsValue; - description?: string; - parentKey?: string; - childKey?: string; - key?: string; - properties?: SettingsSchema; - showInDialog?: boolean; - mergeStrategy?: MergeStrategy; - /** Enum type options */ - options?: readonly SettingEnumOption[]; - /** - * For collection types (e.g. arrays), describes the shape of each item. - */ - items?: SettingCollectionDefinition; - /** - * For map-like objects without explicit `properties`, describes the shape of the values. - */ - additionalProperties?: SettingCollectionDefinition; - /** - * Optional reference identifier for generators that emit a `$ref`. - */ - ref?: string; -} - -export interface SettingsSchema { - [key: string]: SettingDefinition; -} - -export type MemoryImportFormat = 'tree' | 'flat'; -export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; - -/** - * The canonical schema for all settings. - * The structure of this object defines the structure of the `Settings` type. - * `as const` is crucial for TypeScript to infer the most specific types possible. - */ -const SETTINGS_SCHEMA = { - // Maintained for compatibility/criticality - mcpServers: { - type: 'object', - label: 'MCP Servers', - category: 'Advanced', - requiresRestart: true, - default: {} as Record, - description: 'Configuration for MCP servers.', - showInDialog: false, - mergeStrategy: MergeStrategy.SHALLOW_MERGE, - additionalProperties: { - type: 'object', - ref: 'MCPServerConfig', - }, - }, - - general: { - type: 'object', - label: 'General', - category: 'General', - requiresRestart: false, - default: {}, - description: 'General application settings.', - showInDialog: false, - properties: { - preferredEditor: { - type: 'string', - label: 'Preferred Editor', - category: 'General', - requiresRestart: false, - default: undefined as string | undefined, - description: 'The preferred editor to open files in.', - showInDialog: false, - }, - vimMode: { - type: 'boolean', - label: 'Vim Mode', - category: 'General', - requiresRestart: false, - default: false, - description: 'Enable Vim keybindings', - showInDialog: true, - }, - disableAutoUpdate: { - type: 'boolean', - label: 'Disable Auto Update', - category: 'General', - requiresRestart: false, - default: false, - description: 'Disable automatic updates', - showInDialog: true, - }, - disableUpdateNag: { - type: 'boolean', - label: 'Disable Update Nag', - category: 'General', - requiresRestart: false, - default: false, - description: 'Disable update notification prompts.', - showInDialog: false, - }, - checkpointing: { - type: 'object', - label: 'Checkpointing', - category: 'General', - requiresRestart: true, - default: {}, - description: 'Session checkpointing settings.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Enable Checkpointing', - category: 'General', - requiresRestart: true, - default: false, - description: 'Enable session checkpointing for recovery', - showInDialog: false, - }, - }, - }, - enablePromptCompletion: { - type: 'boolean', - label: 'Enable Prompt Completion', - category: 'General', - requiresRestart: true, - default: false, - description: - 'Enable AI-powered prompt completion suggestions while typing.', - showInDialog: true, - }, - retryFetchErrors: { - type: 'boolean', - label: 'Retry Fetch Errors', - category: 'General', - requiresRestart: false, - default: false, - description: - 'Retry on "exception TypeError: fetch failed sending request" errors.', - showInDialog: false, - }, - debugKeystrokeLogging: { - type: 'boolean', - label: 'Debug Keystroke Logging', - category: 'General', - requiresRestart: false, - default: false, - description: 'Enable debug logging of keystrokes to the console.', - showInDialog: true, - }, - sessionRetention: { - type: 'object', - label: 'Session Retention', - category: 'General', - requiresRestart: false, - default: undefined as SessionRetentionSettings | undefined, - properties: { - enabled: { - type: 'boolean', - label: 'Enable Session Cleanup', - category: 'General', - requiresRestart: false, - default: false, - description: 'Enable automatic session cleanup', - showInDialog: true, - }, - maxAge: { - type: 'string', - label: 'Max Session Age', - category: 'General', - requiresRestart: false, - default: undefined as string | undefined, - description: - 'Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w")', - showInDialog: false, - }, - maxCount: { - type: 'number', - label: 'Max Session Count', - category: 'General', - requiresRestart: false, - default: undefined as number | undefined, - description: - 'Alternative: Maximum number of sessions to keep (most recent)', - showInDialog: false, - }, - minRetention: { - type: 'string', - label: 'Min Retention Period', - category: 'General', - requiresRestart: false, - default: DEFAULT_MIN_RETENTION, - description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, - showInDialog: false, - }, - }, - description: 'Settings for automatic session cleanup.', - }, - }, - }, - output: { - type: 'object', - label: 'Output', - category: 'General', - requiresRestart: false, - default: {}, - description: 'Settings for the CLI output.', - showInDialog: false, - properties: { - format: { - type: 'enum', - label: 'Output Format', - category: 'General', - requiresRestart: false, - default: 'text', - description: 'The format of the CLI output.', - showInDialog: true, - options: [ - { value: 'text', label: 'Text' }, - { value: 'json', label: 'JSON' }, - ], - }, - }, - }, - - ui: { - type: 'object', - label: 'UI', - category: 'UI', - requiresRestart: false, - default: {}, - description: 'User interface settings.', - showInDialog: false, - properties: { - theme: { - type: 'string', - label: 'Theme', - category: 'UI', - requiresRestart: false, - default: undefined as string | undefined, - description: - 'The color theme for the UI. See the CLI themes guide for available options.', - showInDialog: false, - }, - customThemes: { - type: 'object', - label: 'Custom Themes', - category: 'UI', - requiresRestart: false, - default: {} as Record, - description: 'Custom theme definitions.', - showInDialog: false, - additionalProperties: { - type: 'object', - ref: 'CustomTheme', - }, - }, - hideWindowTitle: { - type: 'boolean', - label: 'Hide Window Title', - category: 'UI', - requiresRestart: true, - default: false, - description: 'Hide the window title bar', - showInDialog: true, - }, - showStatusInTitle: { - type: 'boolean', - label: 'Show Status in Title', - category: 'UI', - requiresRestart: false, - default: false, - description: - 'Show Gemini CLI status and thoughts in the terminal window title', - showInDialog: true, - }, - hideTips: { - type: 'boolean', - label: 'Hide Tips', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Hide helpful tips in the UI', - showInDialog: true, - }, - hideBanner: { - type: 'boolean', - label: 'Hide Banner', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Hide the application banner', - showInDialog: true, - }, - hideContextSummary: { - type: 'boolean', - label: 'Hide Context Summary', - category: 'UI', - requiresRestart: false, - default: false, - description: - 'Hide the context summary (GEMINI.md, MCP servers) above the input.', - showInDialog: true, - }, - footer: { - type: 'object', - label: 'Footer', - category: 'UI', - requiresRestart: false, - default: {}, - description: 'Settings for the footer.', - showInDialog: false, - properties: { - hideCWD: { - type: 'boolean', - label: 'Hide CWD', - category: 'UI', - requiresRestart: false, - default: false, - description: - 'Hide the current working directory path in the footer.', - showInDialog: true, - }, - hideSandboxStatus: { - type: 'boolean', - label: 'Hide Sandbox Status', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Hide the sandbox status indicator in the footer.', - showInDialog: true, - }, - hideModelInfo: { - type: 'boolean', - label: 'Hide Model Info', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Hide the model name and context usage in the footer.', - showInDialog: true, - }, - hideContextPercentage: { - type: 'boolean', - label: 'Hide Context Window Percentage', - category: 'UI', - requiresRestart: false, - default: true, - description: 'Hides the context window remaining percentage.', - showInDialog: true, - }, - }, - }, - hideFooter: { - type: 'boolean', - label: 'Hide Footer', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Hide the footer from the UI', - showInDialog: true, - }, - showMemoryUsage: { - type: 'boolean', - label: 'Show Memory Usage', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Display memory usage information in the UI', - showInDialog: true, - }, - showLineNumbers: { - type: 'boolean', - label: 'Show Line Numbers', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Show line numbers in the chat.', - showInDialog: true, - }, - showCitations: { - type: 'boolean', - label: 'Show Citations', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Show citations for generated text in the chat.', - showInDialog: true, - }, - useFullWidth: { - type: 'boolean', - label: 'Use Full Width', - category: 'UI', - requiresRestart: false, - default: true, - description: 'Use the entire width of the terminal for output.', - showInDialog: true, - }, - useAlternateBuffer: { - type: 'boolean', - label: 'Use Alternate Screen Buffer', - category: 'UI', - requiresRestart: true, - default: true, - description: - 'Use an alternate screen buffer for the UI, preserving shell history.', - showInDialog: true, - }, - customWittyPhrases: { - type: 'array', - label: 'Custom Witty Phrases', - category: 'UI', - requiresRestart: false, - default: [] as string[], - description: oneLine` - Custom witty phrases to display during loading. - When provided, the CLI cycles through these instead of the defaults. - `, - showInDialog: false, - items: { type: 'string' }, - }, - accessibility: { - type: 'object', - label: 'Accessibility', - category: 'UI', - requiresRestart: true, - default: {}, - description: 'Accessibility settings.', - showInDialog: false, - properties: { - disableLoadingPhrases: { - type: 'boolean', - label: 'Disable Loading Phrases', - category: 'UI', - requiresRestart: true, - default: false, - description: 'Disable loading phrases for accessibility', - showInDialog: true, - }, - screenReader: { - type: 'boolean', - label: 'Screen Reader Mode', - category: 'UI', - requiresRestart: true, - default: false, - description: - 'Render output in plain-text to be more screen reader accessible', - showInDialog: true, - }, - }, - }, - }, - }, - - ide: { - type: 'object', - label: 'IDE', - category: 'IDE', - requiresRestart: true, - default: {}, - description: 'IDE integration settings.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'IDE Mode', - category: 'IDE', - requiresRestart: true, - default: false, - description: 'Enable IDE integration mode', - showInDialog: true, - }, - hasSeenNudge: { - type: 'boolean', - label: 'Has Seen IDE Integration Nudge', - category: 'IDE', - requiresRestart: false, - default: false, - description: 'Whether the user has seen the IDE integration nudge.', - showInDialog: false, - }, - }, - }, - - privacy: { - type: 'object', - label: 'Privacy', - category: 'Privacy', - requiresRestart: true, - default: {}, - description: 'Privacy-related settings.', - showInDialog: false, - properties: { - usageStatisticsEnabled: { - type: 'boolean', - label: 'Enable Usage Statistics', - category: 'Privacy', - requiresRestart: true, - default: true, - description: 'Enable collection of usage statistics', - showInDialog: false, - }, - }, - }, - - telemetry: { - type: 'object', - label: 'Telemetry', - category: 'Advanced', - requiresRestart: true, - default: undefined as TelemetrySettings | undefined, - description: 'Telemetry configuration.', - showInDialog: false, - ref: 'TelemetrySettings', - }, - - model: { - type: 'object', - label: 'Model', - category: 'Model', - requiresRestart: false, - default: {}, - description: 'Settings related to the generative model.', - showInDialog: false, - properties: { - name: { - type: 'string', - label: 'Model', - category: 'Model', - requiresRestart: false, - default: undefined as string | undefined, - description: 'The Gemini model to use for conversations.', - showInDialog: false, - }, - maxSessionTurns: { - type: 'number', - label: 'Max Session Turns', - category: 'Model', - requiresRestart: false, - default: -1, - description: - 'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.', - showInDialog: true, - }, - summarizeToolOutput: { - type: 'object', - label: 'Summarize Tool Output', - category: 'Model', - requiresRestart: false, - default: undefined as - | Record - | undefined, - description: oneLine` - Enables or disables summarization of tool output. - Configure per-tool token budgets (for example {"run_shell_command": {"tokenBudget": 2000}}). - Currently only the run_shell_command tool supports summarization. - `, - showInDialog: false, - additionalProperties: { - type: 'object', - description: - 'Per-tool summarization settings with an optional tokenBudget.', - ref: 'SummarizeToolOutputSettings', - }, - }, - compressionThreshold: { - type: 'number', - label: 'Compression Threshold', - category: 'Model', - requiresRestart: true, - default: 0.2 as number, - description: - 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', - showInDialog: true, - }, - skipNextSpeakerCheck: { - type: 'boolean', - label: 'Skip Next Speaker Check', - category: 'Model', - requiresRestart: false, - default: true, - description: 'Skip the next speaker check.', - showInDialog: true, - }, - }, - }, - - modelConfigs: { - type: 'object', - label: 'Model Configs', - category: 'Model', - requiresRestart: false, - default: DEFAULT_MODEL_CONFIGS, - description: 'Model configurations.', - showInDialog: false, - properties: { - aliases: { - type: 'object', - label: 'Model Config Aliases', - category: 'Model', - requiresRestart: false, - default: DEFAULT_MODEL_CONFIGS.aliases, - description: - 'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.', - showInDialog: false, - }, - overrides: { - type: 'array', - label: 'Model Config Overrides', - category: 'Model', - requiresRestart: false, - default: [], - description: - 'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.', - showInDialog: false, - }, - }, - }, - - context: { - type: 'object', - label: 'Context', - category: 'Context', - requiresRestart: false, - default: {}, - description: 'Settings for managing context provided to the model.', - showInDialog: false, - properties: { - fileName: { - type: 'string', - label: 'Context File Name', - category: 'Context', - requiresRestart: false, - default: undefined as string | string[] | undefined, - ref: 'StringOrStringArray', - description: - 'The name of the context file or files to load into memory. Accepts either a single string or an array of strings.', - showInDialog: false, - }, - importFormat: { - type: 'string', - label: 'Memory Import Format', - category: 'Context', - requiresRestart: false, - default: undefined as MemoryImportFormat | undefined, - description: 'The format to use when importing memory.', - showInDialog: false, - }, - discoveryMaxDirs: { - type: 'number', - label: 'Memory Discovery Max Dirs', - category: 'Context', - requiresRestart: false, - default: 200, - description: 'Maximum number of directories to search for memory.', - showInDialog: true, - }, - includeDirectories: { - type: 'array', - label: 'Include Directories', - category: 'Context', - requiresRestart: false, - default: [] as string[], - description: oneLine` - Additional directories to include in the workspace context. - Missing directories will be skipped with a warning. - `, - showInDialog: false, - items: { type: 'string' }, - mergeStrategy: MergeStrategy.CONCAT, - }, - loadMemoryFromIncludeDirectories: { - type: 'boolean', - label: 'Load Memory From Include Directories', - category: 'Context', - requiresRestart: false, - default: false, - description: oneLine` - Controls how /memory refresh loads GEMINI.md files. - When true, include directories are scanned; when false, only the current directory is used. - `, - showInDialog: true, - }, - fileFiltering: { - type: 'object', - label: 'File Filtering', - category: 'Context', - requiresRestart: true, - default: {}, - description: 'Settings for git-aware file filtering.', - showInDialog: false, - properties: { - respectGitIgnore: { - type: 'boolean', - label: 'Respect .gitignore', - category: 'Context', - requiresRestart: true, - default: true, - description: 'Respect .gitignore files when searching', - showInDialog: true, - }, - respectGeminiIgnore: { - type: 'boolean', - label: 'Respect .geminiignore', - category: 'Context', - requiresRestart: true, - default: true, - description: 'Respect .geminiignore files when searching', - showInDialog: true, - }, - enableRecursiveFileSearch: { - type: 'boolean', - label: 'Enable Recursive File Search', - category: 'Context', - requiresRestart: true, - default: true, - description: oneLine` - Enable recursive file search functionality when completing @ references in the prompt. - `, - showInDialog: true, - }, - disableFuzzySearch: { - type: 'boolean', - label: 'Disable Fuzzy Search', - category: 'Context', - requiresRestart: true, - default: false, - description: 'Disable fuzzy search when searching for files.', - showInDialog: true, - }, - }, - }, - }, - }, - - tools: { - type: 'object', - label: 'Tools', - category: 'Tools', - requiresRestart: true, - default: {}, - description: 'Settings for built-in and custom tools.', - showInDialog: false, - properties: { - sandbox: { - type: 'string', - label: 'Sandbox', - category: 'Tools', - requiresRestart: true, - default: undefined as boolean | string | undefined, - ref: 'BooleanOrString', - description: oneLine` - Sandbox execution environment. - Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile. - `, - showInDialog: false, - }, - shell: { - type: 'object', - label: 'Shell', - category: 'Tools', - requiresRestart: false, - default: {}, - description: 'Settings for shell execution.', - showInDialog: false, - properties: { - enableInteractiveShell: { - type: 'boolean', - label: 'Enable Interactive Shell', - category: 'Tools', - requiresRestart: true, - default: true, - description: oneLine` - Use node-pty for an interactive shell experience. - Fallback to child_process still applies. - `, - showInDialog: true, - }, - pager: { - type: 'string', - label: 'Pager', - category: 'Tools', - requiresRestart: false, - default: 'cat' as string | undefined, - description: - 'The pager command to use for shell output. Defaults to `cat`.', - showInDialog: false, - }, - showColor: { - type: 'boolean', - label: 'Show Color', - category: 'Tools', - requiresRestart: false, - default: false, - description: 'Show color in shell output.', - showInDialog: true, - }, - }, - }, - autoAccept: { - type: 'boolean', - label: 'Auto Accept', - category: 'Tools', - requiresRestart: false, - default: false, - description: oneLine` - Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). - `, - showInDialog: true, - }, - core: { - type: 'array', - label: 'Core Tools', - category: 'Tools', - requiresRestart: true, - default: undefined as string[] | undefined, - description: oneLine` - Restrict the set of built-in tools with an allowlist. - Match semantics mirror tools.allowed; see the built-in tools documentation for available names. - `, - showInDialog: false, - items: { type: 'string' }, - }, - allowed: { - type: 'array', - label: 'Allowed Tools', - category: 'Advanced', - requiresRestart: true, - default: undefined as string[] | undefined, - description: oneLine` - Tool names that bypass the confirmation dialog. - Useful for trusted commands (for example ["run_shell_command(git)", "run_shell_command(npm test)"]). - See shell tool command restrictions for matching details. - `, - showInDialog: false, - items: { type: 'string' }, - }, - exclude: { - type: 'array', - label: 'Exclude Tools', - category: 'Tools', - requiresRestart: true, - default: undefined as string[] | undefined, - description: 'Tool names to exclude from discovery.', - showInDialog: false, - items: { type: 'string' }, - mergeStrategy: MergeStrategy.UNION, - }, - discoveryCommand: { - type: 'string', - label: 'Tool Discovery Command', - category: 'Tools', - requiresRestart: true, - default: undefined as string | undefined, - description: 'Command to run for tool discovery.', - showInDialog: false, - }, - callCommand: { - type: 'string', - label: 'Tool Call Command', - category: 'Tools', - requiresRestart: true, - default: undefined as string | undefined, - description: oneLine` - Defines a custom shell command for invoking discovered tools. - The command must take the tool name as the first argument, read JSON arguments from stdin, and emit JSON results on stdout. - `, - showInDialog: false, - }, - useRipgrep: { - type: 'boolean', - label: 'Use Ripgrep', - category: 'Tools', - requiresRestart: false, - default: true, - description: - 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', - showInDialog: true, - }, - enableToolOutputTruncation: { - type: 'boolean', - label: 'Enable Tool Output Truncation', - category: 'General', - requiresRestart: true, - default: true, - description: 'Enable truncation of large tool outputs.', - showInDialog: true, - }, - truncateToolOutputThreshold: { - type: 'number', - label: 'Tool Output Truncation Threshold', - category: 'General', - requiresRestart: true, - default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - description: - 'Truncate tool output if it is larger than this many characters. Set to -1 to disable.', - showInDialog: true, - }, - truncateToolOutputLines: { - type: 'number', - label: 'Tool Output Truncation Lines', - category: 'General', - requiresRestart: true, - default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - description: 'The number of lines to keep when truncating tool output.', - showInDialog: true, - }, - enableMessageBusIntegration: { - type: 'boolean', - label: 'Enable Message Bus Integration', - category: 'Tools', - requiresRestart: true, - default: false, - description: oneLine` - Enable policy-based tool confirmation via message bus integration. - When enabled, tools automatically respect policy engine decisions (ALLOW/DENY/ASK_USER) without requiring individual tool implementations. - `, - showInDialog: true, - }, - enableHooks: { - type: 'boolean', - label: 'Enable Hooks System', - category: 'Advanced', - requiresRestart: true, - default: false, - description: - 'Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.', - showInDialog: false, - }, - }, - }, - - mcp: { - type: 'object', - label: 'MCP', - category: 'MCP', - requiresRestart: true, - default: {}, - description: 'Settings for Model Context Protocol (MCP) servers.', - showInDialog: false, - properties: { - serverCommand: { - type: 'string', - label: 'MCP Server Command', - category: 'MCP', - requiresRestart: true, - default: undefined as string | undefined, - description: 'Command to start an MCP server.', - showInDialog: false, - }, - allowed: { - type: 'array', - label: 'Allow MCP Servers', - category: 'MCP', - requiresRestart: true, - default: undefined as string[] | undefined, - description: 'A list of MCP servers to allow.', - showInDialog: false, - items: { type: 'string' }, - }, - excluded: { - type: 'array', - label: 'Exclude MCP Servers', - category: 'MCP', - requiresRestart: true, - default: undefined as string[] | undefined, - description: 'A list of MCP servers to exclude.', - showInDialog: false, - items: { type: 'string' }, - }, - }, - }, - useSmartEdit: { - type: 'boolean', - label: 'Use Smart Edit', - category: 'Advanced', - requiresRestart: false, - default: true, - description: 'Enable the smart-edit tool instead of the replace tool.', - showInDialog: false, - }, - useWriteTodos: { - type: 'boolean', - label: 'Use WriteTodos', - category: 'Advanced', - requiresRestart: false, - default: true, - description: 'Enable the write_todos tool.', - showInDialog: false, - }, - security: { - type: 'object', - label: 'Security', - category: 'Security', - requiresRestart: true, - default: {}, - description: 'Security-related settings.', - showInDialog: false, - properties: { - disableYoloMode: { - type: 'boolean', - label: 'Disable YOLO Mode', - category: 'Security', - requiresRestart: true, - default: false, - description: 'Disable YOLO mode, even if enabled by a flag.', - showInDialog: true, - }, - blockGitExtensions: { - type: 'boolean', - label: 'Blocks extensions from Git', - category: 'Security', - requiresRestart: true, - default: false, - description: 'Blocks installing and loading extensions from Git.', - showInDialog: true, - }, - folderTrust: { - type: 'object', - label: 'Folder Trust', - category: 'Security', - requiresRestart: false, - default: {}, - description: 'Settings for folder trust.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Folder Trust', - category: 'Security', - requiresRestart: true, - default: false, - description: 'Setting to track whether Folder trust is enabled.', - showInDialog: true, - }, - }, - }, - auth: { - type: 'object', - label: 'Authentication', - category: 'Security', - requiresRestart: true, - default: {}, - description: 'Authentication settings.', - showInDialog: false, - properties: { - selectedType: { - type: 'string', - label: 'Selected Auth Type', - category: 'Security', - requiresRestart: true, - default: undefined as AuthType | undefined, - description: 'The currently selected authentication type.', - showInDialog: false, - }, - enforcedType: { - type: 'string', - label: 'Enforced Auth Type', - category: 'Advanced', - requiresRestart: true, - default: undefined as AuthType | undefined, - description: - 'The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.', - showInDialog: false, - }, - useExternal: { - type: 'boolean', - label: 'Use External Auth', - category: 'Security', - requiresRestart: true, - default: undefined as boolean | undefined, - description: 'Whether to use an external authentication flow.', - showInDialog: false, - }, - }, - }, - }, - }, - - advanced: { - type: 'object', - label: 'Advanced', - category: 'Advanced', - requiresRestart: true, - default: {}, - description: 'Advanced settings for power users.', - showInDialog: false, - properties: { - autoConfigureMemory: { - type: 'boolean', - label: 'Auto Configure Max Old Space Size', - category: 'Advanced', - requiresRestart: true, - default: false, - description: 'Automatically configure Node.js memory limits', - showInDialog: false, - }, - dnsResolutionOrder: { - type: 'string', - label: 'DNS Resolution Order', - category: 'Advanced', - requiresRestart: true, - default: undefined as DnsResolutionOrder | undefined, - description: 'The DNS resolution order.', - showInDialog: false, - }, - excludedEnvVars: { - type: 'array', - label: 'Excluded Project Environment Variables', - category: 'Advanced', - requiresRestart: false, - default: ['DEBUG', 'DEBUG_MODE'] as string[], - description: 'Environment variables to exclude from project context.', - showInDialog: false, - items: { type: 'string' }, - mergeStrategy: MergeStrategy.UNION, - }, - bugCommand: { - type: 'object', - label: 'Bug Command', - category: 'Advanced', - requiresRestart: false, - default: undefined as BugCommandSettings | undefined, - description: 'Configuration for the bug report command.', - showInDialog: false, - ref: 'BugCommandSettings', - }, - }, - }, - - experimental: { - type: 'object', - label: 'Experimental', - category: 'Experimental', - requiresRestart: true, - default: {}, - description: 'Setting to enable experimental features', - showInDialog: false, - properties: { - extensionManagement: { - type: 'boolean', - label: 'Extension Management', - category: 'Experimental', - requiresRestart: true, - default: true, - description: 'Enable extension management features.', - showInDialog: false, - }, - extensionReloading: { - type: 'boolean', - label: 'Extension Reloading', - category: 'Experimental', - requiresRestart: true, - default: false, - description: - 'Enables extension loading/unloading within the CLI session.', - showInDialog: false, - }, - useModelRouter: { - type: 'boolean', - label: 'Use Model Router', - category: 'Experimental', - requiresRestart: true, - default: true, - description: - 'Enable model routing to route requests to the best model based on complexity.', - showInDialog: true, - }, - codebaseInvestigatorSettings: { - type: 'object', - label: 'Codebase Investigator Settings', - category: 'Experimental', - requiresRestart: true, - default: {}, - description: 'Configuration for Codebase Investigator.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Enable Codebase Investigator', - category: 'Experimental', - requiresRestart: true, - default: true, - description: 'Enable the Codebase Investigator agent.', - showInDialog: true, - }, - maxNumTurns: { - type: 'number', - label: 'Codebase Investigator Max Num Turns', - category: 'Experimental', - requiresRestart: true, - default: 10, - description: - 'Maximum number of turns for the Codebase Investigator agent.', - showInDialog: true, - }, - maxTimeMinutes: { - type: 'number', - label: 'Max Time (Minutes)', - category: 'Experimental', - requiresRestart: true, - default: 3, - description: - 'Maximum time for the Codebase Investigator agent (in minutes).', - showInDialog: false, - }, - thinkingBudget: { - type: 'number', - label: 'Thinking Budget', - category: 'Experimental', - requiresRestart: true, - default: 8192, - description: - 'The thinking budget for the Codebase Investigator agent.', - showInDialog: false, - }, - model: { - type: 'string', - label: 'Model', - category: 'Experimental', - requiresRestart: true, - default: DEFAULT_GEMINI_MODEL, - description: - 'The model to use for the Codebase Investigator agent.', - showInDialog: false, - }, - }, - }, - }, - }, - - extensions: { - type: 'object', - label: 'Extensions', - category: 'Extensions', - requiresRestart: true, - default: {}, - description: 'Settings for extensions.', - showInDialog: false, - properties: { - disabled: { - type: 'array', - label: 'Disabled Extensions', - category: 'Extensions', - requiresRestart: true, - default: [] as string[], - description: 'List of disabled extensions.', - showInDialog: false, - items: { type: 'string' }, - mergeStrategy: MergeStrategy.UNION, - }, - workspacesWithMigrationNudge: { - type: 'array', - label: 'Workspaces with Migration Nudge', - category: 'Extensions', - requiresRestart: false, - default: [] as string[], - description: - 'List of workspaces for which the migration nudge has been shown.', - showInDialog: false, - items: { type: 'string' }, - mergeStrategy: MergeStrategy.UNION, - }, - }, - }, - - hooks: { - type: 'object', - label: 'Hooks', - category: 'Advanced', - requiresRestart: false, - default: {} as { [K in HookEventName]?: HookDefinition[] }, - description: - 'Hook configurations for intercepting and customizing agent behavior.', - showInDialog: false, - mergeStrategy: MergeStrategy.SHALLOW_MERGE, - }, -} as const satisfies SettingsSchema; - -export type SettingsSchemaType = typeof SETTINGS_SCHEMA; - -export type SettingsJsonSchemaDefinition = Record; - -export const SETTINGS_SCHEMA_DEFINITIONS: Record< - string, - SettingsJsonSchemaDefinition -> = { - MCPServerConfig: { - type: 'object', - description: - 'Definition of a Model Context Protocol (MCP) server configuration.', - additionalProperties: false, - properties: { - command: { - type: 'string', - description: 'Executable invoked for stdio transport.', - }, - args: { - type: 'array', - description: 'Command-line arguments for the stdio transport command.', - items: { type: 'string' }, - }, - env: { - type: 'object', - description: 'Environment variables to set for the server process.', - additionalProperties: { type: 'string' }, - }, - cwd: { - type: 'string', - description: 'Working directory for the server process.', - }, - url: { - type: 'string', - description: 'SSE transport URL.', - }, - httpUrl: { - type: 'string', - description: 'Streaming HTTP transport URL.', - }, - headers: { - type: 'object', - description: 'Additional HTTP headers sent to the server.', - additionalProperties: { type: 'string' }, - }, - tcp: { - type: 'string', - description: 'TCP address for websocket transport.', - }, - timeout: { - type: 'number', - description: 'Timeout in milliseconds for MCP requests.', - }, - trust: { - type: 'boolean', - description: - 'Marks the server as trusted. Trusted servers may gain additional capabilities.', - }, - description: { - type: 'string', - description: 'Human-readable description of the server.', - }, - includeTools: { - type: 'array', - description: - 'Subset of tools that should be enabled for this server. When omitted all tools are enabled.', - items: { type: 'string' }, - }, - excludeTools: { - type: 'array', - description: - 'Tools that should be disabled for this server even if exposed.', - items: { type: 'string' }, - }, - extension: { - type: 'object', - description: - 'Metadata describing the Gemini CLI extension that owns this MCP server.', - additionalProperties: { type: ['string', 'boolean', 'number'] }, - }, - oauth: { - type: 'object', - description: 'OAuth configuration for authenticating with the server.', - additionalProperties: true, - }, - authProviderType: { - type: 'string', - description: - 'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).', - enum: [ - 'dynamic_discovery', - 'google_credentials', - 'service_account_impersonation', - ], - }, - targetAudience: { - type: 'string', - description: - 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', - }, - targetServiceAccount: { - type: 'string', - description: - 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', - }, - }, - }, - TelemetrySettings: { - type: 'object', - description: 'Telemetry configuration for Gemini CLI.', - additionalProperties: false, - properties: { - enabled: { - type: 'boolean', - description: 'Enables telemetry emission.', - }, - target: { - type: 'string', - description: - 'Telemetry destination (for example `stderr`, `stdout`, or `otlp`).', - }, - otlpEndpoint: { - type: 'string', - description: 'Endpoint for OTLP exporters.', - }, - otlpProtocol: { - type: 'string', - description: 'Protocol for OTLP exporters.', - enum: ['grpc', 'http'], - }, - logPrompts: { - type: 'boolean', - description: 'Whether prompts are logged in telemetry payloads.', - }, - outfile: { - type: 'string', - description: 'File path for writing telemetry output.', - }, - useCollector: { - type: 'boolean', - description: 'Whether to forward telemetry to an OTLP collector.', - }, - }, - }, - BugCommandSettings: { - type: 'object', - description: 'Configuration for the bug report helper command.', - additionalProperties: false, - properties: { - urlTemplate: { - type: 'string', - description: - 'Template used to open a bug report URL. Variables in the template are populated at runtime.', - }, - }, - required: ['urlTemplate'], - }, - SummarizeToolOutputSettings: { - type: 'object', - description: - 'Controls summarization behavior for individual tools. All properties are optional.', - additionalProperties: false, - properties: { - tokenBudget: { - type: 'number', - description: - 'Maximum number of tokens used when summarizing tool output.', - }, - }, - }, - CustomTheme: { - type: 'object', - description: - 'Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.', - additionalProperties: false, - properties: { - type: { - type: 'string', - enum: ['custom'], - default: 'custom', - }, - name: { - type: 'string', - description: 'Theme display name.', - }, - text: { - type: 'object', - additionalProperties: false, - properties: { - primary: { type: 'string' }, - secondary: { type: 'string' }, - link: { type: 'string' }, - accent: { type: 'string' }, - }, - }, - background: { - type: 'object', - additionalProperties: false, - properties: { - primary: { type: 'string' }, - diff: { - type: 'object', - additionalProperties: false, - properties: { - added: { type: 'string' }, - removed: { type: 'string' }, - }, - }, - }, - }, - border: { - type: 'object', - additionalProperties: false, - properties: { - default: { type: 'string' }, - focused: { type: 'string' }, - }, - }, - ui: { - type: 'object', - additionalProperties: false, - properties: { - comment: { type: 'string' }, - symbol: { type: 'string' }, - gradient: { - type: 'array', - items: { type: 'string' }, - }, - }, - }, - status: { - type: 'object', - additionalProperties: false, - properties: { - error: { type: 'string' }, - success: { type: 'string' }, - warning: { type: 'string' }, - }, - }, - Background: { type: 'string' }, - Foreground: { type: 'string' }, - LightBlue: { type: 'string' }, - AccentBlue: { type: 'string' }, - AccentPurple: { type: 'string' }, - AccentCyan: { type: 'string' }, - AccentGreen: { type: 'string' }, - AccentYellow: { type: 'string' }, - AccentRed: { type: 'string' }, - DiffAdded: { type: 'string' }, - DiffRemoved: { type: 'string' }, - Comment: { type: 'string' }, - Gray: { type: 'string' }, - DarkGray: { type: 'string' }, - GradientColors: { - type: 'array', - items: { type: 'string' }, - }, - }, - required: ['type', 'name'], - }, - StringOrStringArray: { - description: 'Accepts either a single string or an array of strings.', - anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], - }, - BooleanOrString: { - description: 'Accepts either a boolean flag or a string command name.', - anyOf: [{ type: 'boolean' }, { type: 'string' }], - }, -}; - -export function getSettingsSchema(): SettingsSchemaType { - return SETTINGS_SCHEMA; -} - -type InferSettings = { - -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } - ? InferSettings - : T[K]['type'] extends 'enum' - ? T[K]['options'] extends readonly SettingEnumOption[] - ? T[K]['options'][number]['value'] - : T[K]['default'] - : T[K]['default'] extends boolean - ? boolean - : T[K]['default']; -}; - -export type Settings = InferSettings; - -export interface FooterSettings { - hideCWD?: boolean; - hideSandboxStatus?: boolean; - hideModelInfo?: boolean; -} diff --git a/apps/airiscode-cli/src/gemini-base/core/auth.ts b/apps/airiscode-cli/src/gemini-base/core/auth.ts deleted file mode 100644 index 5529dbfb8..000000000 --- a/apps/airiscode-cli/src/gemini-base/core/auth.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - type AuthType, - type Config, - getErrorMessage, -} from '@airiscode/gemini-cli-core'; - -/** - * Handles the initial authentication flow. - * @param config The application config. - * @param authType The selected auth type. - * @returns An error message if authentication fails, otherwise null. - */ -export async function performInitialAuth( - config: Config, - authType: AuthType | undefined, -): Promise { - if (!authType) { - return null; - } - - try { - await config.refreshAuth(authType); - // The console.log is intentionally left out here. - // We can add a dedicated startup message later if needed. - } catch (e) { - return `Failed to login. Message: ${getErrorMessage(e)}`; - } - - return null; -} diff --git a/apps/airiscode-cli/src/gemini-base/core/initializer.ts b/apps/airiscode-cli/src/gemini-base/core/initializer.ts deleted file mode 100644 index f6d247907..000000000 --- a/apps/airiscode-cli/src/gemini-base/core/initializer.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - IdeClient, - IdeConnectionEvent, - IdeConnectionType, - logIdeConnection, - type Config, - StartSessionEvent, - logCliConfiguration, -} from '@airiscode/gemini-cli-core'; -import { type LoadedSettings } from '../config/settings.js'; -import { performInitialAuth } from './auth.js'; -import { validateTheme } from './theme.js'; - -export interface InitializationResult { - authError: string | null; - themeError: string | null; - shouldOpenAuthDialog: boolean; - geminiMdFileCount: number; -} - -/** - * Orchestrates the application's startup initialization. - * This runs BEFORE the React UI is rendered. - * @param config The application config. - * @param settings The loaded application settings. - * @returns The results of the initialization. - */ -export async function initializeApp( - config: Config, - settings: LoadedSettings, -): Promise { - const authError = await performInitialAuth( - config, - settings.merged.security?.auth?.selectedType, - ); - const themeError = validateTheme(settings); - - const shouldOpenAuthDialog = - settings.merged.security?.auth?.selectedType === undefined || !!authError; - - logCliConfiguration( - config, - new StartSessionEvent(config, config.getToolRegistry()), - ); - - if (config.getIdeMode()) { - const ideClient = await IdeClient.getInstance(); - await ideClient.connect(); - logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); - } - - return { - authError, - themeError, - shouldOpenAuthDialog, - geminiMdFileCount: config.getGeminiMdFileCount(), - }; -} diff --git a/apps/airiscode-cli/src/gemini-base/core/theme.ts b/apps/airiscode-cli/src/gemini-base/core/theme.ts deleted file mode 100644 index ed2805a5a..000000000 --- a/apps/airiscode-cli/src/gemini-base/core/theme.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { themeManager } from '../ui/themes/theme-manager.js'; -import { type LoadedSettings } from '../config/settings.js'; - -/** - * Validates the configured theme. - * @param settings The loaded application settings. - * @returns An error message if the theme is not found, otherwise null. - */ -export function validateTheme(settings: LoadedSettings): string | null { - const effectiveTheme = settings.merged.ui?.theme; - if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { - return `Theme "${effectiveTheme}" not found.`; - } - return null; -} diff --git a/apps/airiscode-cli/src/gemini-base/gemini.tsx b/apps/airiscode-cli/src/gemini-base/gemini.tsx deleted file mode 100644 index bf546a7b5..000000000 --- a/apps/airiscode-cli/src/gemini-base/gemini.tsx +++ /dev/null @@ -1,580 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from 'ink'; -import { AppContainer } from './ui/AppContainer.js'; -import { loadCliConfig, parseArguments } from './config/config.js'; -import * as cliConfig from './config/config.js'; -import { readStdin } from './utils/readStdin.js'; -import { basename } from 'node:path'; -import v8 from 'node:v8'; -import os from 'node:os'; -import dns from 'node:dns'; -import { start_sandbox } from './utils/sandbox.js'; -import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; -import { - loadSettings, - migrateDeprecatedSettings, - SettingScope, -} from './config/settings.js'; -import { themeManager } from './ui/themes/theme-manager.js'; -import { getStartupWarnings } from './utils/startupWarnings.js'; -import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; -import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; -import { runNonInteractive } from './nonInteractiveCli.js'; -import { - cleanupCheckpoints, - registerCleanup, - runExitCleanup, -} from './utils/cleanup.js'; -import { getCliVersion } from './utils/version.js'; -import type { Config, ResumedSessionData } from '@airiscode/gemini-cli-core'; -import { - sessionId, - logUserPrompt, - AuthType, - getOauthClient, - UserPromptEvent, - debugLogger, - recordSlowRender, -} from '@airiscode/gemini-cli-core'; -import { - initializeApp, - type InitializationResult, -} from './core/initializer.js'; -import { validateAuthMethod } from './config/auth.js'; -import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; -import { runZedIntegration } from './zed-integration/zedIntegration.js'; -import { cleanupExpiredSessions } from './utils/sessionCleanup.js'; -import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; -import { checkForUpdates } from './ui/utils/updateCheck.js'; -import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; -import { appEvents, AppEvent } from './utils/events.js'; -import { SessionSelector } from './utils/sessionUtils.js'; -import { computeWindowTitle } from './utils/windowTitle.js'; -import { SettingsContext } from './ui/contexts/SettingsContext.js'; -import { MouseProvider } from './ui/contexts/MouseContext.js'; - -import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; -import { VimModeProvider } from './ui/contexts/VimModeContext.js'; -import { KeypressProvider } from './ui/contexts/KeypressContext.js'; -import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; -import { - relaunchAppInChildProcess, - relaunchOnExitCode, -} from './utils/relaunch.js'; -import { loadSandboxConfig } from './config/sandboxConfig.js'; -import { deleteSession, listSessions } from './utils/sessions.js'; -import { ExtensionManager } from './config/extension-manager.js'; -import { createPolicyUpdater } from './config/policy.js'; -import { requestConsentNonInteractive } from './config/extensions/consent.js'; -import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js'; -import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; -import ansiEscapes from 'ansi-escapes'; -import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; - -const SLOW_RENDER_MS = 200; - -export function validateDnsResolutionOrder( - order: string | undefined, -): DnsResolutionOrder { - const defaultValue: DnsResolutionOrder = 'ipv4first'; - if (order === undefined) { - return defaultValue; - } - if (order === 'ipv4first' || order === 'verbatim') { - return order; - } - // We don't want to throw here, just warn and use the default. - debugLogger.warn( - `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`, - ); - return defaultValue; -} - -function getNodeMemoryArgs(isDebugMode: boolean): string[] { - const totalMemoryMB = os.totalmem() / (1024 * 1024); - const heapStats = v8.getHeapStatistics(); - const currentMaxOldSpaceSizeMb = Math.floor( - heapStats.heap_size_limit / 1024 / 1024, - ); - - // Set target to 50% of total memory - const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); - if (isDebugMode) { - debugLogger.debug( - `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`, - ); - } - - if (process.env['GEMINI_CLI_NO_RELAUNCH']) { - return []; - } - - if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { - if (isDebugMode) { - debugLogger.debug( - `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`, - ); - } - return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`]; - } - - return []; -} - -export function setupUnhandledRejectionHandler() { - let unhandledRejectionOccurred = false; - process.on('unhandledRejection', (reason, _promise) => { - const errorMessage = `========================================= -This is an unexpected error. Please file a bug report using the /bug tool. -CRITICAL: Unhandled Promise Rejection! -========================================= -Reason: ${reason}${ - reason instanceof Error && reason.stack - ? ` -Stack trace: -${reason.stack}` - : '' - }`; - appEvents.emit(AppEvent.LogError, errorMessage); - if (!unhandledRejectionOccurred) { - unhandledRejectionOccurred = true; - appEvents.emit(AppEvent.OpenDebugConsole); - } - }); -} - -export async function startInteractiveUI( - config: Config, - settings: LoadedSettings, - startupWarnings: string[], - workspaceRoot: string = process.cwd(), - resumedSessionData: ResumedSessionData | undefined, - initializationResult: InitializationResult, -) { - // When not in screen reader mode, disable line wrapping. - // We rely on Ink to manage all line wrapping by forcing all content to be - // narrower than the terminal width so there is no need for the terminal to - // also attempt line wrapping. - // Disabling line wrapping reduces Ink rendering artifacts particularly when - // the terminal is resized on terminals that full respect this escape code - // such as Ghostty. Some terminals such as Iterm2 only respect line wrapping - // when using the alternate buffer, which Gemini CLI does not use because we - // do not yet have support for scrolling in that mode. - if (!config.getScreenReader()) { - process.stdout.write('\x1b[?7l'); - } - - const useAlternateBuffer = isAlternateBufferEnabled(settings); - const mouseEventsEnabled = useAlternateBuffer; - if (mouseEventsEnabled) { - enableMouseEvents(); - } - - registerCleanup(() => { - // Re-enable line wrapping on exit. - process.stdout.write('\x1b[?7h'); - if (mouseEventsEnabled) { - disableMouseEvents(); - } - }); - - const version = await getCliVersion(); - setWindowTitle(basename(workspaceRoot), settings); - - // Create wrapper component to use hooks inside render - const AppWrapper = () => { - useKittyKeyboardProtocol(); - return ( - - - - - - - - - - - - - - ); - }; - - const instance = render( - process.env['DEBUG'] ? ( - - - - ) : ( - - ), - { - exitOnCtrlC: false, - isScreenReaderEnabled: config.getScreenReader(), - onRender: ({ renderTime }: { renderTime: number }) => { - if (renderTime > SLOW_RENDER_MS) { - recordSlowRender(config, renderTime); - } - }, - alternateBuffer: useAlternateBuffer, - }, - ); - - checkForUpdates(settings) - .then((info) => { - handleAutoUpdate(info, settings, config.getProjectRoot()); - }) - .catch((err) => { - // Silently ignore update check errors. - if (config.getDebugMode()) { - debugLogger.warn('Update check failed:', err); - } - }); - - registerCleanup(() => instance.unmount()); -} - -export async function main() { - setupUnhandledRejectionHandler(); - const settings = loadSettings(); - migrateDeprecatedSettings( - settings, - // Temporary extension manager only used during this non-interactive UI phase. - new ExtensionManager({ - workspaceDir: process.cwd(), - settings: settings.merged, - enabledExtensionOverrides: [], - requestConsent: requestConsentNonInteractive, - requestSetting: null, - }), - ); - await cleanupCheckpoints(); - - const argv = await parseArguments(settings.merged); - - // Check for invalid input combinations early to prevent crashes - if (argv.promptInteractive && !process.stdin.isTTY) { - debugLogger.error( - 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.', - ); - process.exit(1); - } - - const isDebugMode = cliConfig.isDebugMode(argv); - const consolePatcher = new ConsolePatcher({ - stderr: true, - debugMode: isDebugMode, - }); - consolePatcher.patch(); - registerCleanup(consolePatcher.cleanup); - - dns.setDefaultResultOrder( - validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), - ); - - // Set a default auth type if one isn't set. - if (!settings.merged.security?.auth?.selectedType) { - if (process.env['CLOUD_SHELL'] === 'true') { - settings.setValue( - SettingScope.User, - 'selectedAuthType', - AuthType.CLOUD_SHELL, - ); - } - } - - // Load custom themes from settings - themeManager.loadCustomThemes(settings.merged.ui?.customThemes); - - if (settings.merged.ui?.theme) { - if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { - // If the theme is not found during initial load, log a warning and continue. - // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. - debugLogger.warn( - `Warning: Theme "${settings.merged.ui?.theme}" not found.`, - ); - } - } - - // hop into sandbox if we are outside and sandboxing is enabled - if (!process.env['SANDBOX']) { - const memoryArgs = settings.merged.advanced?.autoConfigureMemory - ? getNodeMemoryArgs(isDebugMode) - : []; - const sandboxConfig = await loadSandboxConfig(settings.merged, argv); - // We intentionally omit the list of extensions here because extensions - // should not impact auth or setting up the sandbox. - // TODO(jacobr): refactor loadCliConfig so there is a minimal version - // that only initializes enough config to enable refreshAuth or find - // another way to decouple refreshAuth from requiring a config. - - if (sandboxConfig) { - const partialConfig = await loadCliConfig( - settings.merged, - sessionId, - argv, - ); - - if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { - // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. - try { - const err = validateAuthMethod( - settings.merged.security.auth.selectedType, - ); - if (err) { - throw new Error(err); - } - - await partialConfig.refreshAuth( - settings.merged.security.auth.selectedType, - ); - } catch (err) { - debugLogger.error('Error authenticating:', err); - process.exit(1); - } - } - let stdinData = ''; - if (!process.stdin.isTTY) { - stdinData = await readStdin(); - } - - // This function is a copy of the one from sandbox.ts - // It is moved here to decouple sandbox.ts from the CLI's argument structure. - const injectStdinIntoArgs = ( - args: string[], - stdinData?: string, - ): string[] => { - const finalArgs = [...args]; - if (stdinData) { - const promptIndex = finalArgs.findIndex( - (arg) => arg === '--prompt' || arg === '-p', - ); - if (promptIndex > -1 && finalArgs.length > promptIndex + 1) { - // If there's a prompt argument, prepend stdin to it - finalArgs[promptIndex + 1] = - `${stdinData}\n\n${finalArgs[promptIndex + 1]}`; - } else { - // If there's no prompt argument, add stdin as the prompt - finalArgs.push('--prompt', stdinData); - } - } - return finalArgs; - }; - - const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData); - - await relaunchOnExitCode(() => - start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs), - ); - process.exit(0); - } else { - // Relaunch app so we always have a child process that can be internally - // restarted if needed. - await relaunchAppInChildProcess(memoryArgs, []); - } - } - - // We are now past the logic handling potentially launching a child process - // to run Gemini CLI. It is now safe to perform expensive initialization that - // may have side effects. - { - const config = await loadCliConfig(settings.merged, sessionId, argv); - - const policyEngine = config.getPolicyEngine(); - const messageBus = config.getMessageBus(); - createPolicyUpdater(policyEngine, messageBus); - - // Cleanup sessions after config initialization - await cleanupExpiredSessions(config, settings.merged); - - if (config.getListExtensions()) { - debugLogger.log('Installed extensions:'); - for (const extension of config.getExtensions()) { - debugLogger.log(`- ${extension.name}`); - } - process.exit(0); - } - - // Handle --list-sessions flag - if (config.getListSessions()) { - await listSessions(config); - process.exit(0); - } - - // Handle --delete-session flag - const sessionToDelete = config.getDeleteSession(); - if (sessionToDelete) { - await deleteSession(config, sessionToDelete); - process.exit(0); - } - - const wasRaw = process.stdin.isRaw; - if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { - // Set this as early as possible to avoid spurious characters from - // input showing up in the output. - process.stdin.setRawMode(true); - - if (isAlternateBufferEnabled(settings)) { - process.stdout.write(ansiEscapes.enterAlternativeScreen); - - // Ink will cleanup so there is no need for us to manually cleanup. - } - - // This cleanup isn't strictly needed but may help in certain situations. - process.on('SIGTERM', () => { - process.stdin.setRawMode(wasRaw); - }); - process.on('SIGINT', () => { - process.stdin.setRawMode(wasRaw); - }); - - // Detect and enable Kitty keyboard protocol once at startup. - await detectAndEnableKittyProtocol(); - } - - setMaxSizedBoxDebugging(isDebugMode); - const initializationResult = await initializeApp(config, settings); - - if ( - settings.merged.security?.auth?.selectedType === - AuthType.LOGIN_WITH_GOOGLE && - config.isBrowserLaunchSuppressed() - ) { - // Do oauth before app renders to make copying the link possible. - await getOauthClient(settings.merged.security.auth.selectedType, config); - } - - if (config.getExperimentalZedIntegration()) { - return runZedIntegration(config, settings, argv); - } - - let input = config.getQuestion(); - const startupWarnings = [ - ...(await getStartupWarnings()), - ...(await getUserStartupWarnings()), - ]; - - // Handle --resume flag - let resumedSessionData: ResumedSessionData | undefined = undefined; - if (argv.resume) { - const sessionSelector = new SessionSelector(config); - try { - const result = await sessionSelector.resolveSession(argv.resume); - resumedSessionData = { - conversation: result.sessionData, - filePath: result.sessionPath, - }; - // Use the existing session ID to continue recording to the same session - config.setSessionId(resumedSessionData.conversation.sessionId); - } catch (error) { - console.error( - `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - process.exit(1); - } - } - - // Render UI, passing necessary config values. Check that there is no command line question. - if (config.isInteractive()) { - await startInteractiveUI( - config, - settings, - startupWarnings, - process.cwd(), - resumedSessionData, - initializationResult, - ); - return; - } - - await config.initialize(); - - // If not a TTY, read from stdin - // This is for cases where the user pipes input directly into the command - if (!process.stdin.isTTY) { - const stdinData = await readStdin(); - if (stdinData) { - input = `${stdinData}\n\n${input}`; - } - } - if (!input) { - debugLogger.error( - `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, - ); - process.exit(1); - } - - const prompt_id = Math.random().toString(16).slice(2); - logUserPrompt( - config, - new UserPromptEvent( - input.length, - prompt_id, - config.getContentGeneratorConfig()?.authType, - input, - ), - ); - - const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, - config, - settings, - ); - - if (config.getDebugMode()) { - debugLogger.log('Session ID: %s', sessionId); - } - - const hasDeprecatedPromptArg = process.argv.some((arg) => - arg.startsWith('--prompt'), - ); - await runNonInteractive({ - config: nonInteractiveConfig, - settings, - input, - prompt_id, - hasDeprecatedPromptArg, - resumedSessionData, - }); - // Call cleanup before process.exit, which causes cleanup to not run - await runExitCleanup(); - process.exit(0); - } -} - -function setWindowTitle(title: string, settings: LoadedSettings) { - if (!settings.merged.ui?.hideWindowTitle) { - const windowTitle = computeWindowTitle(title); - process.stdout.write(`\x1b]2;${windowTitle}\x07`); - - process.on('exit', () => { - process.stdout.write(`\x1b]2;\x07`); - }); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/nonInteractiveCli.ts b/apps/airiscode-cli/src/gemini-base/nonInteractiveCli.ts deleted file mode 100644 index a1186fdcb..000000000 --- a/apps/airiscode-cli/src/gemini-base/nonInteractiveCli.ts +++ /dev/null @@ -1,452 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - Config, - ToolCallRequestInfo, - ResumedSessionData, - CompletedToolCall, - UserFeedbackPayload, -} from '@airiscode/gemini-cli-core'; -import { isSlashCommand } from './ui/utils/commandUtils.js'; -import type { LoadedSettings } from './config/settings.js'; -import { - executeToolCall, - shutdownTelemetry, - isTelemetrySdkInitialized, - GeminiEventType, - FatalInputError, - promptIdContext, - OutputFormat, - JsonFormatter, - StreamJsonFormatter, - JsonStreamEventType, - uiTelemetryService, - debugLogger, - coreEvents, - CoreEvent, -} from '@airiscode/gemini-cli-core'; - -import type { Content, Part } from '@google/genai'; -import readline from 'node:readline'; - -import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js'; -import { handleSlashCommand } from './nonInteractiveCliCommands.js'; -import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; -import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; -import { - handleError, - handleToolError, - handleCancellationError, - handleMaxTurnsExceededError, -} from './utils/errors.js'; -import { TextOutput } from './ui/utils/textOutput.js'; - -interface RunNonInteractiveParams { - config: Config; - settings: LoadedSettings; - input: string; - prompt_id: string; - hasDeprecatedPromptArg?: boolean; - resumedSessionData?: ResumedSessionData; -} - -export async function runNonInteractive({ - config, - settings, - input, - prompt_id, - hasDeprecatedPromptArg, - resumedSessionData, -}: RunNonInteractiveParams): Promise { - return promptIdContext.run(prompt_id, async () => { - const consolePatcher = new ConsolePatcher({ - stderr: true, - debugMode: config.getDebugMode(), - }); - const textOutput = new TextOutput(); - - const handleUserFeedback = (payload: UserFeedbackPayload) => { - const prefix = payload.severity.toUpperCase(); - process.stderr.write(`[${prefix}] ${payload.message}\n`); - if (payload.error && config.getDebugMode()) { - const errorToLog = - payload.error instanceof Error - ? payload.error.stack || payload.error.message - : String(payload.error); - process.stderr.write(`${errorToLog}\n`); - } - }; - - const startTime = Date.now(); - const streamFormatter = - config.getOutputFormat() === OutputFormat.STREAM_JSON - ? new StreamJsonFormatter() - : null; - - const abortController = new AbortController(); - - // Track cancellation state - let isAborting = false; - let cancelMessageTimer: NodeJS.Timeout | null = null; - - // Setup stdin listener for Ctrl+C detection - let stdinWasRaw = false; - let rl: readline.Interface | null = null; - - const setupStdinCancellation = () => { - // Only setup if stdin is a TTY (user can interact) - if (!process.stdin.isTTY) { - return; - } - - // Save original raw mode state - stdinWasRaw = process.stdin.isRaw || false; - - // Enable raw mode to capture individual keypresses - process.stdin.setRawMode(true); - process.stdin.resume(); - - // Setup readline to emit keypress events - rl = readline.createInterface({ - input: process.stdin, - escapeCodeTimeout: 0, - }); - readline.emitKeypressEvents(process.stdin, rl); - - // Listen for Ctrl+C - const keypressHandler = ( - str: string, - key: { name?: string; ctrl?: boolean }, - ) => { - // Detect Ctrl+C: either ctrl+c key combo or raw character code 3 - if ((key && key.ctrl && key.name === 'c') || str === '\u0003') { - // Only handle once - if (isAborting) { - return; - } - - isAborting = true; - - // Only show message if cancellation takes longer than 200ms - // This reduces verbosity for fast cancellations - cancelMessageTimer = setTimeout(() => { - process.stderr.write('\nCancelling...\n'); - }, 200); - - abortController.abort(); - // Note: Don't exit here - let the abort flow through the system - // and trigger handleCancellationError() which will exit with proper code - } - }; - - process.stdin.on('keypress', keypressHandler); - }; - - const cleanupStdinCancellation = () => { - // Clear any pending cancel message timer - if (cancelMessageTimer) { - clearTimeout(cancelMessageTimer); - cancelMessageTimer = null; - } - - // Cleanup readline and stdin listeners - if (rl) { - rl.close(); - rl = null; - } - - // Remove keypress listener - process.stdin.removeAllListeners('keypress'); - - // Restore stdin to original state - if (process.stdin.isTTY) { - process.stdin.setRawMode(stdinWasRaw); - process.stdin.pause(); - } - }; - - let errorToHandle: unknown | undefined; - try { - consolePatcher.patch(); - - // Setup stdin cancellation listener - setupStdinCancellation(); - - coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); - coreEvents.drainFeedbackBacklog(); - - // Handle EPIPE errors when the output is piped to a command that closes early. - process.stdout.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EPIPE') { - // Exit gracefully if the pipe is closed. - process.exit(0); - } - }); - - const geminiClient = config.getGeminiClient(); - - // Initialize chat. Resume if resume data is passed. - if (resumedSessionData) { - await geminiClient.resumeChat( - convertSessionToHistoryFormats( - resumedSessionData.conversation.messages, - ).clientHistory, - resumedSessionData, - ); - } - - // Emit init event for streaming JSON - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.INIT, - timestamp: new Date().toISOString(), - session_id: config.getSessionId(), - model: config.getModel(), - }); - } - - let query: Part[] | undefined; - - if (isSlashCommand(input)) { - const slashCommandResult = await handleSlashCommand( - input, - abortController, - config, - settings, - ); - // If a slash command is found and returns a prompt, use it. - // Otherwise, slashCommandResult fall through to the default prompt - // handling. - if (slashCommandResult) { - query = slashCommandResult as Part[]; - } - } - - if (!query) { - const { processedQuery, shouldProceed } = await handleAtCommand({ - query: input, - config, - addItem: (_item, _timestamp) => 0, - onDebugMessage: () => {}, - messageId: Date.now(), - signal: abortController.signal, - }); - - if (!shouldProceed || !processedQuery) { - // An error occurred during @include processing (e.g., file not found). - // The error message is already logged by handleAtCommand. - throw new FatalInputError( - 'Exiting due to an error processing the @ command.', - ); - } - query = processedQuery as Part[]; - } - - // Emit user message event for streaming JSON - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.MESSAGE, - timestamp: new Date().toISOString(), - role: 'user', - content: input, - }); - } - - let currentMessages: Content[] = [{ role: 'user', parts: query }]; - - let turnCount = 0; - const deprecateText = - 'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n'; - if (hasDeprecatedPromptArg) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.MESSAGE, - timestamp: new Date().toISOString(), - role: 'assistant', - content: deprecateText, - delta: true, - }); - } else { - process.stderr.write(deprecateText); - } - } - while (true) { - turnCount++; - if ( - config.getMaxSessionTurns() >= 0 && - turnCount > config.getMaxSessionTurns() - ) { - handleMaxTurnsExceededError(config); - } - const toolCallRequests: ToolCallRequestInfo[] = []; - - const responseStream = geminiClient.sendMessageStream( - currentMessages[0]?.parts || [], - abortController.signal, - prompt_id, - ); - - let responseText = ''; - for await (const event of responseStream) { - if (abortController.signal.aborted) { - handleCancellationError(config); - } - - if (event.type === GeminiEventType.Content) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.MESSAGE, - timestamp: new Date().toISOString(), - role: 'assistant', - content: event.value, - delta: true, - }); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - responseText += event.value; - } else { - if (event.value) { - textOutput.write(event.value); - } - } - } else if (event.type === GeminiEventType.ToolCallRequest) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.TOOL_USE, - timestamp: new Date().toISOString(), - tool_name: event.value.name, - tool_id: event.value.callId, - parameters: event.value.args, - }); - } - toolCallRequests.push(event.value); - } else if (event.type === GeminiEventType.LoopDetected) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.ERROR, - timestamp: new Date().toISOString(), - severity: 'warning', - message: 'Loop detected, stopping execution', - }); - } - } else if (event.type === GeminiEventType.MaxSessionTurns) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.ERROR, - timestamp: new Date().toISOString(), - severity: 'error', - message: 'Maximum session turns exceeded', - }); - } - } else if (event.type === GeminiEventType.Error) { - throw event.value.error; - } - } - - if (toolCallRequests.length > 0) { - textOutput.ensureTrailingNewline(); - const toolResponseParts: Part[] = []; - const completedToolCalls: CompletedToolCall[] = []; - - for (const requestInfo of toolCallRequests) { - const completedToolCall = await executeToolCall( - config, - requestInfo, - abortController.signal, - ); - const toolResponse = completedToolCall.response; - - completedToolCalls.push(completedToolCall); - - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.TOOL_RESULT, - timestamp: new Date().toISOString(), - tool_id: requestInfo.callId, - status: toolResponse.error ? 'error' : 'success', - output: - typeof toolResponse.resultDisplay === 'string' - ? toolResponse.resultDisplay - : undefined, - error: toolResponse.error - ? { - type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR', - message: toolResponse.error.message, - } - : undefined, - }); - } - - if (toolResponse.error) { - handleToolError( - requestInfo.name, - toolResponse.error, - config, - toolResponse.errorType || 'TOOL_EXECUTION_ERROR', - typeof toolResponse.resultDisplay === 'string' - ? toolResponse.resultDisplay - : undefined, - ); - } - - if (toolResponse.responseParts) { - toolResponseParts.push(...toolResponse.responseParts); - } - } - - // Record tool calls with full metadata before sending responses to Gemini - try { - const currentModel = - geminiClient.getCurrentSequenceModel() ?? config.getModel(); - geminiClient - .getChat() - .recordCompletedToolCalls(currentModel, completedToolCalls); - } catch (error) { - debugLogger.error( - `Error recording completed tool call information: ${error}`, - ); - } - - currentMessages = [{ role: 'user', parts: toolResponseParts }]; - } else { - // Emit final result event for streaming JSON - if (streamFormatter) { - const metrics = uiTelemetryService.getMetrics(); - const durationMs = Date.now() - startTime; - streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, - timestamp: new Date().toISOString(), - status: 'success', - stats: streamFormatter.convertToStreamStats(metrics, durationMs), - }); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const stats = uiTelemetryService.getMetrics(); - textOutput.write(formatter.format(responseText, stats)); - } else { - textOutput.ensureTrailingNewline(); // Ensure a final newline - } - return; - } - } - } catch (error) { - errorToHandle = error; - } finally { - // Cleanup stdin cancellation before other cleanup - cleanupStdinCancellation(); - - consolePatcher.cleanup(); - coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); - if (isTelemetrySdkInitialized()) { - await shutdownTelemetry(config); - } - } - - if (errorToHandle) { - handleError(errorToHandle, config); - } - }); -} diff --git a/apps/airiscode-cli/src/gemini-base/nonInteractiveCliCommands.ts b/apps/airiscode-cli/src/gemini-base/nonInteractiveCliCommands.ts deleted file mode 100644 index 2e6a0008c..000000000 --- a/apps/airiscode-cli/src/gemini-base/nonInteractiveCliCommands.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { PartListUnion } from '@google/genai'; -import { parseSlashCommand } from './utils/commands.js'; -import { - FatalInputError, - Logger, - uiTelemetryService, - type Config, -} from '@airiscode/gemini-cli-core'; -import { CommandService } from './services/CommandService.js'; -import { FileCommandLoader } from './services/FileCommandLoader.js'; -import { McpPromptLoader } from './services/McpPromptLoader.js'; -import type { CommandContext } from './ui/commands/types.js'; -import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; -import type { LoadedSettings } from './config/settings.js'; -import type { SessionStatsState } from './ui/contexts/SessionContext.js'; - -/** - * Processes a slash command in a non-interactive environment. - * - * @returns A Promise that resolves to `PartListUnion` if a valid command is - * found and results in a prompt, or `undefined` otherwise. - * @throws {FatalInputError} if the command result is not supported in - * non-interactive mode. - */ -export const handleSlashCommand = async ( - rawQuery: string, - abortController: AbortController, - config: Config, - settings: LoadedSettings, -): Promise => { - const trimmed = rawQuery.trim(); - if (!trimmed.startsWith('/')) { - return; - } - - const commandService = await CommandService.create( - [new McpPromptLoader(config), new FileCommandLoader(config)], - abortController.signal, - ); - const commands = commandService.getCommands(); - - const { commandToExecute, args } = parseSlashCommand(rawQuery, commands); - - if (commandToExecute) { - if (commandToExecute.action) { - // Not used by custom commands but may be in the future. - const sessionStats: SessionStatsState = { - sessionId: config?.getSessionId(), - sessionStartTime: new Date(), - metrics: uiTelemetryService.getMetrics(), - lastPromptTokenCount: 0, - promptCount: 1, - }; - - const logger = new Logger(config?.getSessionId() || '', config?.storage); - - const context: CommandContext = { - services: { - config, - settings, - git: undefined, - logger, - }, - ui: createNonInteractiveUI(), - session: { - stats: sessionStats, - sessionShellAllowlist: new Set(), - }, - invocation: { - raw: trimmed, - name: commandToExecute.name, - args, - }, - }; - - const result = await commandToExecute.action(context, args); - - if (result) { - switch (result.type) { - case 'submit_prompt': - return result.content; - case 'confirm_shell_commands': - // This result indicates a command attempted to confirm shell commands. - // However note that currently, ShellTool is excluded in non-interactive - // mode unless 'YOLO mode' is active, so confirmation actually won't - // occur because of YOLO mode. - // This ensures that if a command *does* request confirmation (e.g. - // in the future with more granular permissions), it's handled appropriately. - throw new FatalInputError( - 'Exiting due to a confirmation prompt requested by the command.', - ); - default: - throw new FatalInputError( - 'Exiting due to command result that is not supported in non-interactive mode.', - ); - } - } - } - } - - return; -}; diff --git a/apps/airiscode-cli/src/gemini-base/services/BuiltinCommandLoader.ts b/apps/airiscode-cli/src/gemini-base/services/BuiltinCommandLoader.ts deleted file mode 100644 index bfeff2028..000000000 --- a/apps/airiscode-cli/src/gemini-base/services/BuiltinCommandLoader.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { isDevelopment } from '../utils/installationInfo.js'; -import type { ICommandLoader } from './types.js'; -import type { SlashCommand } from '../ui/commands/types.js'; -import type { Config } from '@airiscode/gemini-cli-core'; -import { aboutCommand } from '../ui/commands/aboutCommand.js'; -import { authCommand } from '../ui/commands/authCommand.js'; -import { bugCommand } from '../ui/commands/bugCommand.js'; -import { chatCommand } from '../ui/commands/chatCommand.js'; -import { clearCommand } from '../ui/commands/clearCommand.js'; -import { compressCommand } from '../ui/commands/compressCommand.js'; -import { copyCommand } from '../ui/commands/copyCommand.js'; -import { corgiCommand } from '../ui/commands/corgiCommand.js'; -import { docsCommand } from '../ui/commands/docsCommand.js'; -import { directoryCommand } from '../ui/commands/directoryCommand.js'; -import { editorCommand } from '../ui/commands/editorCommand.js'; -import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; -import { helpCommand } from '../ui/commands/helpCommand.js'; -import { ideCommand } from '../ui/commands/ideCommand.js'; -import { initCommand } from '../ui/commands/initCommand.js'; -import { mcpCommand } from '../ui/commands/mcpCommand.js'; -import { memoryCommand } from '../ui/commands/memoryCommand.js'; -import { modelCommand } from '../ui/commands/modelCommand.js'; -import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; -import { privacyCommand } from '../ui/commands/privacyCommand.js'; -import { policiesCommand } from '../ui/commands/policiesCommand.js'; -import { profileCommand } from '../ui/commands/profileCommand.js'; -import { quitCommand } from '../ui/commands/quitCommand.js'; -import { restoreCommand } from '../ui/commands/restoreCommand.js'; -import { statsCommand } from '../ui/commands/statsCommand.js'; -import { themeCommand } from '../ui/commands/themeCommand.js'; -import { toolsCommand } from '../ui/commands/toolsCommand.js'; -import { settingsCommand } from '../ui/commands/settingsCommand.js'; -import { vimCommand } from '../ui/commands/vimCommand.js'; -import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; -import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; - -/** - * Loads the core, hard-coded slash commands that are an integral part - * of the Gemini CLI application. - */ -export class BuiltinCommandLoader implements ICommandLoader { - constructor(private config: Config | null) {} - - /** - * Gathers all raw built-in command definitions, injects dependencies where - * needed (e.g., config) and filters out any that are not available. - * - * @param _signal An AbortSignal (unused for this synchronous loader). - * @returns A promise that resolves to an array of `SlashCommand` objects. - */ - async loadCommands(_signal: AbortSignal): Promise { - const allDefinitions: Array = [ - aboutCommand, - authCommand, - bugCommand, - chatCommand, - clearCommand, - compressCommand, - copyCommand, - corgiCommand, - docsCommand, - directoryCommand, - editorCommand, - extensionsCommand(this.config?.getEnableExtensionReloading()), - helpCommand, - await ideCommand(), - initCommand, - mcpCommand, - memoryCommand, - ...(this.config?.getUseModelRouter() ? [modelCommand] : []), - ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), - privacyCommand, - ...(this.config?.getEnableMessageBusIntegration() - ? [policiesCommand] - : []), - ...(isDevelopment ? [profileCommand] : []), - quitCommand, - restoreCommand(this.config), - statsCommand, - themeCommand, - toolsCommand, - settingsCommand, - vimCommand, - setupGithubCommand, - terminalSetupCommand, - ]; - - return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/services/FileCommandLoader.ts b/apps/airiscode-cli/src/gemini-base/services/FileCommandLoader.ts deleted file mode 100644 index f515efa1d..000000000 --- a/apps/airiscode-cli/src/gemini-base/services/FileCommandLoader.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import toml from '@iarna/toml'; -import { glob } from 'glob'; -import { z } from 'zod'; -import type { Config } from '@airiscode/gemini-cli-core'; -import { Storage } from '@airiscode/gemini-cli-core'; -import type { ICommandLoader } from './types.js'; -import type { - CommandContext, - SlashCommand, - SlashCommandActionReturn, -} from '../ui/commands/types.js'; -import { CommandKind } from '../ui/commands/types.js'; -import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; -import type { - IPromptProcessor, - PromptPipelineContent, -} from './prompt-processors/types.js'; -import { - SHORTHAND_ARGS_PLACEHOLDER, - SHELL_INJECTION_TRIGGER, - AT_FILE_INJECTION_TRIGGER, -} from './prompt-processors/types.js'; -import { - ConfirmationRequiredError, - ShellProcessor, -} from './prompt-processors/shellProcessor.js'; -import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; - -interface CommandDirectory { - path: string; - extensionName?: string; - extensionId?: string; -} - -/** - * Defines the Zod schema for a command definition file. This serves as the - * single source of truth for both validation and type inference. - */ -const TomlCommandDefSchema = z.object({ - prompt: z.string({ - required_error: "The 'prompt' field is required.", - invalid_type_error: "The 'prompt' field must be a string.", - }), - description: z.string().optional(), -}); - -/** - * Discovers and loads custom slash commands from .toml files in both the - * user's global config directory and the current project's directory. - * - * This loader is responsible for: - * - Recursively scanning command directories. - * - Parsing and validating TOML files. - * - Adapting valid definitions into executable SlashCommand objects. - * - Handling file system errors and malformed files gracefully. - */ -export class FileCommandLoader implements ICommandLoader { - private readonly projectRoot: string; - private readonly folderTrustEnabled: boolean; - private readonly isTrustedFolder: boolean; - - constructor(private readonly config: Config | null) { - this.folderTrustEnabled = !!config?.getFolderTrust(); - this.isTrustedFolder = !!config?.isTrustedFolder(); - this.projectRoot = config?.getProjectRoot() || process.cwd(); - } - - /** - * Loads all commands from user, project, and extension directories. - * Returns commands in order: user → project → extensions (alphabetically). - * - * Order is important for conflict resolution in CommandService: - * - User/project commands (without extensionName) use "last wins" strategy - * - Extension commands (with extensionName) get renamed if conflicts exist - * - * @param signal An AbortSignal to cancel the loading process. - * @returns A promise that resolves to an array of all loaded SlashCommands. - */ - async loadCommands(signal: AbortSignal): Promise { - if (this.folderTrustEnabled && !this.isTrustedFolder) { - return []; - } - - const allCommands: SlashCommand[] = []; - const globOptions = { - nodir: true, - dot: true, - signal, - follow: true, - }; - - // Load commands from each directory - const commandDirs = this.getCommandDirectories(); - for (const dirInfo of commandDirs) { - try { - const files = await glob('**/*.toml', { - ...globOptions, - cwd: dirInfo.path, - }); - - const commandPromises = files.map((file) => - this.parseAndAdaptFile( - path.join(dirInfo.path, file), - dirInfo.path, - dirInfo.extensionName, - dirInfo.extensionId, - ), - ); - - const commands = (await Promise.all(commandPromises)).filter( - (cmd): cmd is SlashCommand => cmd !== null, - ); - - // Add all commands without deduplication - allCommands.push(...commands); - } catch (error) { - if ( - !signal.aborted && - (error as { code?: string })?.code !== 'ENOENT' - ) { - console.error( - `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, - error, - ); - } - } - } - - return allCommands; - } - - /** - * Get all command directories in order for loading. - * User commands → Project commands → Extension commands - * This order ensures extension commands can detect all conflicts. - */ - private getCommandDirectories(): CommandDirectory[] { - const dirs: CommandDirectory[] = []; - - const storage = this.config?.storage ?? new Storage(this.projectRoot); - - // 1. User commands - dirs.push({ path: Storage.getUserCommandsDir() }); - - // 2. Project commands (override user commands) - dirs.push({ path: storage.getProjectCommandsDir() }); - - // 3. Extension commands (processed last to detect all conflicts) - if (this.config) { - const activeExtensions = this.config - .getExtensions() - .filter((ext) => ext.isActive) - .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading - - const extensionCommandDirs = activeExtensions.map((ext) => ({ - path: path.join(ext.path, 'commands'), - extensionName: ext.name, - extensionId: ext.id, - })); - - dirs.push(...extensionCommandDirs); - } - - return dirs; - } - - /** - * Parses a single .toml file and transforms it into a SlashCommand object. - * @param filePath The absolute path to the .toml file. - * @param baseDir The root command directory for name calculation. - * @param extensionName Optional extension name to prefix commands with. - * @returns A promise resolving to a SlashCommand, or null if the file is invalid. - */ - private async parseAndAdaptFile( - filePath: string, - baseDir: string, - extensionName?: string, - extensionId?: string, - ): Promise { - let fileContent: string; - try { - fileContent = await fs.readFile(filePath, 'utf-8'); - } catch (error: unknown) { - console.error( - `[FileCommandLoader] Failed to read file ${filePath}:`, - error instanceof Error ? error.message : String(error), - ); - return null; - } - - let parsed: unknown; - try { - parsed = toml.parse(fileContent); - } catch (error: unknown) { - console.error( - `[FileCommandLoader] Failed to parse TOML file ${filePath}:`, - error instanceof Error ? error.message : String(error), - ); - return null; - } - - const validationResult = TomlCommandDefSchema.safeParse(parsed); - - if (!validationResult.success) { - console.error( - `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, - validationResult.error.flatten(), - ); - return null; - } - - const validDef = validationResult.data; - - const relativePathWithExt = path.relative(baseDir, filePath); - const relativePath = relativePathWithExt.substring( - 0, - relativePathWithExt.length - 5, // length of '.toml' - ); - const baseCommandName = relativePath - .split(path.sep) - // Sanitize each path segment to prevent ambiguity. Since ':' is our - // namespace separator, we replace any literal colons in filenames - // with underscores to avoid naming conflicts. - .map((segment) => segment.replaceAll(':', '_')) - .join(':'); - - // Add extension name tag for extension commands - const defaultDescription = `Custom command from ${path.basename(filePath)}`; - let description = validDef.description || defaultDescription; - if (extensionName) { - description = `[${extensionName}] ${description}`; - } - - const processors: IPromptProcessor[] = []; - const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); - const usesShellInjection = validDef.prompt.includes( - SHELL_INJECTION_TRIGGER, - ); - const usesAtFileInjection = validDef.prompt.includes( - AT_FILE_INJECTION_TRIGGER, - ); - - // 1. @-File Injection (Security First). - // This runs first to ensure we're not executing shell commands that - // could dynamically generate malicious @-paths. - if (usesAtFileInjection) { - processors.push(new AtFileProcessor(baseCommandName)); - } - - // 2. Argument and Shell Injection. - // This runs after file content has been safely injected. - if (usesShellInjection || usesArgs) { - processors.push(new ShellProcessor(baseCommandName)); - } - - // 3. Default Argument Handling. - // Appends the raw invocation if no explicit {{args}} are used. - if (!usesArgs) { - processors.push(new DefaultArgumentProcessor()); - } - - return { - name: baseCommandName, - description, - kind: CommandKind.FILE, - extensionName, - extensionId, - action: async ( - context: CommandContext, - _args: string, - ): Promise => { - if (!context.invocation) { - console.error( - `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, - ); - return { - type: 'submit_prompt', - content: [{ text: validDef.prompt }], // Fallback to unprocessed prompt - }; - } - - try { - let processedContent: PromptPipelineContent = [ - { text: validDef.prompt }, - ]; - for (const processor of processors) { - processedContent = await processor.process( - processedContent, - context, - ); - } - - return { - type: 'submit_prompt', - content: processedContent, - }; - } catch (e) { - // Check if it's our specific error type - if (e instanceof ConfirmationRequiredError) { - // Halt and request confirmation from the UI layer. - return { - type: 'confirm_shell_commands', - commandsToConfirm: e.commandsToConfirm, - originalInvocation: { - raw: context.invocation.raw, - }, - }; - } - // Re-throw other errors to be handled by the global error handler. - throw e; - } - }, - }; - } -} diff --git a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/types.ts b/apps/airiscode-cli/src/gemini-base/services/prompt-processors/types.ts deleted file mode 100644 index c68765744..000000000 --- a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandContext } from '../../ui/commands/types.js'; -import type { PartUnion } from '@google/genai'; - -/** - * Defines the input/output type for prompt processors. - */ -export type PromptPipelineContent = PartUnion[]; - -/** - * Defines the interface for a prompt processor, a module that can transform - * a prompt string before it is sent to the model. Processors are chained - * together to create a processing pipeline. - */ -export interface IPromptProcessor { - /** - * Processes a prompt input (which may contain text and multi-modal parts), - * applying a specific transformation as part of a pipeline. - * - * @param prompt The current state of the prompt string. This may have been - * modified by previous processors in the pipeline. - * @param context The full command context, providing access to invocation - * details (like `context.invocation.raw` and `context.invocation.args`), - * application services, and UI handlers. - * @returns A promise that resolves to the transformed prompt string, which - * will be passed to the next processor or, if it's the last one, sent to the model. - */ - process( - prompt: PromptPipelineContent, - context: CommandContext, - ): Promise; -} - -/** - * The placeholder string for shorthand argument injection in custom commands. - * When used outside of !{...}, arguments are injected raw. - * When used inside !{...}, arguments are shell-escaped. - */ -export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}'; - -/** - * The trigger string for shell command injection in custom commands. - */ -export const SHELL_INJECTION_TRIGGER = '!{'; - -/** - * The trigger string for at file injection in custom commands. - */ -export const AT_FILE_INJECTION_TRIGGER = '@{'; diff --git a/apps/airiscode-cli/src/gemini-base/test-utils/async.ts b/apps/airiscode-cli/src/gemini-base/test-utils/async.ts deleted file mode 100644 index ad34cb581..000000000 --- a/apps/airiscode-cli/src/gemini-base/test-utils/async.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { act } from 'react'; - -// The waitFor from vitest doesn't properly wrap in act(), so we have to -// implement our own like the one in @testing-library/react -// or @testing-library/react-native -// The version of waitFor from vitest is still fine to use if you aren't waiting -// for React state updates. -export async function waitFor( - assertion: () => void, - { timeout = 1000, interval = 50 } = {}, -): Promise { - const startTime = Date.now(); - - while (true) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startTime > timeout) { - throw error; - } - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, interval)); - }); - } - } -} diff --git a/apps/airiscode-cli/src/gemini-base/test-utils/createExtension.ts b/apps/airiscode-cli/src/gemini-base/test-utils/createExtension.ts deleted file mode 100644 index 536ee3491..000000000 --- a/apps/airiscode-cli/src/gemini-base/test-utils/createExtension.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { - type MCPServerConfig, - type ExtensionInstallMetadata, -} from '@airiscode/gemini-cli-core'; -import { - EXTENSIONS_CONFIG_FILENAME, - INSTALL_METADATA_FILENAME, -} from '../config/extensions/variables.js'; -import type { ExtensionSetting } from '../config/extensions/extensionSettings.js'; - -export function createExtension({ - extensionsDir = 'extensions-dir', - name = 'my-extension', - version = '1.0.0', - addContextFile = false, - contextFileName = undefined as string | undefined, - mcpServers = {} as Record, - installMetadata = undefined as ExtensionInstallMetadata | undefined, - settings = undefined as ExtensionSetting[] | undefined, -} = {}): string { - const extDir = path.join(extensionsDir, name); - fs.mkdirSync(extDir, { recursive: true }); - fs.writeFileSync( - path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers, settings }), - ); - - if (addContextFile) { - fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context'); - } - - if (contextFileName) { - fs.writeFileSync(path.join(extDir, contextFileName), 'context'); - } - - if (installMetadata) { - fs.writeFileSync( - path.join(extDir, INSTALL_METADATA_FILENAME), - JSON.stringify(installMetadata), - ); - } - return extDir; -} diff --git a/apps/airiscode-cli/src/gemini-base/test-utils/render.tsx b/apps/airiscode-cli/src/gemini-base/test-utils/render.tsx deleted file mode 100644 index a6cfba211..000000000 --- a/apps/airiscode-cli/src/gemini-base/test-utils/render.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render as inkRender } from 'ink-testing-library'; -import { Box } from 'ink'; -import type React from 'react'; -import { act } from 'react'; -import { LoadedSettings, type Settings } from '../config/settings.js'; -import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; -import { SettingsContext } from '../ui/contexts/SettingsContext.js'; -import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; -import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; -import { StreamingState } from '../ui/types.js'; -import { ConfigContext } from '../ui/contexts/ConfigContext.js'; -import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; -import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; -import { MouseProvider } from '../ui/contexts/MouseContext.js'; -import { ScrollProvider } from '../ui/contexts/ScrollProvider.js'; -import { StreamingContext } from '../ui/contexts/StreamingContext.js'; - -import { type Config } from '@airiscode/gemini-cli-core'; - -// Wrapper around ink-testing-library's render that ensures act() is called -export const render = ( - tree: React.ReactElement, - terminalWidth?: number, -): ReturnType => { - let renderResult: ReturnType = - undefined as unknown as ReturnType; - act(() => { - renderResult = inkRender(tree); - }); - - if (terminalWidth !== undefined && renderResult?.stdout) { - // Override the columns getter on the stdout instance provided by ink-testing-library - Object.defineProperty(renderResult.stdout, 'columns', { - get: () => terminalWidth, - configurable: true, - }); - - // Trigger a rerender so Ink can pick up the new terminal width - act(() => { - renderResult.rerender(tree); - }); - } - - const originalUnmount = renderResult.unmount; - const originalRerender = renderResult.rerender; - - return { - ...renderResult, - unmount: () => { - act(() => { - originalUnmount(); - }); - }, - rerender: (newTree: React.ReactElement) => { - act(() => { - originalRerender(newTree); - }); - }, - }; -}; - -const mockConfig = { - getModel: () => 'gemini-pro', - getTargetDir: () => - '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', - getDebugMode: () => false, - isTrustedFolder: () => true, - getIdeMode: () => false, - getEnableInteractiveShell: () => true, -}; - -const configProxy = new Proxy(mockConfig, { - get(target, prop) { - if (prop in target) { - return target[prop as keyof typeof target]; - } - throw new Error(`mockConfig does not have property ${String(prop)}`); - }, -}); - -export const mockSettings = new LoadedSettings( - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - true, - new Set(), -); - -export const createMockSettings = ( - overrides: Partial, -): LoadedSettings => { - const settings = overrides as Settings; - return new LoadedSettings( - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings, originalSettings: settings }, - { path: '', settings: {}, originalSettings: {} }, - true, - new Set(), - ); -}; - -// A minimal mock UIState to satisfy the context provider. -// Tests that need specific UIState values should provide their own. -const baseMockUiState = { - renderMarkdown: true, - streamingState: StreamingState.Idle, - mainAreaWidth: 100, - terminalWidth: 120, - currentModel: 'gemini-pro', -}; - -export const renderWithProviders = ( - component: React.ReactElement, - { - shellFocus = true, - settings = mockSettings, - uiState: providedUiState, - width, - mouseEventsEnabled = false, - config = configProxy as unknown as Config, - useAlternateBuffer, - }: { - shellFocus?: boolean; - settings?: LoadedSettings; - uiState?: Partial; - width?: number; - mouseEventsEnabled?: boolean; - config?: Config; - useAlternateBuffer?: boolean; - } = {}, -): ReturnType => { - const baseState: UIState = new Proxy( - { ...baseMockUiState, ...providedUiState }, - { - get(target, prop) { - if (prop in target) { - return target[prop as keyof typeof target]; - } - // For properties not in the base mock or provided state, - // we'll check the original proxy to see if it's a defined but - // unprovided property, and if not, throw. - if (prop in baseMockUiState) { - return baseMockUiState[prop as keyof typeof baseMockUiState]; - } - throw new Error(`mockUiState does not have property ${String(prop)}`); - }, - }, - ) as UIState; - - const terminalWidth = width ?? baseState.terminalWidth; - let finalSettings = settings; - if (useAlternateBuffer !== undefined) { - finalSettings = createMockSettings({ - ...settings.merged, - ui: { - ...settings.merged.ui, - useAlternateBuffer, - }, - }); - } - - const mainAreaWidth = calculateMainAreaWidth(terminalWidth, finalSettings); - - const finalUiState = { - ...baseState, - terminalWidth, - mainAreaWidth, - }; - - return render( - - - - - - - - - - - {component} - - - - - - - - - - , - terminalWidth, - ); -}; - -export function renderHook( - renderCallback: (props: Props) => Result, - options?: { - initialProps?: Props; - wrapper?: React.ComponentType<{ children: React.ReactNode }>; - }, -): { - result: { current: Result }; - rerender: (props?: Props) => void; - unmount: () => void; -} { - const result = { current: undefined as unknown as Result }; - let currentProps = options?.initialProps as Props; - - function TestComponent({ - renderCallback, - props, - }: { - renderCallback: (props: Props) => Result; - props: Props; - }) { - result.current = renderCallback(props); - return null; - } - - const Wrapper = options?.wrapper || (({ children }) => <>{children}); - - let inkRerender: (tree: React.ReactElement) => void = () => {}; - let unmount: () => void = () => {}; - - act(() => { - const renderResult = render( - - - , - ); - inkRerender = renderResult.rerender; - unmount = renderResult.unmount; - }); - - function rerender(props?: Props) { - if (arguments.length > 0) { - currentProps = props as Props; - } - act(() => { - inkRerender( - - - , - ); - }); - } - - return { result, rerender, unmount }; -} diff --git a/apps/airiscode-cli/src/gemini-base/ui b/apps/airiscode-cli/src/gemini-base/ui deleted file mode 120000 index 96937b07b..000000000 --- a/apps/airiscode-cli/src/gemini-base/ui +++ /dev/null @@ -1 +0,0 @@ -../ui-full \ No newline at end of file diff --git a/apps/airiscode-cli/src/gemini-base/utils/commentJson.ts b/apps/airiscode-cli/src/gemini-base/utils/commentJson.ts deleted file mode 100644 index f8a38a078..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/commentJson.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import { parse, stringify } from 'comment-json'; -import { coreEvents } from '@airiscode/gemini-cli-core'; - -/** - * Type representing an object that may contain Symbol keys for comments. - */ -type CommentedRecord = Record; - -/** - * Updates a JSON file while preserving comments and formatting. - */ -export function updateSettingsFilePreservingFormat( - filePath: string, - updates: Record, -): void { - if (!fs.existsSync(filePath)) { - fs.writeFileSync(filePath, JSON.stringify(updates, null, 2), 'utf-8'); - return; - } - - const originalContent = fs.readFileSync(filePath, 'utf-8'); - - let parsed: Record; - try { - parsed = parse(originalContent) as Record; - } catch (error) { - coreEvents.emitFeedback( - 'error', - 'Error parsing settings file. Please check the JSON syntax.', - error, - ); - return; - } - - const updatedStructure = applyUpdates(parsed, updates); - const updatedContent = stringify(updatedStructure, null, 2); - - fs.writeFileSync(filePath, updatedContent, 'utf-8'); -} - -/** - * When deleting a property from a comment-json parsed object, relocate any - * leading/trailing comments that were attached to that property so they are not lost. - * - * This function re-attaches comments to the next sibling's leading comments if - * available, otherwise to the previous sibling's trailing comments, otherwise - * to the container's leading/trailing comments. - */ -function preserveCommentsOnPropertyDeletion( - container: Record, - propName: string, -): void { - const target = container as CommentedRecord; - const beforeSym = Symbol.for(`before:${propName}`); - const afterSym = Symbol.for(`after:${propName}`); - - const beforeComments = target[beforeSym] as unknown[] | undefined; - const afterComments = target[afterSym] as unknown[] | undefined; - - if (!beforeComments && !afterComments) return; - - const keys = Object.getOwnPropertyNames(container); - const idx = keys.indexOf(propName); - const nextKey = idx >= 0 && idx + 1 < keys.length ? keys[idx + 1] : undefined; - const prevKey = idx > 0 ? keys[idx - 1] : undefined; - - function appendToSymbol(destSym: symbol, comments: unknown[]) { - if (!comments || comments.length === 0) return; - const existing = target[destSym]; - target[destSym] = Array.isArray(existing) - ? existing.concat(comments) - : comments; - } - - if (beforeComments && beforeComments.length > 0) { - if (nextKey) { - appendToSymbol(Symbol.for(`before:${nextKey}`), beforeComments); - } else if (prevKey) { - appendToSymbol(Symbol.for(`after:${prevKey}`), beforeComments); - } else { - appendToSymbol(Symbol.for('before'), beforeComments); - } - delete target[beforeSym]; - } - - if (afterComments && afterComments.length > 0) { - if (nextKey) { - appendToSymbol(Symbol.for(`before:${nextKey}`), afterComments); - } else if (prevKey) { - appendToSymbol(Symbol.for(`after:${prevKey}`), afterComments); - } else { - appendToSymbol(Symbol.for('after'), afterComments); - } - delete target[afterSym]; - } -} - -/** - * Applies sync-by-omission semantics: synchronizes base to match desired. - * - Adds/updates keys from desired - * - Removes keys from base that are not in desired - * - Recursively applies to nested objects - * - Preserves comments when deleting keys - */ -function applyKeyDiff( - base: Record, - desired: Record, -): void { - for (const existingKey of Object.getOwnPropertyNames(base)) { - if (!Object.prototype.hasOwnProperty.call(desired, existingKey)) { - preserveCommentsOnPropertyDeletion(base, existingKey); - delete base[existingKey]; - } - } - - for (const nextKey of Object.getOwnPropertyNames(desired)) { - const nextVal = desired[nextKey]; - const baseVal = base[nextKey]; - - const isObj = - typeof nextVal === 'object' && - nextVal !== null && - !Array.isArray(nextVal); - const isBaseObj = - typeof baseVal === 'object' && - baseVal !== null && - !Array.isArray(baseVal); - const isArr = Array.isArray(nextVal); - const isBaseArr = Array.isArray(baseVal); - - if (isObj && isBaseObj) { - applyKeyDiff( - baseVal as Record, - nextVal as Record, - ); - } else if (isArr && isBaseArr) { - // In-place mutate arrays to preserve array-level comments on CommentArray - const baseArr = baseVal as unknown[]; - const desiredArr = nextVal as unknown[]; - baseArr.length = 0; - for (const el of desiredArr) { - baseArr.push(el); - } - } else { - base[nextKey] = nextVal; - } - } -} - -function applyUpdates( - current: Record, - updates: Record, -): Record { - // Apply sync-by-omission semantics consistently at all levels - applyKeyDiff(current, updates); - return current; -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/dialogScopeUtils.ts b/apps/airiscode-cli/src/gemini-base/utils/dialogScopeUtils.ts deleted file mode 100644 index ccf93b6a6..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/dialogScopeUtils.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - LoadableSettingScope, - LoadedSettings, -} from '../config/settings.js'; -import { isLoadableSettingScope, SettingScope } from '../config/settings.js'; -import { settingExistsInScope } from './settingsUtils.js'; - -/** - * Shared scope labels for dialog components that need to display setting scopes - */ -export const SCOPE_LABELS = { - [SettingScope.User]: 'User Settings', - [SettingScope.Workspace]: 'Workspace Settings', - [SettingScope.System]: 'System Settings', -} as const; - -/** - * Helper function to get scope items for radio button selects - */ -export function getScopeItems(): Array<{ - label: string; - value: LoadableSettingScope; -}> { - return [ - { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, - { - label: SCOPE_LABELS[SettingScope.Workspace], - value: SettingScope.Workspace, - }, - { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, - ]; -} - -/** - * Generate scope message for a specific setting - */ -export function getScopeMessageForSetting( - settingKey: string, - selectedScope: LoadableSettingScope, - settings: LoadedSettings, -): string { - const otherScopes = Object.values(SettingScope) - .filter(isLoadableSettingScope) - .filter((scope) => scope !== selectedScope); - - const modifiedInOtherScopes = otherScopes.filter((scope) => { - const scopeSettings = settings.forScope(scope).settings; - return settingExistsInScope(settingKey, scopeSettings); - }); - - if (modifiedInOtherScopes.length === 0) { - return ''; - } - - const modifiedScopesStr = modifiedInOtherScopes.join(', '); - const currentScopeSettings = settings.forScope(selectedScope).settings; - const existsInCurrentScope = settingExistsInScope( - settingKey, - currentScopeSettings, - ); - - return existsInCurrentScope - ? `(Also modified in ${modifiedScopesStr})` - : `(Modified in ${modifiedScopesStr})`; -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/envVarResolver.ts b/apps/airiscode-cli/src/gemini-base/utils/envVarResolver.ts deleted file mode 100644 index 7374024e9..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/envVarResolver.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Resolves environment variables in a string. - * Replaces $VAR_NAME and ${VAR_NAME} with their corresponding environment variable values. - * If the environment variable is not defined, the original placeholder is preserved. - * - * @param value - The string that may contain environment variable placeholders - * @returns The string with environment variables resolved - * - * @example - * resolveEnvVarsInString("Token: $API_KEY") // Returns "Token: secret-123" - * resolveEnvVarsInString("URL: ${BASE_URL}/api") // Returns "URL: https://api.example.com/api" - * resolveEnvVarsInString("Missing: $UNDEFINED_VAR") // Returns "Missing: $UNDEFINED_VAR" - */ -export function resolveEnvVarsInString( - value: string, - customEnv?: Record, -): string { - const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} - return value.replace(envVarRegex, (match, varName1, varName2) => { - const varName = varName1 || varName2; - if (customEnv && typeof customEnv[varName] === 'string') { - return customEnv[varName]!; - } - if (process && process.env && typeof process.env[varName] === 'string') { - return process.env[varName]!; - } - return match; - }); -} - -/** - * Recursively resolves environment variables in an object of any type. - * Handles strings, arrays, nested objects, and preserves other primitive types. - * Protected against circular references using a WeakSet to track visited objects. - * - * @param obj - The object to process for environment variable resolution - * @returns A new object with environment variables resolved - * - * @example - * const config = { - * server: { - * host: "$HOST", - * port: "${PORT}", - * enabled: true, - * tags: ["$ENV", "api"] - * } - * }; - * const resolved = resolveEnvVarsInObject(config); - */ -export function resolveEnvVarsInObject( - obj: T, - customEnv?: Record, -): T { - return resolveEnvVarsInObjectInternal(obj, new WeakSet(), customEnv); -} - -/** - * Internal implementation of resolveEnvVarsInObject with circular reference protection. - * - * @param obj - The object to process - * @param visited - WeakSet to track visited objects and prevent circular references - * @returns A new object with environment variables resolved - */ -function resolveEnvVarsInObjectInternal( - obj: T, - visited: WeakSet, - customEnv?: Record, -): T { - if ( - obj === null || - obj === undefined || - typeof obj === 'boolean' || - typeof obj === 'number' - ) { - return obj; - } - - if (typeof obj === 'string') { - return resolveEnvVarsInString(obj, customEnv) as unknown as T; - } - - if (Array.isArray(obj)) { - // Check for circular reference - if (visited.has(obj)) { - // Return a shallow copy to break the cycle - return [...obj] as unknown as T; - } - - visited.add(obj); - const result = obj.map((item) => - resolveEnvVarsInObjectInternal(item, visited, customEnv), - ) as unknown as T; - visited.delete(obj); - return result; - } - - if (typeof obj === 'object') { - // Check for circular reference - if (visited.has(obj as object)) { - // Return a shallow copy to break the cycle - return { ...obj } as T; - } - - visited.add(obj as object); - const newObj = { ...obj } as T; - for (const key in newObj) { - if (Object.prototype.hasOwnProperty.call(newObj, key)) { - newObj[key] = resolveEnvVarsInObjectInternal( - newObj[key], - visited, - customEnv, - ); - } - } - visited.delete(obj as object); - return newObj; - } - - return obj; -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/errors.ts b/apps/airiscode-cli/src/gemini-base/utils/errors.ts deleted file mode 100644 index c759e99ca..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/errors.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Config } from '@airiscode/gemini-cli-core'; -import { - OutputFormat, - JsonFormatter, - StreamJsonFormatter, - JsonStreamEventType, - uiTelemetryService, - parseAndFormatApiError, - FatalTurnLimitedError, - FatalCancellationError, - FatalToolExecutionError, - isFatalToolError, -} from '@airiscode/gemini-cli-core'; - -export function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -interface ErrorWithCode extends Error { - exitCode?: number; - code?: string | number; - status?: string | number; -} - -/** - * Extracts the appropriate error code from an error object. - */ -function extractErrorCode(error: unknown): string | number { - const errorWithCode = error as ErrorWithCode; - - // Prioritize exitCode for FatalError types, fall back to other codes - if (typeof errorWithCode.exitCode === 'number') { - return errorWithCode.exitCode; - } - if (errorWithCode.code !== undefined) { - return errorWithCode.code; - } - if (errorWithCode.status !== undefined) { - return errorWithCode.status; - } - - return 1; // Default exit code -} - -/** - * Converts an error code to a numeric exit code. - */ -function getNumericExitCode(errorCode: string | number): number { - return typeof errorCode === 'number' ? errorCode : 1; -} - -/** - * Handles errors consistently for both JSON and text output formats. - * In JSON mode, outputs formatted JSON error and exits. - * In streaming JSON mode, emits a result event with error status. - * In text mode, outputs error message and re-throws. - */ -export function handleError( - error: unknown, - config: Config, - customErrorCode?: string | number, -): never { - const errorMessage = parseAndFormatApiError( - error, - config.getContentGeneratorConfig()?.authType, - ); - - if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { - const streamFormatter = new StreamJsonFormatter(); - const errorCode = customErrorCode ?? extractErrorCode(error); - const metrics = uiTelemetryService.getMetrics(); - - streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, - timestamp: new Date().toISOString(), - status: 'error', - error: { - type: error instanceof Error ? error.constructor.name : 'Error', - message: errorMessage, - }, - stats: streamFormatter.convertToStreamStats(metrics, 0), - }); - - process.exit(getNumericExitCode(errorCode)); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const errorCode = customErrorCode ?? extractErrorCode(error); - - const formattedError = formatter.formatError( - error instanceof Error ? error : new Error(getErrorMessage(error)), - errorCode, - ); - - console.error(formattedError); - process.exit(getNumericExitCode(errorCode)); - } else { - console.error(errorMessage); - throw error; - } -} - -/** - * Handles tool execution errors specifically. - * - * Fatal errors (e.g., NO_SPACE_LEFT) cause the CLI to exit immediately, - * as they indicate unrecoverable system state. - * - * Non-fatal errors (e.g., INVALID_TOOL_PARAMS, FILE_NOT_FOUND, PATH_NOT_IN_WORKSPACE) - * are logged to stderr and the error response is sent back to the model, - * allowing it to self-correct. - */ -export function handleToolError( - toolName: string, - toolError: Error, - config: Config, - errorType?: string, - resultDisplay?: string, -): void { - const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`; - - const isFatal = isFatalToolError(errorType); - - if (isFatal) { - const toolExecutionError = new FatalToolExecutionError(errorMessage); - if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { - const streamFormatter = new StreamJsonFormatter(); - const metrics = uiTelemetryService.getMetrics(); - streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, - timestamp: new Date().toISOString(), - status: 'error', - error: { - type: errorType ?? 'FatalToolExecutionError', - message: toolExecutionError.message, - }, - stats: streamFormatter.convertToStreamStats(metrics, 0), - }); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const formattedError = formatter.formatError( - toolExecutionError, - errorType ?? toolExecutionError.exitCode, - ); - console.error(formattedError); - } else { - console.error(errorMessage); - } - process.exit(toolExecutionError.exitCode); - } - - // Non-fatal: log and continue - console.error(errorMessage); -} - -/** - * Handles cancellation/abort signals consistently. - */ -export function handleCancellationError(config: Config): never { - const cancellationError = new FatalCancellationError('Operation cancelled.'); - - if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { - const streamFormatter = new StreamJsonFormatter(); - const metrics = uiTelemetryService.getMetrics(); - streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, - timestamp: new Date().toISOString(), - status: 'error', - error: { - type: 'FatalCancellationError', - message: cancellationError.message, - }, - stats: streamFormatter.convertToStreamStats(metrics, 0), - }); - process.exit(cancellationError.exitCode); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const formattedError = formatter.formatError( - cancellationError, - cancellationError.exitCode, - ); - - console.error(formattedError); - process.exit(cancellationError.exitCode); - } else { - console.error(cancellationError.message); - process.exit(cancellationError.exitCode); - } -} - -/** - * Handles max session turns exceeded consistently. - */ -export function handleMaxTurnsExceededError(config: Config): never { - const maxTurnsError = new FatalTurnLimitedError( - 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', - ); - - if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { - const streamFormatter = new StreamJsonFormatter(); - const metrics = uiTelemetryService.getMetrics(); - streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, - timestamp: new Date().toISOString(), - status: 'error', - error: { - type: 'FatalTurnLimitedError', - message: maxTurnsError.message, - }, - stats: streamFormatter.convertToStreamStats(metrics, 0), - }); - process.exit(maxTurnsError.exitCode); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const formattedError = formatter.formatError( - maxTurnsError, - maxTurnsError.exitCode, - ); - - console.error(formattedError); - process.exit(maxTurnsError.exitCode); - } else { - console.error(maxTurnsError.message); - process.exit(maxTurnsError.exitCode); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/events.ts b/apps/airiscode-cli/src/gemini-base/utils/events.ts deleted file mode 100644 index 8808c6332..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/events.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ExtensionEvents, McpClient } from '@airiscode/gemini-cli-core'; -import { EventEmitter } from 'node:events'; - -export enum AppEvent { - OpenDebugConsole = 'open-debug-console', - LogError = 'log-error', - OauthDisplayMessage = 'oauth-display-message', - Flicker = 'flicker', - McpClientUpdate = 'mcp-client-update', -} - -export interface AppEvents extends ExtensionEvents { - [AppEvent.OpenDebugConsole]: never[]; - [AppEvent.LogError]: string[]; - [AppEvent.OauthDisplayMessage]: string[]; - [AppEvent.Flicker]: never[]; - [AppEvent.McpClientUpdate]: Array | never>; -} - -export const appEvents = new EventEmitter(); diff --git a/apps/airiscode-cli/src/gemini-base/utils/gitUtils.ts b/apps/airiscode-cli/src/gemini-base/utils/gitUtils.ts deleted file mode 100644 index 81914d81c..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/gitUtils.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { debugLogger } from '@airiscode/gemini-cli-core'; -import { execSync } from 'node:child_process'; -import { ProxyAgent } from 'undici'; - -/** - * Checks if a directory is within a git repository hosted on GitHub. - * @returns true if the directory is in a git repository with a github.com remote, false otherwise - */ -export const isGitHubRepository = (): boolean => { - try { - const remotes = ( - execSync('git remote -v', { - encoding: 'utf-8', - }) || '' - ).trim(); - - const pattern = /github\.com/; - - return pattern.test(remotes); - } catch (_error) { - // If any filesystem error occurs, assume not a git repo - debugLogger.debug(`Failed to get git remote:`, _error); - return false; - } -}; - -/** - * getGitRepoRoot returns the root directory of the git repository. - * @returns the path to the root of the git repo. - * @throws error if the exec command fails. - */ -export const getGitRepoRoot = (): string => { - const gitRepoRoot = ( - execSync('git rev-parse --show-toplevel', { - encoding: 'utf-8', - }) || '' - ).trim(); - - if (!gitRepoRoot) { - throw new Error(`Git repo returned empty value`); - } - - return gitRepoRoot; -}; - -/** - * getLatestGitHubRelease returns the release tag as a string. - * @returns string of the release tag (e.g. "v1.2.3"). - */ -export const getLatestGitHubRelease = async ( - proxy?: string, -): Promise => { - try { - const controller = new AbortController(); - - const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`; - - const response = await fetch(endpoint, { - method: 'GET', - headers: { - Accept: 'application/vnd.github+json', - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', - }, - dispatcher: proxy ? new ProxyAgent(proxy) : undefined, - signal: AbortSignal.any([AbortSignal.timeout(30_000), controller.signal]), - } as RequestInit); - - if (!response.ok) { - throw new Error( - `Invalid response code: ${response.status} - ${response.statusText}`, - ); - } - - const releaseTag = (await response.json()).tag_name; - if (!releaseTag) { - throw new Error(`Response did not include tag_name field`); - } - return releaseTag; - } catch (_error) { - debugLogger.debug( - `Failed to determine latest run-gemini-cli release:`, - _error, - ); - throw new Error( - `Unable to determine the latest run-gemini-cli release on GitHub.`, - ); - } -}; - -/** - * getGitHubRepoInfo returns the owner and repository for a GitHub repo. - * @returns the owner and repository of the github repo. - * @throws error if the exec command fails. - */ -export function getGitHubRepoInfo(): { owner: string; repo: string } { - const remoteUrl = execSync('git remote get-url origin', { - encoding: 'utf-8', - }).trim(); - - // Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git - const match = remoteUrl.match( - /(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/, - ); - - // If the regex fails match, throw an error. - if (!match || !match[1] || !match[2]) { - throw new Error( - `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, - ); - } - - return { owner: match[1], repo: match[2] }; -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/installationInfo.ts b/apps/airiscode-cli/src/gemini-base/utils/installationInfo.ts deleted file mode 100644 index a2260af16..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/installationInfo.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { debugLogger, isGitRepository } from '@airiscode/gemini-cli-core'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as childProcess from 'node:child_process'; -import process from 'node:process'; - -export const isDevelopment = process.env['NODE_ENV'] === 'development'; - -export enum PackageManager { - NPM = 'npm', - YARN = 'yarn', - PNPM = 'pnpm', - PNPX = 'pnpx', - BUN = 'bun', - BUNX = 'bunx', - HOMEBREW = 'homebrew', - NPX = 'npx', - UNKNOWN = 'unknown', -} - -export interface InstallationInfo { - packageManager: PackageManager; - isGlobal: boolean; - updateCommand?: string; - updateMessage?: string; -} - -export function getInstallationInfo( - projectRoot: string, - isAutoUpdateDisabled: boolean, -): InstallationInfo { - const cliPath = process.argv[1]; - if (!cliPath) { - return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; - } - - try { - // Normalize path separators to forward slashes for consistent matching. - const realPath = fs.realpathSync(cliPath).replace(/\\/g, '/'); - const normalizedProjectRoot = projectRoot?.replace(/\\/g, '/'); - const isGit = isGitRepository(process.cwd()); - - // Check for local git clone first - if ( - isGit && - normalizedProjectRoot && - realPath.startsWith(normalizedProjectRoot) && - !realPath.includes('/node_modules/') - ) { - return { - packageManager: PackageManager.UNKNOWN, // Not managed by a package manager in this sense - isGlobal: false, - updateMessage: - 'Running from a local git clone. Please update with "git pull".', - }; - } - - // Check for npx/pnpx - if (realPath.includes('/.npm/_npx') || realPath.includes('/npm/_npx')) { - return { - packageManager: PackageManager.NPX, - isGlobal: false, - updateMessage: 'Running via npx, update not applicable.', - }; - } - if (realPath.includes('/.pnpm/_pnpx')) { - return { - packageManager: PackageManager.PNPX, - isGlobal: false, - updateMessage: 'Running via pnpx, update not applicable.', - }; - } - - // Check for Homebrew - if (process.platform === 'darwin') { - try { - // The package name in homebrew is gemini-cli - childProcess.execSync('brew list -1 | grep -q "^gemini-cli$"', { - stdio: 'ignore', - }); - return { - packageManager: PackageManager.HOMEBREW, - isGlobal: true, - updateMessage: - 'Installed via Homebrew. Please update with "brew upgrade".', - }; - } catch (_error) { - // Brew is not installed or gemini-cli is not installed via brew. - // Continue to the next check. - } - } - - // Check for pnpm - if (realPath.includes('/.pnpm/global')) { - const updateCommand = 'pnpm add -g @google/gemini-cli@latest'; - return { - packageManager: PackageManager.PNPM, - isGlobal: true, - updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with pnpm. Attempting to automatically update now...', - }; - } - - // Check for yarn - if (realPath.includes('/.yarn/global')) { - const updateCommand = 'yarn global add @google/gemini-cli@latest'; - return { - packageManager: PackageManager.YARN, - isGlobal: true, - updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with yarn. Attempting to automatically update now...', - }; - } - - // Check for bun - if (realPath.includes('/.bun/install/cache')) { - return { - packageManager: PackageManager.BUNX, - isGlobal: false, - updateMessage: 'Running via bunx, update not applicable.', - }; - } - if (realPath.includes('/.bun/bin')) { - const updateCommand = 'bun add -g @google/gemini-cli@latest'; - return { - packageManager: PackageManager.BUN, - isGlobal: true, - updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with bun. Attempting to automatically update now...', - }; - } - - // Check for local install - if ( - normalizedProjectRoot && - realPath.startsWith(`${normalizedProjectRoot}/node_modules`) - ) { - let pm = PackageManager.NPM; - if (fs.existsSync(path.join(projectRoot, 'yarn.lock'))) { - pm = PackageManager.YARN; - } else if (fs.existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) { - pm = PackageManager.PNPM; - } else if (fs.existsSync(path.join(projectRoot, 'bun.lockb'))) { - pm = PackageManager.BUN; - } - return { - packageManager: pm, - isGlobal: false, - updateMessage: - "Locally installed. Please update via your project's package.json.", - }; - } - - // Assume global npm - const updateCommand = 'npm install -g @google/gemini-cli@latest'; - return { - packageManager: PackageManager.NPM, - isGlobal: true, - updateCommand, - updateMessage: isAutoUpdateDisabled - ? `Please run ${updateCommand} to update` - : 'Installed with npm. Attempting to automatically update now...', - }; - } catch (error) { - debugLogger.log(error); - return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; - } -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/sandbox.ts b/apps/airiscode-cli/src/gemini-base/utils/sandbox.ts deleted file mode 100644 index 039009347..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/sandbox.ts +++ /dev/null @@ -1,971 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { exec, execSync, spawn, type ChildProcess } from 'node:child_process'; -import os from 'node:os'; -import path from 'node:path'; -import fs from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import { quote, parse } from 'shell-quote'; -import { USER_SETTINGS_DIR } from '../config/settings.js'; -import { promisify } from 'node:util'; -import type { Config, SandboxConfig } from '@airiscode/gemini-cli-core'; -import { - coreEvents, - debugLogger, - FatalSandboxError, - GEMINI_DIR, -} from '@airiscode/gemini-cli-core'; -import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; -import { randomBytes } from 'node:crypto'; - -const execAsync = promisify(exec); - -function getContainerPath(hostPath: string): string { - if (os.platform() !== 'win32') { - return hostPath; - } - - const withForwardSlashes = hostPath.replace(/\\/g, '/'); - const match = withForwardSlashes.match(/^([A-Z]):\/(.*)/i); - if (match) { - return `/${match[1].toLowerCase()}/${match[2]}`; - } - return hostPath; -} - -const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox'; -const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox'; -const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy'; -const BUILTIN_SEATBELT_PROFILES = [ - 'permissive-open', - 'permissive-closed', - 'permissive-proxied', - 'restrictive-open', - 'restrictive-closed', - 'restrictive-proxied', -]; - -/** - * Determines whether the sandbox container should be run with the current user's UID and GID. - * This is often necessary on Linux systems (especially Debian/Ubuntu based) when using - * rootful Docker without userns-remap configured, to avoid permission issues with - * mounted volumes. - * - * The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable: - * - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`. - * - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`. - * - If `SANDBOX_SET_UID_GID` is not set: - * - On Debian/Ubuntu Linux, it defaults to `true`. - * - On other OSes, or if OS detection fails, it defaults to `false`. - * - * For more context on running Docker containers as non-root, see: - * https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 - * - * @returns {Promise} A promise that resolves to true if the current user's UID/GID should be used, false otherwise. - */ -async function shouldUseCurrentUserInSandbox(): Promise { - const envVar = process.env['SANDBOX_SET_UID_GID']?.toLowerCase().trim(); - - if (envVar === '1' || envVar === 'true') { - return true; - } - if (envVar === '0' || envVar === 'false') { - return false; - } - - // If environment variable is not explicitly set, check for Debian/Ubuntu Linux - if (os.platform() === 'linux') { - try { - const osReleaseContent = await readFile('/etc/os-release', 'utf8'); - if ( - osReleaseContent.includes('ID=debian') || - osReleaseContent.includes('ID=ubuntu') || - osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives - osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives - ) { - debugLogger.log( - 'Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.', - ); - return true; - } - } catch (_err) { - // Silently ignore if /etc/os-release is not found or unreadable. - // The default (false) will be applied in this case. - debugLogger.warn( - 'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.', - ); - } - } - return false; // Default to false if no other condition is met -} - -// docker does not allow container names to contain ':' or '/', so we -// parse those out to shorten the name -function parseImageName(image: string): string { - const [fullName, tag] = image.split(':'); - const name = fullName.split('/').at(-1) ?? 'unknown-image'; - return tag ? `${name}-${tag}` : name; -} - -function ports(): string[] { - return (process.env['SANDBOX_PORTS'] ?? '') - .split(',') - .filter((p) => p.trim()) - .map((p) => p.trim()); -} - -function entrypoint(workdir: string, cliArgs: string[]): string[] { - const isWindows = os.platform() === 'win32'; - const containerWorkdir = getContainerPath(workdir); - const shellCmds = []; - const pathSeparator = isWindows ? ';' : ':'; - - let pathSuffix = ''; - if (process.env['PATH']) { - const paths = process.env['PATH'].split(pathSeparator); - for (const p of paths) { - const containerPath = getContainerPath(p); - if ( - containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase()) - ) { - pathSuffix += `:${containerPath}`; - } - } - } - if (pathSuffix) { - shellCmds.push(`export PATH="$PATH${pathSuffix}";`); - } - - let pythonPathSuffix = ''; - if (process.env['PYTHONPATH']) { - const paths = process.env['PYTHONPATH'].split(pathSeparator); - for (const p of paths) { - const containerPath = getContainerPath(p); - if ( - containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase()) - ) { - pythonPathSuffix += `:${containerPath}`; - } - } - } - if (pythonPathSuffix) { - shellCmds.push(`export PYTHONPATH="$PYTHONPATH${pythonPathSuffix}";`); - } - - const projectSandboxBashrc = path.join(GEMINI_DIR, 'sandbox.bashrc'); - if (fs.existsSync(projectSandboxBashrc)) { - shellCmds.push(`source ${getContainerPath(projectSandboxBashrc)};`); - } - - ports().forEach((p) => - shellCmds.push( - `socat TCP4-LISTEN:${p},bind=$(hostname -i),fork,reuseaddr TCP4:127.0.0.1:${p} 2> /dev/null &`, - ), - ); - - const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg])); - const cliCmd = - process.env['NODE_ENV'] === 'development' - ? process.env['DEBUG'] - ? 'npm run debug --' - : 'npm rebuild && npm run start --' - : process.env['DEBUG'] - ? `node --inspect-brk=0.0.0.0:${process.env['DEBUG_PORT'] || '9229'} $(which gemini)` - : 'gemini'; - - const args = [...shellCmds, cliCmd, ...quotedCliArgs]; - return ['bash', '-c', args.join(' ')]; -} - -export async function start_sandbox( - config: SandboxConfig, - nodeArgs: string[] = [], - cliConfig?: Config, - cliArgs: string[] = [], -): Promise { - const patcher = new ConsolePatcher({ - debugMode: cliConfig?.getDebugMode() || !!process.env['DEBUG'], - stderr: true, - }); - patcher.patch(); - - try { - if (config.command === 'sandbox-exec') { - // disallow BUILD_SANDBOX - if (process.env['BUILD_SANDBOX']) { - throw new FatalSandboxError( - 'Cannot BUILD_SANDBOX when using macOS Seatbelt', - ); - } - - const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open'); - let profileFile = fileURLToPath( - new URL(`sandbox-macos-${profile}.sb`, import.meta.url), - ); - // if profile name is not recognized, then look for file under project settings directory - if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { - profileFile = path.join(GEMINI_DIR, `sandbox-macos-${profile}.sb`); - } - if (!fs.existsSync(profileFile)) { - throw new FatalSandboxError( - `Missing macos seatbelt profile file '${profileFile}'`, - ); - } - debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`); - // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS - const nodeOptions = [ - ...(process.env['DEBUG'] ? ['--inspect-brk'] : []), - ...nodeArgs, - ].join(' '); - - const args = [ - '-D', - `TARGET_DIR=${fs.realpathSync(process.cwd())}`, - '-D', - `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, - '-D', - `HOME_DIR=${fs.realpathSync(os.homedir())}`, - '-D', - `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, - ]; - - // Add included directories from the workspace context - // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them - const MAX_INCLUDE_DIRS = 5; - const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || ''); - const includedDirs: string[] = []; - - if (cliConfig) { - const workspaceContext = cliConfig.getWorkspaceContext(); - const directories = workspaceContext.getDirectories(); - - // Filter out TARGET_DIR - for (const dir of directories) { - const realDir = fs.realpathSync(dir); - if (realDir !== targetDir) { - includedDirs.push(realDir); - } - } - } - - for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { - let dirPath = '/dev/null'; // Default to a safe path that won't cause issues - - if (i < includedDirs.length) { - dirPath = includedDirs[i]; - } - - args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); - } - - const finalArgv = cliArgs; - - args.push( - '-f', - profileFile, - 'sh', - '-c', - [ - `SANDBOX=sandbox-exec`, - `NODE_OPTIONS="${nodeOptions}"`, - ...finalArgv.map((arg) => quote([arg])), - ].join(' '), - ); - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set - const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - const sandboxEnv = { ...process.env }; - if (proxyCommand) { - const proxy = - process.env['HTTPS_PROXY'] || - process.env['https_proxy'] || - process.env['HTTP_PROXY'] || - process.env['http_proxy'] || - 'http://localhost:8877'; - sandboxEnv['HTTPS_PROXY'] = proxy; - sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl - sandboxEnv['HTTP_PROXY'] = proxy; - sandboxEnv['http_proxy'] = proxy; - const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; - if (noProxy) { - sandboxEnv['NO_PROXY'] = noProxy; - sandboxEnv['no_proxy'] = noProxy; - } - proxyProcess = spawn(proxyCommand, { - stdio: ['ignore', 'pipe', 'pipe'], - shell: true, - detached: true, - }); - // install handlers to stop proxy on exit/signal - const stopProxy = () => { - debugLogger.log('stopping proxy ...'); - if (proxyProcess?.pid) { - process.kill(-proxyProcess.pid, 'SIGTERM'); - } - }; - process.on('exit', stopProxy); - process.on('SIGINT', stopProxy); - process.on('SIGTERM', stopProxy); - - // commented out as it disrupts ink rendering - // proxyProcess.stdout?.on('data', (data) => { - // console.info(data.toString()); - // }); - proxyProcess.stderr?.on('data', (data) => { - debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`); - }); - proxyProcess.on('close', (code, signal) => { - if (sandboxProcess?.pid) { - process.kill(-sandboxProcess.pid, 'SIGTERM'); - } - throw new FatalSandboxError( - `Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, - ); - }); - debugLogger.log('waiting for proxy to start ...'); - await execAsync( - `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, - ); - } - // spawn child and let it inherit stdio - process.stdin.pause(); - sandboxProcess = spawn(config.command, args, { - stdio: 'inherit', - }); - return new Promise((resolve, reject) => { - sandboxProcess?.on('error', reject); - sandboxProcess?.on('close', (code) => { - process.stdin.resume(); - resolve(code ?? 1); - }); - }); - } - - debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`); - - // determine full path for gemini-cli to distinguish linked vs installed setting - const gcPath = fs.realpathSync(process.argv[1]); - - const projectSandboxDockerfile = path.join( - GEMINI_DIR, - 'sandbox.Dockerfile', - ); - const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); - - const image = config.image; - const workdir = path.resolve(process.cwd()); - const containerWorkdir = getContainerPath(workdir); - - // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo - // - // note this can only be done with binary linked from gemini-cli repo - if (process.env['BUILD_SANDBOX']) { - if (!gcPath.includes('gemini-cli/packages/')) { - throw new FatalSandboxError( - 'Cannot build sandbox using installed gemini binary; ' + - 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', - ); - } else { - debugLogger.log('building sandbox ...'); - const gcRoot = gcPath.split('/packages/')[0]; - // if project folder has sandbox.Dockerfile under project settings folder, use that - let buildArgs = ''; - const projectSandboxDockerfile = path.join( - GEMINI_DIR, - 'sandbox.Dockerfile', - ); - if (isCustomProjectSandbox) { - debugLogger.log(`using ${projectSandboxDockerfile} for sandbox`); - buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; - } - execSync( - `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, - { - stdio: 'inherit', - env: { - ...process.env, - GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) - }, - }, - ); - } - } - - // stop if image is missing - if (!(await ensureSandboxImageIsPresent(config.command, image))) { - const remedy = - image === LOCAL_DEV_SANDBOX_IMAGE_NAME - ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' - : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; - throw new FatalSandboxError( - `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, - ); - } - - // use interactive mode and auto-remove container on exit - // run init binary inside container to forward signals & reap zombies - const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; - - // add custom flags from SANDBOX_FLAGS - if (process.env['SANDBOX_FLAGS']) { - const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter( - (f): f is string => typeof f === 'string', - ); - args.push(...flags); - } - - // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container - if (process.stdin.isTTY) { - args.push('-t'); - } - - // allow access to host.docker.internal - args.push('--add-host', 'host.docker.internal:host-gateway'); - - // mount current directory as working directory in sandbox (set via --workdir) - args.push('--volume', `${workdir}:${containerWorkdir}`); - - // mount user settings directory inside container, after creating if missing - // note user/home changes inside sandbox and we mount at BOTH paths for consistency - const userSettingsDirOnHost = USER_SETTINGS_DIR; - const userSettingsDirInSandbox = getContainerPath( - `/home/node/${GEMINI_DIR}`, - ); - if (!fs.existsSync(userSettingsDirOnHost)) { - fs.mkdirSync(userSettingsDirOnHost); - } - args.push( - '--volume', - `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`, - ); - if (userSettingsDirInSandbox !== userSettingsDirOnHost) { - args.push( - '--volume', - `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, - ); - } - - // mount os.tmpdir() as os.tmpdir() inside container - args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); - - // mount gcloud config directory if it exists - const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); - if (fs.existsSync(gcloudConfigDir)) { - args.push( - '--volume', - `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, - ); - } - - // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set - if (process.env['GOOGLE_APPLICATION_CREDENTIALS']) { - const adcFile = process.env['GOOGLE_APPLICATION_CREDENTIALS']; - if (fs.existsSync(adcFile)) { - args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); - args.push( - '--env', - `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, - ); - } - } - - // mount paths listed in SANDBOX_MOUNTS - if (process.env['SANDBOX_MOUNTS']) { - for (let mount of process.env['SANDBOX_MOUNTS'].split(',')) { - if (mount.trim()) { - // parse mount as from:to:opts - let [from, to, opts] = mount.trim().split(':'); - to = to || from; // default to mount at same path inside container - opts = opts || 'ro'; // default to read-only - mount = `${from}:${to}:${opts}`; - // check that from path is absolute - if (!path.isAbsolute(from)) { - throw new FatalSandboxError( - `Path '${from}' listed in SANDBOX_MOUNTS must be absolute`, - ); - } - // check that from path exists on host - if (!fs.existsSync(from)) { - throw new FatalSandboxError( - `Missing mount path '${from}' listed in SANDBOX_MOUNTS`, - ); - } - debugLogger.log(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); - args.push('--volume', mount); - } - } - } - - // expose env-specified ports on the sandbox - ports().forEach((p) => args.push('--publish', `${p}:${p}`)); - - // if DEBUG is set, expose debugging port - if (process.env['DEBUG']) { - const debugPort = process.env['DEBUG_PORT'] || '9229'; - args.push(`--publish`, `${debugPort}:${debugPort}`); - } - - // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME - // copy as both upper-case and lower-case as is required by some utilities - // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set - const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; - - if (proxyCommand) { - let proxy = - process.env['HTTPS_PROXY'] || - process.env['https_proxy'] || - process.env['HTTP_PROXY'] || - process.env['http_proxy'] || - 'http://localhost:8877'; - proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); - if (proxy) { - args.push('--env', `HTTPS_PROXY=${proxy}`); - args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl - args.push('--env', `HTTP_PROXY=${proxy}`); - args.push('--env', `http_proxy=${proxy}`); - } - const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; - if (noProxy) { - args.push('--env', `NO_PROXY=${noProxy}`); - args.push('--env', `no_proxy=${noProxy}`); - } - - // if using proxy, switch to internal networking through proxy - if (proxy) { - execSync( - `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, - ); - args.push('--network', SANDBOX_NETWORK_NAME); - // if proxy command is set, create a separate network w/ host access (i.e. non-internal) - // we will run proxy in its own container connected to both host network and internal network - // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation - if (proxyCommand) { - execSync( - `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, - ); - } - } - } - - // name container after image, plus random suffix to avoid conflicts - const imageName = parseImageName(image); - const isIntegrationTest = - process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true'; - let containerName; - if (isIntegrationTest) { - containerName = `gemini-cli-integration-test-${randomBytes(4).toString( - 'hex', - )}`; - debugLogger.log(`ContainerName: ${containerName}`); - } else { - let index = 0; - const containerNameCheck = execSync( - `${config.command} ps -a --format "{{.Names}}"`, - ) - .toString() - .trim(); - while (containerNameCheck.includes(`${imageName}-${index}`)) { - index++; - } - containerName = `${imageName}-${index}`; - debugLogger.log(`ContainerName (regular): ${containerName}`); - } - args.push('--name', containerName, '--hostname', containerName); - - // copy GEMINI_CLI_TEST_VAR for integration tests - if (process.env['GEMINI_CLI_TEST_VAR']) { - args.push( - '--env', - `GEMINI_CLI_TEST_VAR=${process.env['GEMINI_CLI_TEST_VAR']}`, - ); - } - - // copy GEMINI_API_KEY(s) - if (process.env['GEMINI_API_KEY']) { - args.push('--env', `GEMINI_API_KEY=${process.env['GEMINI_API_KEY']}`); - } - if (process.env['GOOGLE_API_KEY']) { - args.push('--env', `GOOGLE_API_KEY=${process.env['GOOGLE_API_KEY']}`); - } - - // copy GOOGLE_GENAI_USE_VERTEXAI - if (process.env['GOOGLE_GENAI_USE_VERTEXAI']) { - args.push( - '--env', - `GOOGLE_GENAI_USE_VERTEXAI=${process.env['GOOGLE_GENAI_USE_VERTEXAI']}`, - ); - } - - // copy GOOGLE_GENAI_USE_GCA - if (process.env['GOOGLE_GENAI_USE_GCA']) { - args.push( - '--env', - `GOOGLE_GENAI_USE_GCA=${process.env['GOOGLE_GENAI_USE_GCA']}`, - ); - } - - // copy GOOGLE_CLOUD_PROJECT - if (process.env['GOOGLE_CLOUD_PROJECT']) { - args.push( - '--env', - `GOOGLE_CLOUD_PROJECT=${process.env['GOOGLE_CLOUD_PROJECT']}`, - ); - } - - // copy GOOGLE_CLOUD_LOCATION - if (process.env['GOOGLE_CLOUD_LOCATION']) { - args.push( - '--env', - `GOOGLE_CLOUD_LOCATION=${process.env['GOOGLE_CLOUD_LOCATION']}`, - ); - } - - // copy GEMINI_MODEL - if (process.env['GEMINI_MODEL']) { - args.push('--env', `GEMINI_MODEL=${process.env['GEMINI_MODEL']}`); - } - - // copy TERM and COLORTERM to try to maintain terminal setup - if (process.env['TERM']) { - args.push('--env', `TERM=${process.env['TERM']}`); - } - if (process.env['COLORTERM']) { - args.push('--env', `COLORTERM=${process.env['COLORTERM']}`); - } - - // Pass through IDE mode environment variables - for (const envVar of [ - 'GEMINI_CLI_IDE_SERVER_PORT', - 'GEMINI_CLI_IDE_WORKSPACE_PATH', - 'TERM_PROGRAM', - ]) { - if (process.env[envVar]) { - args.push('--env', `${envVar}=${process.env[envVar]}`); - } - } - - // copy VIRTUAL_ENV if under working directory - // also mount-replace VIRTUAL_ENV directory with /sandbox.venv - // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) - // directory will be empty if not set up, which is still preferable to having host binaries - if ( - process.env['VIRTUAL_ENV'] - ?.toLowerCase() - .startsWith(workdir.toLowerCase()) - ) { - const sandboxVenvPath = path.resolve(GEMINI_DIR, 'sandbox.venv'); - if (!fs.existsSync(sandboxVenvPath)) { - fs.mkdirSync(sandboxVenvPath, { recursive: true }); - } - args.push( - '--volume', - `${sandboxVenvPath}:${getContainerPath(process.env['VIRTUAL_ENV'])}`, - ); - args.push( - '--env', - `VIRTUAL_ENV=${getContainerPath(process.env['VIRTUAL_ENV'])}`, - ); - } - - // copy additional environment variables from SANDBOX_ENV - if (process.env['SANDBOX_ENV']) { - for (let env of process.env['SANDBOX_ENV'].split(',')) { - if ((env = env.trim())) { - if (env.includes('=')) { - debugLogger.log(`SANDBOX_ENV: ${env}`); - args.push('--env', env); - } else { - throw new FatalSandboxError( - 'SANDBOX_ENV must be a comma-separated list of key=value pairs', - ); - } - } - } - } - - // copy NODE_OPTIONS - const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; - const allNodeOptions = [ - ...(existingNodeOptions ? [existingNodeOptions] : []), - ...nodeArgs, - ].join(' '); - - if (allNodeOptions.length > 0) { - args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); - } - - // set SANDBOX as container name - args.push('--env', `SANDBOX=${containerName}`); - - // for podman only, use empty --authfile to skip unnecessary auth refresh overhead - if (config.command === 'podman') { - const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); - fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); - args.push('--authfile', emptyAuthFilePath); - } - - // Determine if the current user's UID/GID should be passed to the sandbox. - // See shouldUseCurrentUserInSandbox for more details. - let userFlag = ''; - const finalEntrypoint = entrypoint(workdir, cliArgs); - - if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { - args.push('--user', 'root'); - userFlag = '--user root'; - } else if (await shouldUseCurrentUserInSandbox()) { - // For the user-creation logic to work, the container must start as root. - // The entrypoint script then handles dropping privileges to the correct user. - args.push('--user', 'root'); - - const uid = execSync('id -u').toString().trim(); - const gid = execSync('id -g').toString().trim(); - - // Instead of passing --user to the main sandbox container, we let it - // start as root, then create a user with the host's UID/GID, and - // finally switch to that user to run the gemini process. This is - // necessary on Linux to ensure the user exists within the - // container's /etc/passwd file, which is required by os.userInfo(). - const username = 'gemini'; - const homeDir = getContainerPath(os.homedir()); - - const setupUserCommands = [ - // Use -f with groupadd to avoid errors if the group already exists. - `groupadd -f -g ${gid} ${username}`, - // Create user only if it doesn't exist. Use -o for non-unique UID. - `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, - ].join(' && '); - - const originalCommand = finalEntrypoint[2]; - const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); - - // Use `su -p` to preserve the environment. - const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; - - // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. - finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; - - // We still need userFlag for the simpler proxy container, which does not have this issue. - userFlag = `--user ${uid}:${gid}`; - // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. - args.push('--env', `HOME=${os.homedir()}`); - } - - // push container image name - args.push(image); - - // push container entrypoint (including args) - args.push(...finalEntrypoint); - - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - - if (proxyCommand) { - // run proxyCommand in its own container - const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; - proxyProcess = spawn(proxyContainerCommand, { - stdio: ['ignore', 'pipe', 'pipe'], - shell: true, - detached: true, - }); - // install handlers to stop proxy on exit/signal - const stopProxy = () => { - debugLogger.log('stopping proxy container ...'); - execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); - }; - process.on('exit', stopProxy); - process.on('SIGINT', stopProxy); - process.on('SIGTERM', stopProxy); - - // commented out as it disrupts ink rendering - // proxyProcess.stdout?.on('data', (data) => { - // console.info(data.toString()); - // }); - proxyProcess.stderr?.on('data', (data) => { - debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`); - }); - proxyProcess.on('close', (code, signal) => { - if (sandboxProcess?.pid) { - process.kill(-sandboxProcess.pid, 'SIGTERM'); - } - throw new FatalSandboxError( - `Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, - ); - }); - debugLogger.log('waiting for proxy to start ...'); - await execAsync( - `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, - ); - // connect proxy container to sandbox network - // (workaround for older versions of docker that don't support multiple --network args) - await execAsync( - `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, - ); - } - - // spawn child and let it inherit stdio - process.stdin.pause(); - sandboxProcess = spawn(config.command, args, { - stdio: 'inherit', - }); - - return new Promise((resolve, reject) => { - sandboxProcess.on('error', (err) => { - coreEvents.emitFeedback('error', 'Sandbox process error', err); - reject(err); - }); - - sandboxProcess?.on('close', (code, signal) => { - process.stdin.resume(); - if (code !== 0 && code !== null) { - debugLogger.log( - `Sandbox process exited with code: ${code}, signal: ${signal}`, - ); - } - resolve(code ?? 1); - }); - }); - } finally { - patcher.cleanup(); - } -} - -// Helper functions to ensure sandbox image is present -async function imageExists(sandbox: string, image: string): Promise { - return new Promise((resolve) => { - const args = ['images', '-q', image]; - const checkProcess = spawn(sandbox, args); - - let stdoutData = ''; - if (checkProcess.stdout) { - checkProcess.stdout.on('data', (data) => { - stdoutData += data.toString(); - }); - } - - checkProcess.on('error', (err) => { - debugLogger.warn( - `Failed to start '${sandbox}' command for image check: ${err.message}`, - ); - resolve(false); - }); - - checkProcess.on('close', (code) => { - // Non-zero code might indicate docker daemon not running, etc. - // The primary success indicator is non-empty stdoutData. - if (code !== 0) { - // console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`); - } - resolve(stdoutData.trim() !== ''); - }); - }); -} - -async function pullImage(sandbox: string, image: string): Promise { - console.info(`Attempting to pull image ${image} using ${sandbox}...`); - return new Promise((resolve) => { - const args = ['pull', image]; - const pullProcess = spawn(sandbox, args, { stdio: 'pipe' }); - - let stderrData = ''; - - const onStdoutData = (data: Buffer) => { - console.info(data.toString().trim()); // Show pull progress - }; - - const onStderrData = (data: Buffer) => { - stderrData += data.toString(); - console.error(data.toString().trim()); // Show pull errors/info from the command itself - }; - - const onError = (err: Error) => { - debugLogger.warn( - `Failed to start '${sandbox} pull ${image}' command: ${err.message}`, - ); - cleanup(); - resolve(false); - }; - - const onClose = (code: number | null) => { - if (code === 0) { - console.info(`Successfully pulled image ${image}.`); - cleanup(); - resolve(true); - } else { - debugLogger.warn( - `Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`, - ); - if (stderrData.trim()) { - // Details already printed by the stderr listener above - } - cleanup(); - resolve(false); - } - }; - - const cleanup = () => { - if (pullProcess.stdout) { - pullProcess.stdout.removeListener('data', onStdoutData); - } - if (pullProcess.stderr) { - pullProcess.stderr.removeListener('data', onStderrData); - } - pullProcess.removeListener('error', onError); - pullProcess.removeListener('close', onClose); - if (pullProcess.connected) { - pullProcess.disconnect(); - } - }; - - if (pullProcess.stdout) { - pullProcess.stdout.on('data', onStdoutData); - } - if (pullProcess.stderr) { - pullProcess.stderr.on('data', onStderrData); - } - pullProcess.on('error', onError); - pullProcess.on('close', onClose); - }); -} - -async function ensureSandboxImageIsPresent( - sandbox: string, - image: string, -): Promise { - debugLogger.log(`Checking for sandbox image: ${image}`); - if (await imageExists(sandbox, image)) { - debugLogger.log(`Sandbox image ${image} found locally.`); - return true; - } - - debugLogger.log(`Sandbox image ${image} not found locally.`); - if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) { - // user needs to build the image themselves - return false; - } - - if (await pullImage(sandbox, image)) { - // After attempting to pull, check again to be certain - if (await imageExists(sandbox, image)) { - debugLogger.log(`Sandbox image ${image} is now available after pulling.`); - return true; - } else { - debugLogger.warn( - `Sandbox image ${image} still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available.`, - ); - return false; - } - } - - coreEvents.emitFeedback( - 'error', - `Failed to obtain sandbox image ${image} after check and pull attempt.`, - ); - return false; // Pull command failed or image still not present -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/sessionCleanup.ts b/apps/airiscode-cli/src/gemini-base/utils/sessionCleanup.ts deleted file mode 100644 index 1dc416706..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/sessionCleanup.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { debugLogger, type Config } from '@airiscode/gemini-cli-core'; -import type { Settings, SessionRetentionSettings } from '../config/settings.js'; -import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js'; - -// Constants -export const DEFAULT_MIN_RETENTION = '1d' as string; -const MIN_MAX_COUNT = 1; -const MULTIPLIERS = { - h: 60 * 60 * 1000, // hours to ms - d: 24 * 60 * 60 * 1000, // days to ms - w: 7 * 24 * 60 * 60 * 1000, // weeks to ms - m: 30 * 24 * 60 * 60 * 1000, // months (30 days) to ms -}; - -/** - * Result of session cleanup operation - */ -export interface CleanupResult { - disabled: boolean; - scanned: number; - deleted: number; - skipped: number; - failed: number; -} - -/** - * Main entry point for session cleanup during CLI startup - */ -export async function cleanupExpiredSessions( - config: Config, - settings: Settings, -): Promise { - const result: CleanupResult = { - disabled: false, - scanned: 0, - deleted: 0, - skipped: 0, - failed: 0, - }; - - try { - // Early exit if cleanup is disabled - if (!settings.general?.sessionRetention?.enabled) { - return { ...result, disabled: true }; - } - - const retentionConfig = settings.general.sessionRetention; - const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); - - // Validate retention configuration - const validationErrorMessage = validateRetentionConfig( - config, - retentionConfig, - ); - if (validationErrorMessage) { - // Log validation errors to console for visibility - console.error(`Session cleanup disabled: ${validationErrorMessage}`); - return { ...result, disabled: true }; - } - - // Get all session files (including corrupted ones) for this project - const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId()); - result.scanned = allFiles.length; - - if (allFiles.length === 0) { - return result; - } - - // Determine which sessions to delete (corrupted and expired) - const sessionsToDelete = await identifySessionsToDelete( - allFiles, - retentionConfig, - ); - - // Delete all sessions that need to be deleted - for (const sessionToDelete of sessionsToDelete) { - try { - const sessionPath = path.join(chatsDir, sessionToDelete.fileName); - await fs.unlink(sessionPath); - - if (config.getDebugMode()) { - if (sessionToDelete.sessionInfo === null) { - debugLogger.debug( - `Deleted corrupted session file: ${sessionToDelete.fileName}`, - ); - } else { - debugLogger.debug( - `Deleted expired session: ${sessionToDelete.sessionInfo.id} (${sessionToDelete.sessionInfo.lastUpdated})`, - ); - } - } - result.deleted++; - } catch (error) { - // Ignore ENOENT errors (file already deleted) - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { - // File already deleted, do nothing. - } else { - // Log error directly to console - const sessionId = - sessionToDelete.sessionInfo === null - ? sessionToDelete.fileName - : sessionToDelete.sessionInfo.id; - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - console.error( - `Failed to delete session ${sessionId}: ${errorMessage}`, - ); - result.failed++; - } - } - } - - result.skipped = result.scanned - result.deleted - result.failed; - - if (config.getDebugMode() && result.deleted > 0) { - debugLogger.debug( - `Session cleanup: deleted ${result.deleted}, skipped ${result.skipped}, failed ${result.failed}`, - ); - } - } catch (error) { - // Global error handler - don't let cleanup failures break startup - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - console.error(`Session cleanup failed: ${errorMessage}`); - result.failed++; - } - - return result; -} - -/** - * Identifies sessions that should be deleted (corrupted or expired based on retention policy) - */ -async function identifySessionsToDelete( - allFiles: SessionFileEntry[], - retentionConfig: SessionRetentionSettings, -): Promise { - const sessionsToDelete: SessionFileEntry[] = []; - - // All corrupted files should be deleted - sessionsToDelete.push( - ...allFiles.filter((entry) => entry.sessionInfo === null), - ); - - // Now handle valid sessions based on retention policy - const validSessions = allFiles.filter((entry) => entry.sessionInfo !== null); - if (validSessions.length === 0) { - return sessionsToDelete; - } - - const now = new Date(); - - // Calculate cutoff date for age-based retention - let cutoffDate: Date | null = null; - if (retentionConfig.maxAge) { - try { - const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); - cutoffDate = new Date(now.getTime() - maxAgeMs); - } catch { - // This should not happen as validation should have caught it, - // but handle gracefully just in case - cutoffDate = null; - } - } - - // Sort valid sessions by lastUpdated (newest first) for count-based retention - const sortedValidSessions = [...validSessions].sort( - (a, b) => - new Date(b.sessionInfo!.lastUpdated).getTime() - - new Date(a.sessionInfo!.lastUpdated).getTime(), - ); - - // Separate deletable sessions from the active session - const deletableSessions = sortedValidSessions.filter( - (entry) => !entry.sessionInfo!.isCurrentSession, - ); - - // Calculate how many deletable sessions to keep (accounting for the active session) - const hasActiveSession = sortedValidSessions.some( - (e) => e.sessionInfo!.isCurrentSession, - ); - const maxDeletableSessions = - retentionConfig.maxCount && hasActiveSession - ? Math.max(0, retentionConfig.maxCount - 1) - : retentionConfig.maxCount; - - for (let i = 0; i < deletableSessions.length; i++) { - const entry = deletableSessions[i]; - const session = entry.sessionInfo!; - - let shouldDelete = false; - - // Age-based retention check - if (cutoffDate && new Date(session.lastUpdated) < cutoffDate) { - shouldDelete = true; - } - - // Count-based retention check (keep only N most recent deletable sessions) - if (maxDeletableSessions !== undefined && i >= maxDeletableSessions) { - shouldDelete = true; - } - - if (shouldDelete) { - sessionsToDelete.push(entry); - } - } - - return sessionsToDelete; -} - -/** - * Parses retention period strings like "30d", "7d", "24h" into milliseconds - * @throws {Error} If the format is invalid - */ -function parseRetentionPeriod(period: string): number { - const match = period.match(/^(\d+)([dhwm])$/); - if (!match) { - throw new Error( - `Invalid retention period format: ${period}. Expected format: where unit is h, d, w, or m`, - ); - } - - const value = parseInt(match[1], 10); - const unit = match[2]; - - // Reject zero values as they're semantically invalid - if (value === 0) { - throw new Error( - `Invalid retention period: ${period}. Value must be greater than 0`, - ); - } - - return value * MULTIPLIERS[unit as keyof typeof MULTIPLIERS]; -} - -/** - * Validates retention configuration - */ -function validateRetentionConfig( - config: Config, - retentionConfig: SessionRetentionSettings, -): string | null { - if (!retentionConfig.enabled) { - return 'Retention not enabled'; - } - - // Validate maxAge if provided - if (retentionConfig.maxAge) { - let maxAgeMs: number; - try { - maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); - } catch (error) { - return (error as Error | string).toString(); - } - - // Enforce minimum retention period - const minRetention = retentionConfig.minRetention || DEFAULT_MIN_RETENTION; - let minRetentionMs: number; - try { - minRetentionMs = parseRetentionPeriod(minRetention); - } catch (error) { - // If minRetention format is invalid, fall back to default - if (config.getDebugMode()) { - console.error(`Failed to parse minRetention: ${error}`); - } - minRetentionMs = parseRetentionPeriod(DEFAULT_MIN_RETENTION); - } - - if (maxAgeMs < minRetentionMs) { - return `maxAge cannot be less than minRetention (${minRetention})`; - } - } - - // Validate maxCount if provided - if (retentionConfig.maxCount !== undefined) { - if (retentionConfig.maxCount < MIN_MAX_COUNT) { - return `maxCount must be at least ${MIN_MAX_COUNT}`; - } - } - - // At least one retention method must be specified - if (!retentionConfig.maxAge && retentionConfig.maxCount === undefined) { - return 'Either maxAge or maxCount must be specified'; - } - - return null; -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/sessionUtils.ts b/apps/airiscode-cli/src/gemini-base/utils/sessionUtils.ts deleted file mode 100644 index 9fb934c6c..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/sessionUtils.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - Config, - ConversationRecord, - MessageRecord, -} from '@airiscode/gemini-cli-core'; -import { - SESSION_FILE_PREFIX, - partListUnionToString, -} from '@airiscode/gemini-cli-core'; -import * as fs from 'node:fs/promises'; -import path from 'node:path'; - -/** - * Session information for display and selection purposes. - */ -export interface SessionInfo { - /** Unique session identifier (filename without .json) */ - id: string; - /** Filename without extension */ - file: string; - /** Full filename including .json extension */ - fileName: string; - /** ISO timestamp when session started */ - startTime: string; - /** ISO timestamp when session was last updated */ - lastUpdated: string; - /** Cleaned first user message content */ - firstUserMessage: string; - /** Whether this is the currently active session */ - isCurrentSession: boolean; - /** Display index in the list */ - index: number; -} - -/** - * Represents a session file, which may be valid or corrupted. - */ -export interface SessionFileEntry { - /** Full filename including .json extension */ - fileName: string; - /** Parsed session info if valid, null if corrupted */ - sessionInfo: SessionInfo | null; -} - -/** - * Result of resolving a session selection argument. - */ -export interface SessionSelectionResult { - sessionPath: string; - sessionData: ConversationRecord; -} - -/** - * Extracts the first meaningful user message from conversation messages. - */ -export const extractFirstUserMessage = (messages: MessageRecord[]): string => { - const userMessage = messages.find((msg) => { - const content = partListUnionToString(msg.content); - return msg.type === 'user' && content?.trim() && content !== '/resume'; - }); - - if (!userMessage) { - return 'Empty conversation'; - } - - // Truncate long messages for display - const content = partListUnionToString(userMessage.content).trim(); - return content.length > 100 ? content.slice(0, 97) + '...' : content; -}; - -/** - * Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago"). - */ -export const formatRelativeTime = (timestamp: string): string => { - const now = new Date(); - const time = new Date(timestamp); - const diffMs = now.getTime() - time.getTime(); - const diffSeconds = Math.floor(diffMs / 1000); - const diffMinutes = Math.floor(diffSeconds / 60); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffDays > 0) { - return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; - } else if (diffHours > 0) { - return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; - } else if (diffMinutes > 0) { - return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; - } else { - return 'Just now'; - } -}; - -/** - * Loads all session files (including corrupted ones) from the chats directory. - * @returns Array of session file entries, with sessionInfo null for corrupted files - */ -export const getAllSessionFiles = async ( - chatsDir: string, - currentSessionId?: string, -): Promise => { - try { - const files = await fs.readdir(chatsDir); - const sessionFiles = files - .filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json')) - .sort(); // Sort by filename, which includes timestamp - - const sessionPromises = sessionFiles.map( - async (file): Promise => { - const filePath = path.join(chatsDir, file); - try { - const content: ConversationRecord = JSON.parse( - await fs.readFile(filePath, 'utf8'), - ); - - // Validate required fields - if ( - !content.sessionId || - !content.messages || - !Array.isArray(content.messages) || - !content.startTime || - !content.lastUpdated - ) { - // Missing required fields - treat as corrupted - return { fileName: file, sessionInfo: null }; - } - - const firstUserMessage = extractFirstUserMessage(content.messages); - const isCurrentSession = currentSessionId - ? file.includes(currentSessionId.slice(0, 8)) - : false; - - const sessionInfo: SessionInfo = { - id: content.sessionId, - file: file.replace('.json', ''), - fileName: file, - startTime: content.startTime, - lastUpdated: content.lastUpdated, - firstUserMessage, - isCurrentSession, - index: 0, // Will be set after sorting valid sessions - }; - - return { fileName: file, sessionInfo }; - } catch { - // File is corrupted (can't read or parse JSON) - return { fileName: file, sessionInfo: null }; - } - }, - ); - - return await Promise.all(sessionPromises); - } catch (error) { - // It's expected that the directory might not exist, which is not an error. - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return []; - } - // For other errors (e.g., permissions), re-throw to be handled by the caller. - throw error; - } -}; - -/** - * Loads all valid session files from the chats directory and converts them to SessionInfo. - * Corrupted files are automatically filtered out. - */ -export const getSessionFiles = async ( - chatsDir: string, - currentSessionId?: string, -): Promise => { - const allFiles = await getAllSessionFiles(chatsDir, currentSessionId); - - // Filter out corrupted files and extract SessionInfo - const validSessions = allFiles - .filter( - (entry): entry is { fileName: string; sessionInfo: SessionInfo } => - entry.sessionInfo !== null, - ) - .map((entry) => entry.sessionInfo); - - // Sort by startTime (oldest first) for stable session numbering - validSessions.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), - ); - - // Set the correct 1-based indexes after sorting - validSessions.forEach((session, index) => { - session.index = index + 1; - }); - - return validSessions; -}; - -/** - * Utility class for session discovery and selection. - */ -export class SessionSelector { - constructor(private config: Config) {} - - /** - * Lists all available sessions for the current project. - */ - async listSessions(): Promise { - const chatsDir = path.join( - this.config.storage.getProjectTempDir(), - 'chats', - ); - return getSessionFiles(chatsDir, this.config.getSessionId()); - } - - /** - * Finds a session by identifier (UUID or numeric index). - * - * @param identifier - Can be a full UUID or an index number (1-based) - * @returns Promise resolving to the found SessionInfo - * @throws Error if the session is not found or identifier is invalid - */ - async findSession(identifier: string): Promise { - const sessions = await this.listSessions(); - - if (sessions.length === 0) { - throw new Error('No previous sessions found for this project.'); - } - - // Sort by startTime (oldest first, so newest sessions get highest numbers) - const sortedSessions = sessions.sort( - (a, b) => - new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), - ); - - // Try to find by UUID first - const sessionByUuid = sortedSessions.find( - (session) => session.id === identifier, - ); - if (sessionByUuid) { - return sessionByUuid; - } - - // Parse as index number (1-based) - only allow numeric indexes - const index = parseInt(identifier, 10); - if ( - !isNaN(index) && - index.toString() === identifier && - index > 0 && - index <= sortedSessions.length - ) { - return sortedSessions[index - 1]; - } - - throw new Error( - `Invalid session identifier "${identifier}". Use --list-sessions to see available sessions.`, - ); - } - - /** - * Resolves a resume argument to a specific session. - * - * @param resumeArg - Can be "latest", a full UUID, or an index number (1-based) - * @returns Promise resolving to session selection result - */ - async resolveSession(resumeArg: string): Promise { - let selectedSession: SessionInfo; - - if (resumeArg === 'latest') { - const sessions = await this.listSessions(); - - if (sessions.length === 0) { - throw new Error('No previous sessions found for this project.'); - } - - // Sort by startTime (oldest first, so newest sessions get highest numbers) - sessions.sort( - (a, b) => - new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), - ); - - selectedSession = sessions[sessions.length - 1]; - } else { - try { - selectedSession = await this.findSession(resumeArg); - } catch (error) { - // Re-throw with more detailed message for resume command - throw new Error( - `Invalid session identifier "${resumeArg}". Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest. Error: ${error}`, - ); - } - } - - return this.selectSession(selectedSession); - } - - /** - * Loads session data for a selected session. - */ - private async selectSession( - sessionInfo: SessionInfo, - ): Promise { - const chatsDir = path.join( - this.config.storage.getProjectTempDir(), - 'chats', - ); - const sessionPath = path.join(chatsDir, sessionInfo.fileName); - - try { - const sessionData: ConversationRecord = JSON.parse( - await fs.readFile(sessionPath, 'utf8'), - ); - - return { - sessionPath, - sessionData, - }; - } catch (error) { - throw new Error( - `Failed to load session ${sessionInfo.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/sessions.ts b/apps/airiscode-cli/src/gemini-base/utils/sessions.ts deleted file mode 100644 index dfc390365..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/sessions.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { ChatRecordingService, type Config } from '@airiscode/gemini-cli-core'; -import { - formatRelativeTime, - SessionSelector, - type SessionInfo, -} from './sessionUtils.js'; - -export async function listSessions(config: Config): Promise { - const sessionSelector = new SessionSelector(config); - const sessions = await sessionSelector.listSessions(); - - if (sessions.length === 0) { - console.log('No previous sessions found for this project.'); - return; - } - - console.log(`\nAvailable sessions for this project (${sessions.length}):\n`); - - sessions - .sort( - (a, b) => - new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), - ) - .forEach((session, index) => { - const current = session.isCurrentSession ? ', current' : ''; - const time = formatRelativeTime(session.lastUpdated); - console.log( - ` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`, - ); - }); -} - -export async function deleteSession( - config: Config, - sessionIndex: string, -): Promise { - const sessionSelector = new SessionSelector(config); - const sessions = await sessionSelector.listSessions(); - - if (sessions.length === 0) { - console.error('No sessions found for this project.'); - return; - } - - // Sort sessions by start time to match list-sessions ordering - const sortedSessions = sessions.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), - ); - - let sessionToDelete: SessionInfo; - - // Try to find by UUID first - const sessionByUuid = sortedSessions.find( - (session) => session.id === sessionIndex, - ); - if (sessionByUuid) { - sessionToDelete = sessionByUuid; - } else { - // Parse session index - const index = parseInt(sessionIndex, 10); - if (isNaN(index) || index < 1 || index > sessions.length) { - console.error( - `Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`, - ); - return; - } - sessionToDelete = sortedSessions[index - 1]; - } - - // Prevent deleting the current session - if (sessionToDelete.isCurrentSession) { - console.error('Cannot delete the current active session.'); - return; - } - - try { - // Use ChatRecordingService to delete the session - const chatRecordingService = new ChatRecordingService(config); - chatRecordingService.deleteSession(sessionToDelete.file); - - const time = formatRelativeTime(sessionToDelete.lastUpdated); - console.log( - `Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`, - ); - } catch (error) { - console.error( - `Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/settingsUtils.ts b/apps/airiscode-cli/src/gemini-base/utils/settingsUtils.ts deleted file mode 100644 index 7ec5fd588..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/settingsUtils.ts +++ /dev/null @@ -1,498 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - Settings, - LoadedSettings, - LoadableSettingScope, -} from '../config/settings.js'; -import type { - SettingDefinition, - SettingsSchema, - SettingsType, - SettingsValue, -} from '../config/settingsSchema.js'; -import { getSettingsSchema } from '../config/settingsSchema.js'; - -// The schema is now nested, but many parts of the UI and logic work better -// with a flattened structure and dot-notation keys. This section flattens the -// schema into a map for easier lookups. - -type FlattenedSchema = Record; - -function flattenSchema(schema: SettingsSchema, prefix = ''): FlattenedSchema { - let result: FlattenedSchema = {}; - for (const key in schema) { - const newKey = prefix ? `${prefix}.${key}` : key; - const definition = schema[key]; - result[newKey] = { ...definition, key: newKey }; - if (definition.properties) { - result = { ...result, ...flattenSchema(definition.properties, newKey) }; - } - } - return result; -} - -let _FLATTENED_SCHEMA: FlattenedSchema | undefined; - -/** Returns a flattened schema, the first call is memoized for future requests. */ -export function getFlattenedSchema() { - return ( - _FLATTENED_SCHEMA ?? - (_FLATTENED_SCHEMA = flattenSchema(getSettingsSchema())) - ); -} - -function clearFlattenedSchema() { - _FLATTENED_SCHEMA = undefined; -} - -/** - * Get all settings grouped by category - */ -export function getSettingsByCategory(): Record< - string, - Array -> { - const categories: Record< - string, - Array - > = {}; - - Object.values(getFlattenedSchema()).forEach((definition) => { - const category = definition.category; - if (!categories[category]) { - categories[category] = []; - } - categories[category].push(definition); - }); - - return categories; -} - -/** - * Get a setting definition by key - */ -export function getSettingDefinition( - key: string, -): (SettingDefinition & { key: string }) | undefined { - return getFlattenedSchema()[key]; -} - -/** - * Check if a setting requires restart - */ -export function requiresRestart(key: string): boolean { - return getFlattenedSchema()[key]?.requiresRestart ?? false; -} - -/** - * Get the default value for a setting - */ -export function getDefaultValue(key: string): SettingsValue { - return getFlattenedSchema()[key]?.default; -} - -/** - * Get all setting keys that require restart - */ -export function getRestartRequiredSettings(): string[] { - return Object.values(getFlattenedSchema()) - .filter((definition) => definition.requiresRestart) - .map((definition) => definition.key); -} - -/** - * Recursively gets a value from a nested object using a key path array. - */ -export function getNestedValue( - obj: Record, - path: string[], -): unknown { - const [first, ...rest] = path; - if (!first || !(first in obj)) { - return undefined; - } - const value = obj[first]; - if (rest.length === 0) { - return value; - } - if (value && typeof value === 'object' && value !== null) { - return getNestedValue(value as Record, rest); - } - return undefined; -} - -/** - * Get the effective value for a setting, considering inheritance from higher scopes - * Always returns a value (never undefined) - falls back to default if not set anywhere - */ -export function getEffectiveValue( - key: string, - settings: Settings, - mergedSettings: Settings, -): SettingsValue { - const definition = getSettingDefinition(key); - if (!definition) { - return undefined; - } - - const path = key.split('.'); - - // Check the current scope's settings first - let value = getNestedValue(settings as Record, path); - if (value !== undefined) { - return value as SettingsValue; - } - - // Check the merged settings for an inherited value - value = getNestedValue(mergedSettings as Record, path); - if (value !== undefined) { - return value as SettingsValue; - } - - // Return default value if no value is set anywhere - return definition.default; -} - -/** - * Get all setting keys from the schema - */ -export function getAllSettingKeys(): string[] { - return Object.keys(getFlattenedSchema()); -} - -/** - * Get settings by type - */ -export function getSettingsByType( - type: SettingsType, -): Array { - return Object.values(getFlattenedSchema()).filter( - (definition) => definition.type === type, - ); -} - -/** - * Get settings that require restart - */ -export function getSettingsRequiringRestart(): Array< - SettingDefinition & { - key: string; - } -> { - return Object.values(getFlattenedSchema()).filter( - (definition) => definition.requiresRestart, - ); -} - -/** - * Validate if a setting key exists in the schema - */ -export function isValidSettingKey(key: string): boolean { - return key in getFlattenedSchema(); -} - -/** - * Get the category for a setting - */ -export function getSettingCategory(key: string): string | undefined { - return getFlattenedSchema()[key]?.category; -} - -/** - * Check if a setting should be shown in the settings dialog - */ -export function shouldShowInDialog(key: string): boolean { - return getFlattenedSchema()[key]?.showInDialog ?? true; // Default to true for backward compatibility -} - -/** - * Get all settings that should be shown in the dialog, grouped by category - */ -export function getDialogSettingsByCategory(): Record< - string, - Array -> { - const categories: Record< - string, - Array - > = {}; - - Object.values(getFlattenedSchema()) - .filter((definition) => definition.showInDialog !== false) - .forEach((definition) => { - const category = definition.category; - if (!categories[category]) { - categories[category] = []; - } - categories[category].push(definition); - }); - - return categories; -} - -/** - * Get settings by type that should be shown in the dialog - */ -export function getDialogSettingsByType( - type: SettingsType, -): Array { - return Object.values(getFlattenedSchema()).filter( - (definition) => - definition.type === type && definition.showInDialog !== false, - ); -} - -/** - * Get all setting keys that should be shown in the dialog - */ -export function getDialogSettingKeys(): string[] { - return Object.values(getFlattenedSchema()) - .filter((definition) => definition.showInDialog !== false) - .map((definition) => definition.key); -} - -// ============================================================================ -// BUSINESS LOGIC UTILITIES (Higher-level utilities for setting operations) -// ============================================================================ - -/** - * Get the current value for a setting in a specific scope - * Always returns a value (never undefined) - falls back to default if not set anywhere - */ -export function getSettingValue( - key: string, - settings: Settings, - mergedSettings: Settings, -): boolean { - const definition = getSettingDefinition(key); - if (!definition) { - return false; // Default fallback for invalid settings - } - - const value = getEffectiveValue(key, settings, mergedSettings); - // Ensure we return a boolean value, converting from the more general type - if (typeof value === 'boolean') { - return value; - } - // Fall back to default value, ensuring it's a boolean - const defaultValue = definition.default; - if (typeof defaultValue === 'boolean') { - return defaultValue; - } - return false; // Final fallback -} - -/** - * Check if a setting value is modified from its default - */ -export function isSettingModified(key: string, value: boolean): boolean { - const defaultValue = getDefaultValue(key); - // Handle type comparison properly - if (typeof defaultValue === 'boolean') { - return value !== defaultValue; - } - // If default is not a boolean, consider it modified if value is true - return value === true; -} - -/** - * Check if a setting exists in the original settings file for a scope - */ -export function settingExistsInScope( - key: string, - scopeSettings: Settings, -): boolean { - const path = key.split('.'); - const value = getNestedValue(scopeSettings as Record, path); - return value !== undefined; -} - -/** - * Recursively sets a value in a nested object using a key path array. - */ -function setNestedValue( - obj: Record, - path: string[], - value: unknown, -): Record { - const [first, ...rest] = path; - if (!first) { - return obj; - } - - if (rest.length === 0) { - obj[first] = value; - return obj; - } - - if (!obj[first] || typeof obj[first] !== 'object') { - obj[first] = {}; - } - - setNestedValue(obj[first] as Record, rest, value); - return obj; -} - -/** - * Set a setting value in the pending settings - */ -export function setPendingSettingValue( - key: string, - value: boolean, - pendingSettings: Settings, -): Settings { - const path = key.split('.'); - const newSettings = JSON.parse(JSON.stringify(pendingSettings)); - setNestedValue(newSettings, path, value); - return newSettings; -} - -/** - * Generic setter: Set a setting value (boolean, number, string, etc.) in the pending settings - */ -export function setPendingSettingValueAny( - key: string, - value: SettingsValue, - pendingSettings: Settings, -): Settings { - const path = key.split('.'); - const newSettings = structuredClone(pendingSettings); - setNestedValue(newSettings, path, value); - return newSettings; -} - -/** - * Check if any modified settings require a restart - */ -export function hasRestartRequiredSettings( - modifiedSettings: Set, -): boolean { - return Array.from(modifiedSettings).some((key) => requiresRestart(key)); -} - -/** - * Get the restart required settings from a set of modified settings - */ -export function getRestartRequiredFromModified( - modifiedSettings: Set, -): string[] { - return Array.from(modifiedSettings).filter((key) => requiresRestart(key)); -} - -/** - * Save modified settings to the appropriate scope - */ -export function saveModifiedSettings( - modifiedSettings: Set, - pendingSettings: Settings, - loadedSettings: LoadedSettings, - scope: LoadableSettingScope, -): void { - modifiedSettings.forEach((settingKey) => { - const path = settingKey.split('.'); - const value = getNestedValue( - pendingSettings as Record, - path, - ); - - if (value === undefined) { - return; - } - - const existsInOriginalFile = settingExistsInScope( - settingKey, - loadedSettings.forScope(scope).settings, - ); - - const isDefaultValue = value === getDefaultValue(settingKey); - - if (existsInOriginalFile || !isDefaultValue) { - loadedSettings.setValue(scope, settingKey, value); - } - }); -} - -/** - * Get the display value for a setting, showing current scope value with default change indicator - */ -export function getDisplayValue( - key: string, - settings: Settings, - _mergedSettings: Settings, - modifiedSettings: Set, - pendingSettings?: Settings, -): string { - // Prioritize pending changes if user has modified this setting - const definition = getSettingDefinition(key); - - let value: SettingsValue; - if (pendingSettings && settingExistsInScope(key, pendingSettings)) { - // Show the value from the pending (unsaved) edits when it exists - value = getEffectiveValue(key, pendingSettings, {}); - } else if (settingExistsInScope(key, settings)) { - // Show the value defined at the current scope if present - value = getEffectiveValue(key, settings, {}); - } else { - // Fall back to the schema default when the key is unset in this scope - value = getDefaultValue(key); - } - - let valueString = String(value); - - if (definition?.type === 'enum' && definition.options) { - const option = definition.options?.find((option) => option.value === value); - valueString = option?.label ?? `${value}`; - } - - // Check if value is different from default OR if it's in modified settings OR if there are pending changes - const defaultValue = getDefaultValue(key); - const isChangedFromDefault = value !== defaultValue; - const isInModifiedSettings = modifiedSettings.has(key); - - // Mark as modified if setting exists in current scope OR is in modified settings - if (settingExistsInScope(key, settings) || isInModifiedSettings) { - return `${valueString}*`; // * indicates setting is set in current scope - } - if (isChangedFromDefault || isInModifiedSettings) { - return `${valueString}*`; // * indicates changed from default value - } - - return valueString; -} - -/** - * Check if a setting doesn't exist in current scope (should be greyed out) - */ -export function isDefaultValue(key: string, settings: Settings): boolean { - return !settingExistsInScope(key, settings); -} - -/** - * Check if a setting value is inherited (not set at current scope) - */ -export function isValueInherited( - key: string, - settings: Settings, - _mergedSettings: Settings, -): boolean { - return !settingExistsInScope(key, settings); -} - -/** - * Get the effective value for display, considering inheritance - * Always returns a boolean value (never undefined) - */ -export function getEffectiveDisplayValue( - key: string, - settings: Settings, - mergedSettings: Settings, -): boolean { - return getSettingValue(key, settings, mergedSettings); -} - -export const TEST_ONLY = { clearFlattenedSchema }; diff --git a/apps/airiscode-cli/src/gemini-base/utils/userStartupWarnings.ts b/apps/airiscode-cli/src/gemini-base/utils/userStartupWarnings.ts deleted file mode 100644 index d35529078..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/userStartupWarnings.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import fs from 'node:fs/promises'; -import * as os from 'node:os'; -import path from 'node:path'; - -type WarningCheck = { - id: string; - check: (workspaceRoot: string) => Promise; -}; - -// Individual warning checks -const homeDirectoryCheck: WarningCheck = { - id: 'home-directory', - check: async (workspaceRoot: string) => { - try { - const [workspaceRealPath, homeRealPath] = await Promise.all([ - fs.realpath(workspaceRoot), - fs.realpath(os.homedir()), - ]); - - if (workspaceRealPath === homeRealPath) { - return 'You are running Gemini CLI in your home directory. It is recommended to run in a project-specific directory.'; - } - return null; - } catch (_err: unknown) { - return 'Could not verify the current directory due to a file system error.'; - } - }, -}; - -const rootDirectoryCheck: WarningCheck = { - id: 'root-directory', - check: async (workspaceRoot: string) => { - try { - const workspaceRealPath = await fs.realpath(workspaceRoot); - const errorMessage = - 'Warning: You are running Gemini CLI in the root directory. Your entire folder structure will be used for context. It is strongly recommended to run in a project-specific directory.'; - - // Check for Unix root directory - if (path.dirname(workspaceRealPath) === workspaceRealPath) { - return errorMessage; - } - - return null; - } catch (_err: unknown) { - return 'Could not verify the current directory due to a file system error.'; - } - }, -}; - -// All warning checks -const WARNING_CHECKS: readonly WarningCheck[] = [ - homeDirectoryCheck, - rootDirectoryCheck, -]; - -export async function getUserStartupWarnings( - workspaceRoot: string = process.cwd(), -): Promise { - const results = await Promise.all( - WARNING_CHECKS.map((check) => check.check(workspaceRoot)), - ); - return results.filter((msg) => msg !== null); -} diff --git a/apps/airiscode-cli/src/gemini-base/utils/version.ts b/apps/airiscode-cli/src/gemini-base/utils/version.ts deleted file mode 100644 index 0c16a8027..000000000 --- a/apps/airiscode-cli/src/gemini-base/utils/version.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { getPackageJson } from '@airiscode/gemini-cli-core'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export async function getCliVersion(): Promise { - const pkgJson = await getPackageJson(__dirname); - return process.env['CLI_VERSION'] || pkgJson?.version || 'unknown'; -} diff --git a/apps/airiscode-cli/src/gemini-base/validateNonInterActiveAuth.ts b/apps/airiscode-cli/src/gemini-base/validateNonInterActiveAuth.ts deleted file mode 100644 index 3ac56d6f0..000000000 --- a/apps/airiscode-cli/src/gemini-base/validateNonInterActiveAuth.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Config } from '@airiscode/gemini-cli-core'; -import { AuthType, debugLogger, OutputFormat } from '@airiscode/gemini-cli-core'; -import { USER_SETTINGS_PATH } from './config/settings.js'; -import { validateAuthMethod } from './config/auth.js'; -import { type LoadedSettings } from './config/settings.js'; -import { handleError } from './utils/errors.js'; - -function getAuthTypeFromEnv(): AuthType | undefined { - if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') { - return AuthType.LOGIN_WITH_GOOGLE; - } - if (process.env['GOOGLE_GENAI_USE_VERTEXAI'] === 'true') { - return AuthType.USE_VERTEX_AI; - } - if (process.env['GEMINI_API_KEY']) { - return AuthType.USE_GEMINI; - } - return undefined; -} - -export async function validateNonInteractiveAuth( - configuredAuthType: AuthType | undefined, - useExternalAuth: boolean | undefined, - nonInteractiveConfig: Config, - settings: LoadedSettings, -) { - try { - const effectiveAuthType = configuredAuthType || getAuthTypeFromEnv(); - - const enforcedType = settings.merged.security?.auth?.enforcedType; - if (enforcedType && effectiveAuthType !== enforcedType) { - const message = effectiveAuthType - ? `The enforced authentication type is '${enforcedType}', but the current type is '${effectiveAuthType}'. Please re-authenticate with the correct type.` - : `The auth type '${enforcedType}' is enforced, but no authentication is configured.`; - throw new Error(message); - } - - if (!effectiveAuthType) { - const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: GEMINI_API_KEY, GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_GENAI_USE_GCA`; - throw new Error(message); - } - - const authType: AuthType = effectiveAuthType as AuthType; - - if (!useExternalAuth) { - const err = validateAuthMethod(String(authType)); - if (err != null) { - throw new Error(err); - } - } - - await nonInteractiveConfig.refreshAuth(authType); - return nonInteractiveConfig; - } catch (error) { - if (nonInteractiveConfig.getOutputFormat() === OutputFormat.JSON) { - handleError( - error instanceof Error ? error : new Error(String(error)), - nonInteractiveConfig, - 1, - ); - } else { - debugLogger.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - } - } -} diff --git a/apps/airiscode-cli/src/gemini-base/zed-integration/acp.ts b/apps/airiscode-cli/src/gemini-base/zed-integration/acp.ts deleted file mode 100644 index 3fa74af10..000000000 --- a/apps/airiscode-cli/src/gemini-base/zed-integration/acp.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ - -import { z } from 'zod'; -import * as schema from './schema.js'; -export * from './schema.js'; - -import type { WritableStream, ReadableStream } from 'node:stream/web'; -import { coreEvents } from '@airiscode/gemini-cli-core'; - -export class AgentSideConnection implements Client { - #connection: Connection; - - constructor( - toAgent: (conn: Client) => Agent, - input: WritableStream, - output: ReadableStream, - ) { - const agent = toAgent(this); - - const handler = async ( - method: string, - params: unknown, - ): Promise => { - switch (method) { - case schema.AGENT_METHODS.initialize: { - const validatedParams = schema.initializeRequestSchema.parse(params); - return agent.initialize(validatedParams); - } - case schema.AGENT_METHODS.session_new: { - const validatedParams = schema.newSessionRequestSchema.parse(params); - return agent.newSession(validatedParams); - } - case schema.AGENT_METHODS.session_load: { - if (!agent.loadSession) { - throw RequestError.methodNotFound(); - } - const validatedParams = schema.loadSessionRequestSchema.parse(params); - return agent.loadSession(validatedParams); - } - case schema.AGENT_METHODS.authenticate: { - const validatedParams = - schema.authenticateRequestSchema.parse(params); - return agent.authenticate(validatedParams); - } - case schema.AGENT_METHODS.session_prompt: { - const validatedParams = schema.promptRequestSchema.parse(params); - return agent.prompt(validatedParams); - } - case schema.AGENT_METHODS.session_cancel: { - const validatedParams = schema.cancelNotificationSchema.parse(params); - return agent.cancel(validatedParams); - } - default: - throw RequestError.methodNotFound(method); - } - }; - - this.#connection = new Connection(handler, input, output); - } - - /** - * Streams new content to the client including text, tool calls, etc. - */ - async sessionUpdate(params: schema.SessionNotification): Promise { - return await this.#connection.sendNotification( - schema.CLIENT_METHODS.session_update, - params, - ); - } - - /** - * Request permission before running a tool - * - * The agent specifies a series of permission options with different granularity, - * and the client returns the chosen one. - */ - async requestPermission( - params: schema.RequestPermissionRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.session_request_permission, - params, - ); - } - - async readTextFile( - params: schema.ReadTextFileRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.fs_read_text_file, - params, - ); - } - - async writeTextFile( - params: schema.WriteTextFileRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.fs_write_text_file, - params, - ); - } -} - -type AnyMessage = AnyRequest | AnyResponse | AnyNotification; - -type AnyRequest = { - jsonrpc: '2.0'; - id: string | number; - method: string; - params?: unknown; -}; - -type AnyResponse = { - jsonrpc: '2.0'; - id: string | number; -} & Result; - -type AnyNotification = { - jsonrpc: '2.0'; - method: string; - params?: unknown; -}; - -type Result = - | { - result: T; - } - | { - error: ErrorResponse; - }; - -type ErrorResponse = { - code: number; - message: string; - data?: unknown; -}; - -type PendingResponse = { - resolve: (response: unknown) => void; - reject: (error: ErrorResponse) => void; -}; - -type MethodHandler = (method: string, params: unknown) => Promise; - -class Connection { - #pendingResponses: Map = new Map(); - #nextRequestId: number = 0; - #handler: MethodHandler; - #peerInput: WritableStream; - #writeQueue: Promise = Promise.resolve(); - #textEncoder: TextEncoder; - - constructor( - handler: MethodHandler, - peerInput: WritableStream, - peerOutput: ReadableStream, - ) { - this.#handler = handler; - this.#peerInput = peerInput; - this.#textEncoder = new TextEncoder(); - this.#receive(peerOutput); - } - - async #receive(output: ReadableStream) { - let content = ''; - const decoder = new TextDecoder(); - for await (const chunk of output) { - content += decoder.decode(chunk, { stream: true }); - const lines = content.split('\n'); - content = lines.pop() || ''; - - for (const line of lines) { - const trimmedLine = line.trim(); - - if (trimmedLine) { - const message = JSON.parse(trimmedLine); - this.#processMessage(message); - } - } - } - } - - async #processMessage(message: AnyMessage) { - if ('method' in message && 'id' in message) { - // It's a request - const response = await this.#tryCallHandler( - message.method, - message.params, - ); - - await this.#sendMessage({ - jsonrpc: '2.0', - id: message.id, - ...response, - }); - } else if ('method' in message) { - // It's a notification - await this.#tryCallHandler(message.method, message.params); - } else if ('id' in message) { - // It's a response - this.#handleResponse(message as AnyResponse); - } - } - - async #tryCallHandler( - method: string, - params?: unknown, - ): Promise> { - try { - const result = await this.#handler(method, params); - return { result: result ?? null }; - } catch (error: unknown) { - if (error instanceof RequestError) { - return error.toResult(); - } - - if (error instanceof z.ZodError) { - return RequestError.invalidParams( - JSON.stringify(error.format(), undefined, 2), - ).toResult(); - } - - let details; - - if (error instanceof Error) { - details = error.message; - } else if ( - typeof error === 'object' && - error != null && - 'message' in error && - typeof error.message === 'string' - ) { - details = error.message; - } - - return RequestError.internalError(details).toResult(); - } - } - - #handleResponse(response: AnyResponse) { - const pendingResponse = this.#pendingResponses.get(response.id); - if (pendingResponse) { - if ('result' in response) { - pendingResponse.resolve(response.result); - } else if ('error' in response) { - pendingResponse.reject(response.error); - } - this.#pendingResponses.delete(response.id); - } - } - - async sendRequest(method: string, params?: Req): Promise { - const id = this.#nextRequestId++; - const responsePromise = new Promise((resolve, reject) => { - this.#pendingResponses.set(id, { resolve, reject }); - }); - await this.#sendMessage({ jsonrpc: '2.0', id, method, params }); - return responsePromise as Promise; - } - - async sendNotification(method: string, params?: N): Promise { - await this.#sendMessage({ jsonrpc: '2.0', method, params }); - } - - async #sendMessage(json: AnyMessage) { - const content = JSON.stringify(json) + '\n'; - this.#writeQueue = this.#writeQueue - .then(async () => { - const writer = this.#peerInput.getWriter(); - try { - await writer.write(this.#textEncoder.encode(content)); - } finally { - writer.releaseLock(); - } - }) - .catch((error) => { - // Continue processing writes on error - coreEvents.emitFeedback('error', 'ACP write error.', error); - }); - return this.#writeQueue; - } -} - -export class RequestError extends Error { - data?: { details?: string }; - - constructor( - public code: number, - message: string, - details?: string, - ) { - super(message); - this.name = 'RequestError'; - if (details) { - this.data = { details }; - } - } - - static parseError(details?: string): RequestError { - return new RequestError(-32700, 'Parse error', details); - } - - static invalidRequest(details?: string): RequestError { - return new RequestError(-32600, 'Invalid request', details); - } - - static methodNotFound(details?: string): RequestError { - return new RequestError(-32601, 'Method not found', details); - } - - static invalidParams(details?: string): RequestError { - return new RequestError(-32602, 'Invalid params', details); - } - - static internalError(details?: string): RequestError { - return new RequestError(-32603, 'Internal error', details); - } - - static authRequired(details?: string): RequestError { - return new RequestError(-32000, 'Authentication required', details); - } - - toResult(): Result { - return { - error: { - code: this.code, - message: this.message, - data: this.data, - }, - }; - } -} - -export interface Client { - requestPermission( - params: schema.RequestPermissionRequest, - ): Promise; - sessionUpdate(params: schema.SessionNotification): Promise; - writeTextFile( - params: schema.WriteTextFileRequest, - ): Promise; - readTextFile( - params: schema.ReadTextFileRequest, - ): Promise; -} - -export interface Agent { - initialize( - params: schema.InitializeRequest, - ): Promise; - newSession( - params: schema.NewSessionRequest, - ): Promise; - loadSession?( - params: schema.LoadSessionRequest, - ): Promise; - authenticate(params: schema.AuthenticateRequest): Promise; - prompt(params: schema.PromptRequest): Promise; - cancel(params: schema.CancelNotification): Promise; -} diff --git a/apps/airiscode-cli/src/gemini-base/zed-integration/fileSystemService.ts b/apps/airiscode-cli/src/gemini-base/zed-integration/fileSystemService.ts deleted file mode 100644 index 9c12782a2..000000000 --- a/apps/airiscode-cli/src/gemini-base/zed-integration/fileSystemService.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { FileSystemService } from '@airiscode/gemini-cli-core'; -import type * as acp from './acp.js'; - -/** - * ACP client-based implementation of FileSystemService - */ -export class AcpFileSystemService implements FileSystemService { - constructor( - private readonly client: acp.Client, - private readonly sessionId: string, - private readonly capabilities: acp.FileSystemCapability, - private readonly fallback: FileSystemService, - ) {} - - async readTextFile(filePath: string): Promise { - if (!this.capabilities.readTextFile) { - return this.fallback.readTextFile(filePath); - } - - const response = await this.client.readTextFile({ - path: filePath, - sessionId: this.sessionId, - line: null, - limit: null, - }); - - return response.content; - } - - async writeTextFile(filePath: string, content: string): Promise { - if (!this.capabilities.writeTextFile) { - return this.fallback.writeTextFile(filePath, content); - } - - await this.client.writeTextFile({ - path: filePath, - content, - sessionId: this.sessionId, - }); - } - findFiles(fileName: string, searchPaths: readonly string[]): string[] { - return this.fallback.findFiles(fileName, searchPaths); - } -} diff --git a/apps/airiscode-cli/src/gemini-base/zed-integration/schema.ts b/apps/airiscode-cli/src/gemini-base/zed-integration/schema.ts deleted file mode 100644 index b35cc47d5..000000000 --- a/apps/airiscode-cli/src/gemini-base/zed-integration/schema.ts +++ /dev/null @@ -1,466 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { z } from 'zod'; - -export const AGENT_METHODS = { - authenticate: 'authenticate', - initialize: 'initialize', - session_cancel: 'session/cancel', - session_load: 'session/load', - session_new: 'session/new', - session_prompt: 'session/prompt', -}; - -export const CLIENT_METHODS = { - fs_read_text_file: 'fs/read_text_file', - fs_write_text_file: 'fs/write_text_file', - session_request_permission: 'session/request_permission', - session_update: 'session/update', -}; - -export const PROTOCOL_VERSION = 1; - -export type WriteTextFileRequest = z.infer; - -export type ReadTextFileRequest = z.infer; - -export type PermissionOptionKind = z.infer; - -export type Role = z.infer; - -export type TextResourceContents = z.infer; - -export type BlobResourceContents = z.infer; - -export type ToolKind = z.infer; - -export type ToolCallStatus = z.infer; - -export type WriteTextFileResponse = z.infer; - -export type ReadTextFileResponse = z.infer; - -export type RequestPermissionOutcome = z.infer< - typeof requestPermissionOutcomeSchema ->; - -export type CancelNotification = z.infer; - -export type AuthenticateRequest = z.infer; - -export type AuthenticateResponse = z.infer; - -export type NewSessionResponse = z.infer; - -export type LoadSessionResponse = z.infer; - -export type StopReason = z.infer; - -export type PromptResponse = z.infer; - -export type ToolCallLocation = z.infer; - -export type PlanEntry = z.infer; - -export type PermissionOption = z.infer; - -export type Annotations = z.infer; - -export type RequestPermissionResponse = z.infer< - typeof requestPermissionResponseSchema ->; - -export type FileSystemCapability = z.infer; - -export type EnvVariable = z.infer; - -export type McpServer = z.infer; - -export type AgentCapabilities = z.infer; - -export type AuthMethod = z.infer; - -export type PromptCapabilities = z.infer; - -export type ClientResponse = z.infer; - -export type ClientNotification = z.infer; - -export type EmbeddedResourceResource = z.infer< - typeof embeddedResourceResourceSchema ->; - -export type NewSessionRequest = z.infer; - -export type LoadSessionRequest = z.infer; - -export type InitializeResponse = z.infer; - -export type ContentBlock = z.infer; - -export type ToolCallContent = z.infer; - -export type ToolCall = z.infer; - -export type ClientCapabilities = z.infer; - -export type PromptRequest = z.infer; - -export type SessionUpdate = z.infer; - -export type AgentResponse = z.infer; - -export type RequestPermissionRequest = z.infer< - typeof requestPermissionRequestSchema ->; - -export type InitializeRequest = z.infer; - -export type SessionNotification = z.infer; - -export type ClientRequest = z.infer; - -export type AgentRequest = z.infer; - -export type AgentNotification = z.infer; - -export const writeTextFileRequestSchema = z.object({ - content: z.string(), - path: z.string(), - sessionId: z.string(), -}); - -export const readTextFileRequestSchema = z.object({ - limit: z.number().optional().nullable(), - line: z.number().optional().nullable(), - path: z.string(), - sessionId: z.string(), -}); - -export const permissionOptionKindSchema = z.union([ - z.literal('allow_once'), - z.literal('allow_always'), - z.literal('reject_once'), - z.literal('reject_always'), -]); - -export const roleSchema = z.union([z.literal('assistant'), z.literal('user')]); - -export const textResourceContentsSchema = z.object({ - mimeType: z.string().optional().nullable(), - text: z.string(), - uri: z.string(), -}); - -export const blobResourceContentsSchema = z.object({ - blob: z.string(), - mimeType: z.string().optional().nullable(), - uri: z.string(), -}); - -export const toolKindSchema = z.union([ - z.literal('read'), - z.literal('edit'), - z.literal('delete'), - z.literal('move'), - z.literal('search'), - z.literal('execute'), - z.literal('think'), - z.literal('fetch'), - z.literal('other'), -]); - -export const toolCallStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed'), -]); - -export const writeTextFileResponseSchema = z.null(); - -export const readTextFileResponseSchema = z.object({ - content: z.string(), -}); - -export const requestPermissionOutcomeSchema = z.union([ - z.object({ - outcome: z.literal('cancelled'), - }), - z.object({ - optionId: z.string(), - outcome: z.literal('selected'), - }), -]); - -export const cancelNotificationSchema = z.object({ - sessionId: z.string(), -}); - -export const authenticateRequestSchema = z.object({ - methodId: z.string(), -}); - -export const authenticateResponseSchema = z.null(); - -export const newSessionResponseSchema = z.object({ - sessionId: z.string(), -}); - -export const loadSessionResponseSchema = z.null(); - -export const stopReasonSchema = z.union([ - z.literal('end_turn'), - z.literal('max_tokens'), - z.literal('refusal'), - z.literal('cancelled'), -]); - -export const promptResponseSchema = z.object({ - stopReason: stopReasonSchema, -}); - -export const toolCallLocationSchema = z.object({ - line: z.number().optional().nullable(), - path: z.string(), -}); - -export const planEntrySchema = z.object({ - content: z.string(), - priority: z.union([z.literal('high'), z.literal('medium'), z.literal('low')]), - status: z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - ]), -}); - -export const permissionOptionSchema = z.object({ - kind: permissionOptionKindSchema, - name: z.string(), - optionId: z.string(), -}); - -export const annotationsSchema = z.object({ - audience: z.array(roleSchema).optional().nullable(), - lastModified: z.string().optional().nullable(), - priority: z.number().optional().nullable(), -}); - -export const requestPermissionResponseSchema = z.object({ - outcome: requestPermissionOutcomeSchema, -}); - -export const fileSystemCapabilitySchema = z.object({ - readTextFile: z.boolean(), - writeTextFile: z.boolean(), -}); - -export const envVariableSchema = z.object({ - name: z.string(), - value: z.string(), -}); - -export const mcpServerSchema = z.object({ - args: z.array(z.string()), - command: z.string(), - env: z.array(envVariableSchema), - name: z.string(), -}); - -export const promptCapabilitiesSchema = z.object({ - audio: z.boolean().optional(), - embeddedContext: z.boolean().optional(), - image: z.boolean().optional(), -}); - -export const agentCapabilitiesSchema = z.object({ - loadSession: z.boolean().optional(), - promptCapabilities: promptCapabilitiesSchema.optional(), -}); - -export const authMethodSchema = z.object({ - description: z.string().nullable(), - id: z.string(), - name: z.string(), -}); - -export const clientResponseSchema = z.union([ - writeTextFileResponseSchema, - readTextFileResponseSchema, - requestPermissionResponseSchema, -]); - -export const clientNotificationSchema = cancelNotificationSchema; - -export const embeddedResourceResourceSchema = z.union([ - textResourceContentsSchema, - blobResourceContentsSchema, -]); - -export const newSessionRequestSchema = z.object({ - cwd: z.string(), - mcpServers: z.array(mcpServerSchema), -}); - -export const loadSessionRequestSchema = z.object({ - cwd: z.string(), - mcpServers: z.array(mcpServerSchema), - sessionId: z.string(), -}); - -export const initializeResponseSchema = z.object({ - agentCapabilities: agentCapabilitiesSchema, - authMethods: z.array(authMethodSchema), - protocolVersion: z.number(), -}); - -export const contentBlockSchema = z.union([ - z.object({ - annotations: annotationsSchema.optional().nullable(), - text: z.string(), - type: z.literal('text'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - data: z.string(), - mimeType: z.string(), - type: z.literal('image'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - data: z.string(), - mimeType: z.string(), - type: z.literal('audio'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - description: z.string().optional().nullable(), - mimeType: z.string().optional().nullable(), - name: z.string(), - size: z.number().optional().nullable(), - title: z.string().optional().nullable(), - type: z.literal('resource_link'), - uri: z.string(), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - resource: embeddedResourceResourceSchema, - type: z.literal('resource'), - }), -]); - -export const toolCallContentSchema = z.union([ - z.object({ - content: contentBlockSchema, - type: z.literal('content'), - }), - z.object({ - newText: z.string(), - oldText: z.string().nullable(), - path: z.string(), - type: z.literal('diff'), - }), -]); - -export const toolCallSchema = z.object({ - content: z.array(toolCallContentSchema).optional(), - kind: toolKindSchema, - locations: z.array(toolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - status: toolCallStatusSchema, - title: z.string(), - toolCallId: z.string(), -}); - -export const clientCapabilitiesSchema = z.object({ - fs: fileSystemCapabilitySchema, -}); - -export const promptRequestSchema = z.object({ - prompt: z.array(contentBlockSchema), - sessionId: z.string(), -}); - -export const sessionUpdateSchema = z.union([ - z.object({ - content: contentBlockSchema, - sessionUpdate: z.literal('user_message_chunk'), - }), - z.object({ - content: contentBlockSchema, - sessionUpdate: z.literal('agent_message_chunk'), - }), - z.object({ - content: contentBlockSchema, - sessionUpdate: z.literal('agent_thought_chunk'), - }), - z.object({ - content: z.array(toolCallContentSchema).optional(), - kind: toolKindSchema, - locations: z.array(toolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - sessionUpdate: z.literal('tool_call'), - status: toolCallStatusSchema, - title: z.string(), - toolCallId: z.string(), - }), - z.object({ - content: z.array(toolCallContentSchema).optional().nullable(), - kind: toolKindSchema.optional().nullable(), - locations: z.array(toolCallLocationSchema).optional().nullable(), - rawInput: z.unknown().optional(), - sessionUpdate: z.literal('tool_call_update'), - status: toolCallStatusSchema.optional().nullable(), - title: z.string().optional().nullable(), - toolCallId: z.string(), - }), - z.object({ - entries: z.array(planEntrySchema), - sessionUpdate: z.literal('plan'), - }), -]); - -export const agentResponseSchema = z.union([ - initializeResponseSchema, - authenticateResponseSchema, - newSessionResponseSchema, - loadSessionResponseSchema, - promptResponseSchema, -]); - -export const requestPermissionRequestSchema = z.object({ - options: z.array(permissionOptionSchema), - sessionId: z.string(), - toolCall: toolCallSchema, -}); - -export const initializeRequestSchema = z.object({ - clientCapabilities: clientCapabilitiesSchema, - protocolVersion: z.number(), -}); - -export const sessionNotificationSchema = z.object({ - sessionId: z.string(), - update: sessionUpdateSchema, -}); - -export const clientRequestSchema = z.union([ - writeTextFileRequestSchema, - readTextFileRequestSchema, - requestPermissionRequestSchema, -]); - -export const agentRequestSchema = z.union([ - initializeRequestSchema, - authenticateRequestSchema, - newSessionRequestSchema, - loadSessionRequestSchema, - promptRequestSchema, -]); - -export const agentNotificationSchema = sessionNotificationSchema; diff --git a/apps/airiscode-cli/src/gemini-base/zed-integration/zedIntegration.ts b/apps/airiscode-cli/src/gemini-base/zed-integration/zedIntegration.ts deleted file mode 100644 index 497945f14..000000000 --- a/apps/airiscode-cli/src/gemini-base/zed-integration/zedIntegration.ts +++ /dev/null @@ -1,941 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { WritableStream, ReadableStream } from 'node:stream/web'; - -import type { - Config, - GeminiChat, - ToolResult, - ToolCallConfirmationDetails, - FilterFilesOptions, -} from '@airiscode/gemini-cli-core'; -import { - AuthType, - logToolCall, - convertToFunctionResponse, - ToolConfirmationOutcome, - clearCachedCredentialFile, - isNodeError, - getErrorMessage, - isWithinRoot, - getErrorStatus, - MCPServerConfig, - DiscoveredMCPTool, - StreamEventType, - ToolCallEvent, - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, - DEFAULT_GEMINI_FLASH_MODEL, - debugLogger, -} from '@airiscode/gemini-cli-core'; -import * as acp from './acp.js'; -import { AcpFileSystemService } from './fileSystemService.js'; -import { Readable, Writable } from 'node:stream'; -import type { Content, Part, FunctionCall } from '@google/genai'; -import type { LoadedSettings } from '../config/settings.js'; -import { SettingScope } from '../config/settings.js'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { z } from 'zod'; - -import { randomUUID } from 'node:crypto'; -import type { CliArgs } from '../config/config.js'; -import { loadCliConfig } from '../config/config.js'; - -/** - * Resolves the model to use based on the current configuration. - * - * If the model is set to "auto", it will use the flash model if in fallback - * mode, otherwise it will use the default model. - */ -export function resolveModel(model: string, isInFallbackMode: boolean): string { - if (model === DEFAULT_GEMINI_MODEL_AUTO) { - return isInFallbackMode ? DEFAULT_GEMINI_FLASH_MODEL : DEFAULT_GEMINI_MODEL; - } - return model; -} - -export async function runZedIntegration( - config: Config, - settings: LoadedSettings, - argv: CliArgs, -) { - const stdout = Writable.toWeb(process.stdout) as WritableStream; - const stdin = Readable.toWeb(process.stdin) as ReadableStream; - - // Stdout is used to send messages to the client, so console.log/console.info - // messages to stderr so that they don't interfere with ACP. - console.log = console.error; - console.info = console.error; - console.debug = console.error; - - new acp.AgentSideConnection( - (client: acp.Client) => new GeminiAgent(config, settings, argv, client), - stdout, - stdin, - ); -} - -class GeminiAgent { - private sessions: Map = new Map(); - private clientCapabilities: acp.ClientCapabilities | undefined; - - constructor( - private config: Config, - private settings: LoadedSettings, - private argv: CliArgs, - private client: acp.Client, - ) {} - - async initialize( - args: acp.InitializeRequest, - ): Promise { - this.clientCapabilities = args.clientCapabilities; - const authMethods = [ - { - id: AuthType.LOGIN_WITH_GOOGLE, - name: 'Log in with Google', - description: null, - }, - { - id: AuthType.USE_GEMINI, - name: 'Use Gemini API key', - description: - 'Requires setting the `GEMINI_API_KEY` environment variable', - }, - { - id: AuthType.USE_VERTEX_AI, - name: 'Vertex AI', - description: null, - }, - ]; - - return { - protocolVersion: acp.PROTOCOL_VERSION, - authMethods, - agentCapabilities: { - loadSession: false, - promptCapabilities: { - image: true, - audio: true, - embeddedContext: true, - }, - }, - }; - } - - async authenticate({ methodId }: acp.AuthenticateRequest): Promise { - const method = z.nativeEnum(AuthType).parse(methodId); - - await clearCachedCredentialFile(); - await this.config.refreshAuth(method); - this.settings.setValue( - SettingScope.User, - 'security.auth.selectedType', - method, - ); - } - - async newSession({ - cwd, - mcpServers, - }: acp.NewSessionRequest): Promise { - const sessionId = randomUUID(); - const config = await this.newSessionConfig(sessionId, cwd, mcpServers); - - let isAuthenticated = false; - if (this.settings.merged.security?.auth?.selectedType) { - try { - await config.refreshAuth( - this.settings.merged.security.auth.selectedType, - ); - isAuthenticated = true; - } catch (e) { - debugLogger.error(`Authentication failed: ${e}`); - } - } - - if (!isAuthenticated) { - throw acp.RequestError.authRequired(); - } - - if (this.clientCapabilities?.fs) { - const acpFileSystemService = new AcpFileSystemService( - this.client, - sessionId, - this.clientCapabilities.fs, - config.getFileSystemService(), - ); - config.setFileSystemService(acpFileSystemService); - } - - const geminiClient = config.getGeminiClient(); - const chat = await geminiClient.startChat(); - const session = new Session(sessionId, chat, config, this.client); - this.sessions.set(sessionId, session); - - return { - sessionId, - }; - } - - async newSessionConfig( - sessionId: string, - cwd: string, - mcpServers: acp.McpServer[], - ): Promise { - const mergedMcpServers = { ...this.settings.merged.mcpServers }; - - for (const { command, args, env: rawEnv, name } of mcpServers) { - const env: Record = {}; - for (const { name: envName, value } of rawEnv) { - env[envName] = value; - } - mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd); - } - - const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; - - const config = await loadCliConfig(settings, sessionId, this.argv, cwd); - - await config.initialize(); - return config; - } - - async cancel(params: acp.CancelNotification): Promise { - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - await session.cancelPendingPrompt(); - } - - async prompt(params: acp.PromptRequest): Promise { - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - return session.prompt(params); - } -} - -class Session { - private pendingPrompt: AbortController | null = null; - - constructor( - private readonly id: string, - private readonly chat: GeminiChat, - private readonly config: Config, - private readonly client: acp.Client, - ) {} - - async cancelPendingPrompt(): Promise { - if (!this.pendingPrompt) { - throw new Error('Not currently generating'); - } - - this.pendingPrompt.abort(); - this.pendingPrompt = null; - } - - async prompt(params: acp.PromptRequest): Promise { - this.pendingPrompt?.abort(); - const pendingSend = new AbortController(); - this.pendingPrompt = pendingSend; - - const promptId = Math.random().toString(16).slice(2); - const chat = this.chat; - - const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); - - let nextMessage: Content | null = { role: 'user', parts }; - - while (nextMessage !== null) { - if (pendingSend.signal.aborted) { - chat.addHistory(nextMessage); - return { stopReason: 'cancelled' }; - } - - const functionCalls: FunctionCall[] = []; - - try { - const responseStream = await chat.sendMessageStream( - resolveModel(this.config.getModel(), this.config.isInFallbackMode()), - { - message: nextMessage?.parts ?? [], - config: { - abortSignal: pendingSend.signal, - }, - }, - promptId, - ); - nextMessage = null; - - for await (const resp of responseStream) { - if (pendingSend.signal.aborted) { - return { stopReason: 'cancelled' }; - } - - if ( - resp.type === StreamEventType.CHUNK && - resp.value.candidates && - resp.value.candidates.length > 0 - ) { - const candidate = resp.value.candidates[0]; - for (const part of candidate.content?.parts ?? []) { - if (!part.text) { - continue; - } - - const content: acp.ContentBlock = { - type: 'text', - text: part.text, - }; - - this.sendUpdate({ - sessionUpdate: part.thought - ? 'agent_thought_chunk' - : 'agent_message_chunk', - content, - }); - } - } - - if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { - functionCalls.push(...resp.value.functionCalls); - } - } - } catch (error) { - if (getErrorStatus(error) === 429) { - throw new acp.RequestError( - 429, - 'Rate limit exceeded. Try again later.', - ); - } - - throw error; - } - - if (functionCalls.length > 0) { - const toolResponseParts: Part[] = []; - - for (const fc of functionCalls) { - const response = await this.runTool(pendingSend.signal, promptId, fc); - toolResponseParts.push(...response); - } - - nextMessage = { role: 'user', parts: toolResponseParts }; - } - } - - return { stopReason: 'end_turn' }; - } - - private async sendUpdate(update: acp.SessionUpdate): Promise { - const params: acp.SessionNotification = { - sessionId: this.id, - update, - }; - - await this.client.sessionUpdate(params); - } - - private async runTool( - abortSignal: AbortSignal, - promptId: string, - fc: FunctionCall, - ): Promise { - const callId = fc.id ?? `${fc.name}-${Date.now()}`; - const args = (fc.args ?? {}) as Record; - - const startTime = Date.now(); - - const errorResponse = (error: Error) => { - const durationMs = Date.now() - startTime; - logToolCall( - this.config, - new ToolCallEvent( - undefined, - fc.name ?? '', - args, - durationMs, - false, - promptId, - typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool - ? 'mcp' - : 'native', - error.message, - ), - ); - - return [ - { - functionResponse: { - id: callId, - name: fc.name ?? '', - response: { error: error.message }, - }, - }, - ]; - }; - - if (!fc.name) { - return errorResponse(new Error('Missing function name')); - } - - const toolRegistry = this.config.getToolRegistry(); - const tool = toolRegistry.getTool(fc.name as string); - - if (!tool) { - return errorResponse( - new Error(`Tool "${fc.name}" not found in registry.`), - ); - } - - try { - const invocation = tool.build(args); - - const confirmationDetails = - await invocation.shouldConfirmExecute(abortSignal); - - if (confirmationDetails) { - const content: acp.ToolCallContent[] = []; - - if (confirmationDetails.type === 'edit') { - content.push({ - type: 'diff', - path: confirmationDetails.fileName, - oldText: confirmationDetails.originalContent, - newText: confirmationDetails.newContent, - }); - } - - const params: acp.RequestPermissionRequest = { - sessionId: this.id, - options: toPermissionOptions(confirmationDetails), - toolCall: { - toolCallId: callId, - status: 'pending', - title: invocation.getDescription(), - content, - locations: invocation.toolLocations(), - kind: tool.kind, - }, - }; - - const output = await this.client.requestPermission(params); - const outcome = - output.outcome.outcome === 'cancelled' - ? ToolConfirmationOutcome.Cancel - : z - .nativeEnum(ToolConfirmationOutcome) - .parse(output.outcome.optionId); - - await confirmationDetails.onConfirm(outcome); - - switch (outcome) { - case ToolConfirmationOutcome.Cancel: - return errorResponse( - new Error(`Tool "${fc.name}" was canceled by the user.`), - ); - case ToolConfirmationOutcome.ProceedOnce: - case ToolConfirmationOutcome.ProceedAlways: - case ToolConfirmationOutcome.ProceedAlwaysServer: - case ToolConfirmationOutcome.ProceedAlwaysTool: - case ToolConfirmationOutcome.ModifyWithEditor: - break; - default: { - const resultOutcome: never = outcome; - throw new Error(`Unexpected: ${resultOutcome}`); - } - } - } else { - await this.sendUpdate({ - sessionUpdate: 'tool_call', - toolCallId: callId, - status: 'in_progress', - title: invocation.getDescription(), - content: [], - locations: invocation.toolLocations(), - kind: tool.kind, - }); - } - - const toolResult: ToolResult = await invocation.execute(abortSignal); - const content = toToolCallContent(toolResult); - - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'completed', - content: content ? [content] : [], - }); - - const durationMs = Date.now() - startTime; - logToolCall( - this.config, - new ToolCallEvent( - undefined, - fc.name ?? '', - args, - durationMs, - true, - promptId, - typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool - ? 'mcp' - : 'native', - ), - ); - - return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); - - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'failed', - content: [ - { type: 'content', content: { type: 'text', text: error.message } }, - ], - }); - - return errorResponse(error); - } - } - - async #resolvePrompt( - message: acp.ContentBlock[], - abortSignal: AbortSignal, - ): Promise { - const FILE_URI_SCHEME = 'file://'; - - const embeddedContext: acp.EmbeddedResourceResource[] = []; - - const parts = message.map((part) => { - switch (part.type) { - case 'text': - return { text: part.text }; - case 'image': - case 'audio': - return { - inlineData: { - mimeType: part.mimeType, - data: part.data, - }, - }; - case 'resource_link': { - if (part.uri.startsWith(FILE_URI_SCHEME)) { - return { - fileData: { - mimeData: part.mimeType, - name: part.name, - fileUri: part.uri.slice(FILE_URI_SCHEME.length), - }, - }; - } else { - return { text: `@${part.uri}` }; - } - } - case 'resource': { - embeddedContext.push(part.resource); - return { text: `@${part.resource.uri}` }; - } - default: { - const unreachable: never = part; - throw new Error(`Unexpected chunk type: '${unreachable}'`); - } - } - }); - - const atPathCommandParts = parts.filter((part) => 'fileData' in part); - - if (atPathCommandParts.length === 0 && embeddedContext.length === 0) { - return parts; - } - - const atPathToResolvedSpecMap = new Map(); - - // Get centralized file discovery service - const fileDiscovery = this.config.getFileService(); - const fileFilteringOptions: FilterFilesOptions = - this.config.getFileFilteringOptions(); - - const pathSpecsToRead: string[] = []; - const contentLabelsForDisplay: string[] = []; - const ignoredPaths: string[] = []; - - const toolRegistry = this.config.getToolRegistry(); - const readManyFilesTool = toolRegistry.getTool('read_many_files'); - const globTool = toolRegistry.getTool('glob'); - - if (!readManyFilesTool) { - throw new Error('Error: read_many_files tool not found.'); - } - - for (const atPathPart of atPathCommandParts) { - const pathName = atPathPart.fileData!.fileUri; - // Check if path should be ignored - if (fileDiscovery.shouldIgnoreFile(pathName, fileFilteringOptions)) { - ignoredPaths.push(pathName); - debugLogger.warn(`Path ${pathName} is ignored and will be skipped.`); - continue; - } - let currentPathSpec = pathName; - let resolvedSuccessfully = false; - try { - const absolutePath = path.resolve(this.config.getTargetDir(), pathName); - if (isWithinRoot(absolutePath, this.config.getTargetDir())) { - const stats = await fs.stat(absolutePath); - if (stats.isDirectory()) { - currentPathSpec = pathName.endsWith('/') - ? `${pathName}**` - : `${pathName}/**`; - this.debug( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, - ); - } else { - this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`); - } - resolvedSuccessfully = true; - } else { - this.debug( - `Path ${pathName} is outside the project directory. Skipping.`, - ); - } - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (this.config.getEnableRecursiveFileSearch() && globTool) { - this.debug( - `Path ${pathName} not found directly, attempting glob search.`, - ); - try { - const globResult = await globTool.buildAndExecute( - { - pattern: `**/*${pathName}*`, - path: this.config.getTargetDir(), - }, - abortSignal, - ); - if ( - globResult.llmContent && - typeof globResult.llmContent === 'string' && - !globResult.llmContent.startsWith('No files found') && - !globResult.llmContent.startsWith('Error:') - ) { - const lines = globResult.llmContent.split('\n'); - if (lines.length > 1 && lines[1]) { - const firstMatchAbsolute = lines[1].trim(); - currentPathSpec = path.relative( - this.config.getTargetDir(), - firstMatchAbsolute, - ); - this.debug( - `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, - ); - resolvedSuccessfully = true; - } else { - this.debug( - `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, - ); - } - } else { - this.debug( - `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, - ); - } - } catch (globError) { - debugLogger.error( - `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, - ); - } - } else { - this.debug( - `Glob tool not found. Path ${pathName} will be skipped.`, - ); - } - } else { - debugLogger.error( - `Error stating path ${pathName}. Path ${pathName} will be skipped.`, - ); - } - } - if (resolvedSuccessfully) { - pathSpecsToRead.push(currentPathSpec); - atPathToResolvedSpecMap.set(pathName, currentPathSpec); - contentLabelsForDisplay.push(pathName); - } - } - - // Construct the initial part of the query for the LLM - let initialQueryText = ''; - for (let i = 0; i < parts.length; i++) { - const chunk = parts[i]; - if ('text' in chunk) { - initialQueryText += chunk.text; - } else { - // type === 'atPath' - const resolvedSpec = - chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri); - if ( - i > 0 && - initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') && - resolvedSpec - ) { - // Add space if previous part was text and didn't end with space, or if previous was @path - const prevPart = parts[i - 1]; - if ( - 'text' in prevPart || - ('fileData' in prevPart && - atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri)) - ) { - initialQueryText += ' '; - } - } - if (resolvedSpec) { - initialQueryText += `@${resolvedSpec}`; - } else { - // If not resolved for reading (e.g. lone @ or invalid path that was skipped), - // add the original @-string back, ensuring spacing if it's not the first element. - if ( - i > 0 && - initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') && - !chunk.fileData?.fileUri.startsWith(' ') - ) { - initialQueryText += ' '; - } - if (chunk.fileData?.fileUri) { - initialQueryText += `@${chunk.fileData.fileUri}`; - } - } - } - } - initialQueryText = initialQueryText.trim(); - // Inform user about ignored paths - if (ignoredPaths.length > 0) { - this.debug( - `Ignored ${ignoredPaths.length} files: ${ignoredPaths.join(', ')}`, - ); - } - - const processedQueryParts: Part[] = [{ text: initialQueryText }]; - - if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) { - // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText - debugLogger.warn('No valid file paths found in @ commands to read.'); - return [{ text: initialQueryText }]; - } - - if (pathSpecsToRead.length > 0) { - const toolArgs = { - paths: pathSpecsToRead, - }; - - const callId = `${readManyFilesTool.name}-${Date.now()}`; - - try { - const invocation = readManyFilesTool.build(toolArgs); - - await this.sendUpdate({ - sessionUpdate: 'tool_call', - toolCallId: callId, - status: 'in_progress', - title: invocation.getDescription(), - content: [], - locations: invocation.toolLocations(), - kind: readManyFilesTool.kind, - }); - - const result = await invocation.execute(abortSignal); - const content = toToolCallContent(result) || { - type: 'content', - content: { - type: 'text', - text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, - }, - }; - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'completed', - content: content ? [content] : [], - }); - if (Array.isArray(result.llmContent)) { - const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; - processedQueryParts.push({ - text: '\n--- Content from referenced files ---', - }); - for (const part of result.llmContent) { - if (typeof part === 'string') { - const match = fileContentRegex.exec(part); - if (match) { - const filePathSpecInContent = match[1]; // This is a resolved pathSpec - const fileActualContent = match[2].trim(); - processedQueryParts.push({ - text: `\nContent from @${filePathSpecInContent}:\n`, - }); - processedQueryParts.push({ text: fileActualContent }); - } else { - processedQueryParts.push({ text: part }); - } - } else { - // part is a Part object. - processedQueryParts.push(part); - } - } - } else { - debugLogger.warn( - 'read_many_files tool returned no content or empty content.', - ); - } - } catch (error: unknown) { - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'failed', - content: [ - { - type: 'content', - content: { - type: 'text', - text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, - }, - }, - ], - }); - - throw error; - } - } - - if (embeddedContext.length > 0) { - processedQueryParts.push({ - text: '\n--- Content from referenced context ---', - }); - - for (const contextPart of embeddedContext) { - processedQueryParts.push({ - text: `\nContent from @${contextPart.uri}:\n`, - }); - if ('text' in contextPart) { - processedQueryParts.push({ - text: contextPart.text, - }); - } else { - processedQueryParts.push({ - inlineData: { - mimeType: contextPart.mimeType ?? 'application/octet-stream', - data: contextPart.blob, - }, - }); - } - } - } - - return processedQueryParts; - } - - debug(msg: string) { - if (this.config.getDebugMode()) { - debugLogger.warn(msg); - } - } -} - -function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { - if (toolResult.error?.message) { - throw new Error(toolResult.error.message); - } - - if (toolResult.returnDisplay) { - if (typeof toolResult.returnDisplay === 'string') { - return { - type: 'content', - content: { type: 'text', text: toolResult.returnDisplay }, - }; - } else { - if ('fileName' in toolResult.returnDisplay) { - return { - type: 'diff', - path: toolResult.returnDisplay.fileName, - oldText: toolResult.returnDisplay.originalContent, - newText: toolResult.returnDisplay.newContent, - }; - } - return null; - } - } else { - return null; - } -} - -const basicPermissionOptions = [ - { - optionId: ToolConfirmationOutcome.ProceedOnce, - name: 'Allow', - kind: 'allow_once', - }, - { - optionId: ToolConfirmationOutcome.Cancel, - name: 'Reject', - kind: 'reject_once', - }, -] as const; - -function toPermissionOptions( - confirmation: ToolCallConfirmationDetails, -): acp.PermissionOption[] { - switch (confirmation.type) { - case 'edit': - return [ - { - optionId: ToolConfirmationOutcome.ProceedAlways, - name: 'Allow All Edits', - kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'exec': - return [ - { - optionId: ToolConfirmationOutcome.ProceedAlways, - name: `Always Allow ${confirmation.rootCommand}`, - kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'mcp': - return [ - { - optionId: ToolConfirmationOutcome.ProceedAlwaysServer, - name: `Always Allow ${confirmation.serverName}`, - kind: 'allow_always', - }, - { - optionId: ToolConfirmationOutcome.ProceedAlwaysTool, - name: `Always Allow ${confirmation.toolName}`, - kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'info': - return [ - { - optionId: ToolConfirmationOutcome.ProceedAlways, - name: `Always Allow`, - kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - default: { - const unreachable: never = confirmation; - throw new Error(`Unexpected: ${unreachable}`); - } - } -} diff --git a/apps/airiscode-cli/src/gemini.tsx b/apps/airiscode-cli/src/gemini.tsx new file mode 100644 index 000000000..d41745bb4 --- /dev/null +++ b/apps/airiscode-cli/src/gemini.tsx @@ -0,0 +1,527 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + InputFormat, + isDebugLoggingDegraded, + logUserPrompt, + Storage, + type Config, + createDebugLogger, +} from '@airiscode/core'; +import { render } from 'ink'; +import dns from 'node:dns'; +import os from 'node:os'; +import { basename } from 'node:path'; +import v8 from 'node:v8'; +import React from 'react'; +import { validateAuthMethod } from './config/auth.js'; +import * as cliConfig from './config/config.js'; +import { loadCliConfig, parseArguments } from './config/config.js'; +import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; +import { getSettingsWarnings, loadSettings } from './config/settings.js'; +import { + initializeApp, + type InitializationResult, +} from './core/initializer.js'; +import { runNonInteractive } from './nonInteractiveCli.js'; +import { runNonInteractiveStreamJson } from './nonInteractive/session.js'; +import { AppContainer } from './ui/AppContainer.js'; +import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; +import { KeypressProvider } from './ui/contexts/KeypressContext.js'; +import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; +import { SettingsContext } from './ui/contexts/SettingsContext.js'; +import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; +import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { themeManager } from './ui/themes/theme-manager.js'; +import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; +import { checkForUpdates } from './ui/utils/updateCheck.js'; +import { + cleanupCheckpoints, + registerCleanup, + runExitCleanup, +} from './utils/cleanup.js'; +import { AppEvent, appEvents } from './utils/events.js'; +import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; +import { readStdin } from './utils/readStdin.js'; +import { + relaunchAppInChildProcess, + relaunchOnExitCode, +} from './utils/relaunch.js'; +import { start_sandbox } from './utils/sandbox.js'; +import { getStartupWarnings } from './utils/startupWarnings.js'; +import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; +import { getCliVersion } from './utils/version.js'; +import { writeStderrLine } from './utils/stdioHelpers.js'; +import { computeWindowTitle } from './utils/windowTitle.js'; +import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; +import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; +import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; + +const debugLogger = createDebugLogger('STARTUP'); + +export function validateDnsResolutionOrder( + order: string | undefined, +): DnsResolutionOrder { + const defaultValue: DnsResolutionOrder = 'ipv4first'; + if (order === undefined) { + return defaultValue; + } + if (order === 'ipv4first' || order === 'verbatim') { + return order; + } + // We don't want to throw here, just warn and use the default. + writeStderrLine( + `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`, + ); + return defaultValue; +} + +function getNodeMemoryArgs(isDebugMode: boolean): string[] { + const totalMemoryMB = os.totalmem() / (1024 * 1024); + const heapStats = v8.getHeapStatistics(); + const currentMaxOldSpaceSizeMb = Math.floor( + heapStats.heap_size_limit / 1024 / 1024, + ); + + // Set target to 50% of total memory + const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); + if (isDebugMode) { + writeStderrLine( + `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`, + ); + } + + if (process.env['AIRISCODE_NO_RELAUNCH']) { + return []; + } + + if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { + if (isDebugMode) { + writeStderrLine( + `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`, + ); + } + return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`]; + } + + return []; +} + +import { loadSandboxConfig } from './config/sandboxConfig.js'; +const runAcpAgent = null as any; + +export function setupUnhandledRejectionHandler() { + let unhandledRejectionOccurred = false; + process.on('unhandledRejection', (reason, _promise) => { + const errorMessage = `========================================= +This is an unexpected error. Please file a bug report using the /bug tool. +CRITICAL: Unhandled Promise Rejection! +========================================= +Reason: ${reason}${ + reason instanceof Error && reason.stack + ? ` +Stack trace: +${reason.stack}` + : '' + }`; + appEvents.emit(AppEvent.LogError, errorMessage); + if (!unhandledRejectionOccurred) { + unhandledRejectionOccurred = true; + appEvents.emit(AppEvent.OpenDebugConsole); + } + }); +} + +export async function startInteractiveUI( + config: Config, + settings: LoadedSettings, + startupWarnings: string[], + workspaceRoot: string = process.cwd(), + initializationResult: InitializationResult, +) { + const version = await getCliVersion(); + setWindowTitle(basename(workspaceRoot), settings); + + // Create wrapper component to use hooks inside render + const AppWrapper = () => { + const kittyProtocolStatus = useKittyKeyboardProtocol(); + const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + return ( + + + + + + + + + + + + ); + }; + + const instance = render( + process.env['DEBUG'] ? ( + + + + ) : ( + + ), + { + exitOnCtrlC: false, + isScreenReaderEnabled: config.getScreenReader(), + }, + ); + + // Check for updates only if enableAutoUpdate is not explicitly disabled. + // Using !== false ensures updates are enabled by default when undefined. + if (settings.merged.general?.enableAutoUpdate !== false) { + checkForUpdates() + .then((info) => { + handleAutoUpdate(info, settings, config.getProjectRoot()); + }) + .catch((err) => { + // Silently ignore update check errors. + debugLogger.warn(`Update check failed: ${err}`); + }); + } + + registerCleanup(() => instance.unmount()); +} + +export async function main() { + setupUnhandledRejectionHandler(); + const settings = loadSettings(); + await cleanupCheckpoints(); + + let argv = await parseArguments(); + + // Check for invalid input combinations early to prevent crashes + if (argv.promptInteractive && !process.stdin.isTTY) { + writeStderrLine( + 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.', + ); + process.exit(1); + } + + const isDebugMode = cliConfig.isDebugMode(argv); + + dns.setDefaultResultOrder( + validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), + ); + + // Load custom themes from settings + themeManager.loadCustomThemes(settings.merged.ui?.customThemes); + + if (settings.merged.ui?.theme) { + if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { + // If the theme is not found during initial load, log a warning and continue. + // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. + writeStderrLine( + `Warning: Theme "${settings.merged.ui?.theme}" not found.`, + ); + } + } + + // hop into sandbox if we are outside and sandboxing is enabled + if (!process.env['SANDBOX']) { + const memoryArgs = settings.merged.advanced?.autoConfigureMemory + ? getNodeMemoryArgs(isDebugMode) + : []; + const sandboxConfig = await loadSandboxConfig(settings.merged, argv); + // We intentially omit the list of extensions here because extensions + // should not impact auth or setting up the sandbox. + // TODO(jacobr): refactor loadCliConfig so there is a minimal version + // that only initializes enough config to enable refreshAuth or find + // another way to decouple refreshAuth from requiring a config. + + if (sandboxConfig) { + const partialConfig = await loadCliConfig( + settings.merged, + argv, + undefined, + [], + ); + + if (!settings.merged.security?.auth?.useExternal) { + // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. + try { + const authType = partialConfig.getModelsConfig().getCurrentAuthType(); + // Fresh users may not have selected/persisted an authType yet. + // In that case, defer auth prompting/selection to the main interactive flow. + if (authType) { + const err = validateAuthMethod(authType, partialConfig); + if (err) { + throw new Error(err); + } + + await partialConfig.refreshAuth(authType); + } + } catch (err) { + writeStderrLine(`Error authenticating: ${err}`); + process.exit(1); + } + } + // For stream-json and ACP modes, don't read stdin here — stdin carries + // protocol data (not a user prompt) and should be forwarded to the sandbox + // intact via stdio: 'inherit'. + const inputFormat = argv.inputFormat as string | undefined; + const isAcpMode = argv.acp || argv.experimentalAcp; + let stdinData = ''; + if (!process.stdin.isTTY && inputFormat !== 'stream-json' && !isAcpMode) { + stdinData = await readStdin(); + } + + // This function is a copy of the one from sandbox.ts + // It is moved here to decouple sandbox.ts from the CLI's argument structure. + const injectStdinIntoArgs = ( + args: string[], + stdinData?: string, + ): string[] => { + const finalArgs = [...args]; + if (stdinData) { + const promptIndex = finalArgs.findIndex( + (arg) => arg === '--prompt' || arg === '-p', + ); + if (promptIndex > -1 && finalArgs.length > promptIndex + 1) { + // If there's a prompt argument, prepend stdin to it + finalArgs[promptIndex + 1] = + `${stdinData}\n\n${finalArgs[promptIndex + 1]}`; + } else { + // If there's no prompt argument, add stdin as the prompt + finalArgs.push('--prompt', stdinData); + } + } + return finalArgs; + }; + + const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData); + + await relaunchOnExitCode(() => + start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs), + ); + process.exit(0); + } else { + // Relaunch app so we always have a child process that can be internally + // restarted if needed. + await relaunchAppInChildProcess(memoryArgs, []); + } + } + + // Handle --resume without a session ID by showing the session picker. + // Set the runtime output dir early so the picker can find sessions stored + // under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will + // be called again inside loadCliConfig). + if (argv.resume === '') { + Storage.setRuntimeBaseDir( + settings.merged.advanced?.runtimeOutputDir, + process.cwd(), + ); + const selectedSessionId = await showResumeSessionPicker(); + if (!selectedSessionId) { + // User cancelled or no sessions available + process.exit(0); + } + + // Update argv with the selected session ID + argv = { ...argv, resume: selectedSessionId }; + } + + // We are now past the logic handling potentially launching a child process + // to run AIRIS Code. It is now safe to perform expensive initialization that + // may have side effects. + + // Initialize output language file before config loads to ensure it's included in context + initializeLlmOutputLanguage(settings.merged.general?.outputLanguage); + + { + const config = await loadCliConfig( + settings.merged, + argv, + process.cwd(), + argv.extensions, + ); + + // Register cleanup for MCP clients as early as possible + // This ensures MCP server subprocesses are properly terminated on exit + registerCleanup(() => config.shutdown()); + + // FIXME: list extensions after the config initialize + // if (config.getListExtensions()) { + // console.log('Installed extensions:'); + // for (const extension of extensions) { + // console.log(`- ${extension.config.name}`); + // } + // process.exit(0); + // } + + const wasRaw = process.stdin.isRaw; + let kittyProtocolDetectionComplete: Promise | undefined; + if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { + // Set this as early as possible to avoid spurious characters from + // input showing up in the output. + process.stdin.setRawMode(true); + + // This cleanup isn't strictly needed but may help in certain situations. + process.on('SIGTERM', () => { + process.stdin.setRawMode(wasRaw); + }); + process.on('SIGINT', () => { + process.stdin.setRawMode(wasRaw); + }); + + // Detect and enable Kitty keyboard protocol once at startup. + kittyProtocolDetectionComplete = detectAndEnableKittyProtocol(); + } + + setMaxSizedBoxDebugging(isDebugMode); + + // Check input format early to determine initialization flow + // In TTY mode, ignore stream-json input format to prevent process from hanging + const inputFormat = process.stdin.isTTY + ? InputFormat.TEXT + : typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + + // For stream-json mode, defer config.initialize() until after the initialize control request + // For other modes, initialize normally + const initializationResult = await initializeApp(config, settings); + + if (config.getExperimentalZedIntegration()) { + await runAcpAgent(config, settings, argv); + // Clean up child processes and force exit, matching other non-interactive modes + await runExitCleanup(); + process.exit(0); + } + + let input = config.getQuestion(); + const startupWarnings = [ + ...new Set([ + ...(await getStartupWarnings()), + ...(await getUserStartupWarnings({ + workspaceRoot: process.cwd(), + useRipgrep: settings.merged.tools?.useRipgrep ?? true, + useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true, + })), + ...getSettingsWarnings(settings), + ...config.getWarnings(), + ]), + ]; + + // Render UI, passing necessary config values. Check that there is no command line question. + if (config.isInteractive()) { + // Need kitty detection to be complete before we can start the interactive UI. + await kittyProtocolDetectionComplete; + await startInteractiveUI( + config, + settings, + startupWarnings, + process.cwd(), + initializationResult!, + ); + return; + } + + // Print debug mode notice to stderr for non-interactive mode + if (config.getDebugMode()) { + writeStderrLine('Debug mode enabled'); + writeStderrLine( + `Logging to: ${Storage.getDebugLogPath(config.getSessionId())}`, + ); + if (isDebugLoggingDegraded()) { + writeStderrLine( + 'Warning: Debug logging is degraded (write failures occurred)', + ); + } + } + + // For non-stream-json mode, initialize config here + if (inputFormat !== InputFormat.STREAM_JSON) { + await config.initialize(); + } + + // Only read stdin if NOT in stream-json mode + // In stream-json mode, stdin is used for protocol messages (control requests, etc.) + // and should be consumed by StreamJsonInputReader instead + if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) { + const stdinData = await readStdin(); + if (stdinData) { + input = `${stdinData}\n\n${input}`; + } + } + + const nonInteractiveConfig = await validateNonInteractiveAuth( + settings.merged.security?.auth?.useExternal, + config, + settings, + ); + + const prompt_id = Math.random().toString(16).slice(2); + + if (inputFormat === InputFormat.STREAM_JSON) { + const trimmedInput = (input ?? '').trim(); + + await runNonInteractiveStreamJson( + nonInteractiveConfig, + trimmedInput.length > 0 ? trimmedInput : '', + ); + await runExitCleanup(); + process.exit(0); + } + + if (!input) { + writeStderrLine( + `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, + ); + process.exit(1); + } + + logUserPrompt(config, { + 'event.name': 'user_prompt', + 'event.timestamp': new Date().toISOString(), + prompt: input, + prompt_id, + auth_type: config.getContentGeneratorConfig()?.authType, + prompt_length: input.length, + }); + + debugLogger.debug(`Session ID: ${config.getSessionId()}`); + + await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id); + // Call cleanup before process.exit, which causes cleanup to not run + await runExitCleanup(); + process.exit(0); + } +} + +function setWindowTitle(title: string, settings: LoadedSettings) { + if (!settings.merged.ui?.hideWindowTitle) { + const windowTitle = computeWindowTitle(title); + process.stdout.write(`\x1b]2;${windowTitle}\x07`); + + process.on('exit', () => { + process.stdout.write(`\x1b]2;\x07`); + }); + } +} diff --git a/apps/airiscode-cli/src/generated/git-commit.ts b/apps/airiscode-cli/src/generated/git-commit.ts deleted file mode 100644 index bb7675941..000000000 --- a/apps/airiscode-cli/src/generated/git-commit.ts +++ /dev/null @@ -1 +0,0 @@ -export const GIT_COMMIT_INFO = 'dev-mode-no-commit-info'; \ No newline at end of file diff --git a/apps/airiscode-cli/src/hooks/useChatSession.ts b/apps/airiscode-cli/src/hooks/useChatSession.ts deleted file mode 100644 index eb43bdfcd..000000000 --- a/apps/airiscode-cli/src/hooks/useChatSession.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * useChatSession Hook - * - * Manages chat session with LLM driver and MCP tool execution loop. - * Implements the core conversation flow: - * 1. User sends message - * 2. LLM processes and may request tools - * 3. Tools executed via MCP - * 4. Results sent back to LLM - * 5. LLM continues until done - */ - -import { useState, useRef, useCallback } from 'react'; -import type { ModelDriver, ChatMessage, ChatRequest, ToolCall } from '@airiscode/drivers'; -import type { MCPSessionManager } from '@airiscode/mcp-session'; -import { ApprovalsLevel, TrustLevel } from '@airiscode/policies'; - -export interface ChatSessionConfig { - sessionId: string; - workingDir: string; - driver: ModelDriver; - mcpSession?: MCPSessionManager; - systemPrompt?: string; -} - -export interface ConversationMessage { - id: string; - role: 'user' | 'assistant' | 'system' | 'tool'; - content: string; - timestamp: Date; - toolCalls?: ToolCall[]; - toolResults?: Array<{ toolName: string; result: string }>; - streaming?: boolean; -} - -export interface UseChatSessionReturn { - messages: ConversationMessage[]; - isProcessing: boolean; - currentResponse: string; - sendMessage: (content: string) => Promise; - clearHistory: () => void; -} - -export function useChatSession(config: ChatSessionConfig): UseChatSessionReturn { - const { sessionId, workingDir, driver, mcpSession, systemPrompt } = config; - - const [messages, setMessages] = useState(() => { - if (systemPrompt) { - return [ - { - id: `system-${Date.now()}`, - role: 'system', - content: systemPrompt, - timestamp: new Date(), - }, - ]; - } - return []; - }); - - const [isProcessing, setIsProcessing] = useState(false); - const [currentResponse, setCurrentResponse] = useState(''); - const conversationRef = useRef([]); - - /** - * Execute tool calls via MCP - */ - const executeToolCalls = async (toolCalls: ToolCall[]): Promise => { - if (!mcpSession) { - // No MCP session, return error messages - return toolCalls.map((tc) => ({ - role: 'tool', - content: JSON.stringify({ - error: 'MCP session not available', - toolName: tc.name, - }), - toolCallId: tc.id, - })); - } - - const results: ChatMessage[] = []; - - for (const toolCall of toolCalls) { - try { - // Check if this is a lazy server tool - const allTools = mcpSession.getAllTools(); - const tool = allTools.find((t) => t.name === toolCall.name); - - if (!tool) { - // Tool not found, might need to enable lazy server - // Extract server name from tool name (e.g., "playwright_click" -> "playwright") - const serverName = toolCall.name.split('_')[0]; - - try { - await mcpSession.enableLazyServer(serverName); - } catch (error) { - results.push({ - role: 'tool', - content: JSON.stringify({ - error: `Tool ${toolCall.name} not available and lazy server ${serverName} failed to load`, - details: error instanceof Error ? error.message : String(error), - }), - toolCallId: toolCall.id, - }); - continue; - } - } - - // Extract server name and tool name - // MCP tools are namespaced: "mcp____" - const match = toolCall.name.match(/^mcp__([^_]+)__(.+)$/); - if (!match) { - results.push({ - role: 'tool', - content: JSON.stringify({ - error: `Invalid tool name format: ${toolCall.name}`, - }), - toolCallId: toolCall.id, - }); - continue; - } - - const [, serverName, toolName] = match; - - // Invoke tool via MCP - const result = await mcpSession.invokeTool(serverName, toolName, toolCall.arguments); - - results.push({ - role: 'tool', - content: JSON.stringify(result), - toolCallId: toolCall.id, - }); - - // Add UI message for visibility - setMessages((prev) => [ - ...prev, - { - id: `tool-${toolCall.id}`, - role: 'tool', - content: `Executed: ${toolCall.name}`, - timestamp: new Date(), - toolResults: [{ toolName: toolCall.name, result: JSON.stringify(result, null, 2) }], - }, - ]); - } catch (error) { - results.push({ - role: 'tool', - content: JSON.stringify({ - error: 'Tool execution failed', - details: error instanceof Error ? error.message : String(error), - }), - toolCallId: toolCall.id, - }); - } - } - - return results; - }; - - /** - * Tool execution loop - * Continues calling LLM until no more tool calls - */ - const runToolExecutionLoop = async ( - initialMessages: ChatMessage[], - tools: ChatRequest['tools'] - ): Promise => { - let messages = [...initialMessages]; - let finalText = ''; - const maxIterations = 10; // Prevent infinite loops - let iteration = 0; - - while (iteration < maxIterations) { - iteration++; - - // Call LLM - const response = await driver.chat({ - sessionId, - messages, - tools, - policy: { - approvals: ApprovalsLevel.NEVER, - trust: TrustLevel.SANDBOXED, - }, - temperature: 0.7, - }); - - finalText = response.text; - - // No tool calls, we're done - if (!response.toolCalls || response.toolCalls.length === 0) { - return finalText; - } - - // Add assistant message with tool calls - messages.push({ - role: 'assistant', - content: response.text || '', - }); - - // Execute tools - const toolResults = await executeToolCalls(response.toolCalls); - - // Add tool results to conversation - messages.push(...toolResults); - - // Update conversation ref - conversationRef.current = messages; - } - - return finalText + '\n\n(Tool execution loop limit reached)'; - }; - - /** - * Send user message and process response - */ - const sendMessage = useCallback( - async (content: string) => { - if (isProcessing || !driver) return; - - setIsProcessing(true); - setCurrentResponse(''); - - try { - // Add user message - const userMessage: ConversationMessage = { - id: `user-${Date.now()}`, - role: 'user', - content, - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, userMessage]); - - // Build conversation history - const chatMessages: ChatMessage[] = conversationRef.current.length > 0 - ? conversationRef.current - : messages - .filter((m) => m.role !== 'tool') - .map((m) => ({ - role: m.role as 'user' | 'assistant' | 'system', - content: m.content, - })); - - // Add new user message - chatMessages.push({ - role: 'user', - content, - }); - - // Get MCP tools if available - const tools = mcpSession - ? mcpSession.getAllTools().map((t) => ({ - name: `mcp__${t.serverName || 'unknown'}__${t.name}`, - description: t.description, - parameters: t.inputSchema, - })) - : undefined; - - // Run tool execution loop - const responseText = await runToolExecutionLoop(chatMessages, tools); - - // Add assistant response - const assistantMessage: ConversationMessage = { - id: `assistant-${Date.now()}`, - role: 'assistant', - content: responseText, - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, assistantMessage]); - - // Update conversation ref - conversationRef.current = [ - ...chatMessages, - { - role: 'assistant', - content: responseText, - }, - ]; - } catch (error) { - const errorMessage: ConversationMessage = { - id: `error-${Date.now()}`, - role: 'system', - content: `Error: ${error instanceof Error ? error.message : String(error)}`, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMessage]); - } finally { - setIsProcessing(false); - } - }, - [isProcessing, driver, mcpSession, sessionId, messages] - ); - - /** - * Clear conversation history - */ - const clearHistory = useCallback(() => { - setMessages( - systemPrompt - ? [ - { - id: `system-${Date.now()}`, - role: 'system', - content: systemPrompt, - timestamp: new Date(), - }, - ] - : [] - ); - conversationRef.current = []; - setCurrentResponse(''); - }, [systemPrompt]); - - return { - messages, - isProcessing, - currentResponse, - sendMessage, - clearHistory, - }; -} diff --git a/apps/airiscode-cli/src/hooks/useTextBuffer.ts b/apps/airiscode-cli/src/hooks/useTextBuffer.ts deleted file mode 100644 index cec6e3be2..000000000 --- a/apps/airiscode-cli/src/hooks/useTextBuffer.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * useTextBuffer Hook - * - * Creates and manages a TextBuffer instance for text input - */ - -import { useMemo } from 'react'; -import { useTextBuffer as useTextBufferImpl, type TextBuffer } from '../components/text-buffer.js'; - -export function useTextBuffer(initialText: string = '', maxHeight: number = 10): TextBuffer { - return useTextBufferImpl(initialText, maxHeight); -} - -export type { TextBuffer }; diff --git a/apps/airiscode-cli/src/i18n/index.ts b/apps/airiscode-cli/src/i18n/index.ts new file mode 100644 index 000000000..4436b9b52 --- /dev/null +++ b/apps/airiscode-cli/src/i18n/index.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { homedir } from 'node:os'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { + type SupportedLanguage, + SUPPORTED_LANGUAGES, + getLanguageNameFromLocale, +} from './languages.js'; + +export type { SupportedLanguage }; +export { getLanguageNameFromLocale }; + +// State +let currentLanguage: SupportedLanguage = 'en'; +let translations: Record = {}; + +// Cache +type TranslationValue = string | string[]; +type TranslationDict = Record; +const translationCache: Record = {}; +const loadingPromises: Record> = {}; +// Path helpers +const getBuiltinLocalesDir = (): string => { + const __filename = fileURLToPath(import.meta.url); + return path.join(path.dirname(__filename), 'locales'); +}; + +const getUserLocalesDir = (): string => + path.join(homedir(), '.airiscode', 'locales'); + +/** + * Get the path to the user's custom locales directory. + * Users can place custom language packs (e.g., es.js, fr.js) in this directory. + * @returns The path to ~/.airiscode/locales + */ +export function getUserLocalesDirectory(): string { + return getUserLocalesDir(); +} + +const getLocalePath = ( + lang: SupportedLanguage, + useUserDir: boolean = false, +): string => { + const baseDir = useUserDir ? getUserLocalesDir() : getBuiltinLocalesDir(); + return path.join(baseDir, `${lang}.js`); +}; + +// Language detection +export function detectSystemLanguage(): SupportedLanguage { + const envLang = process.env['AIRISCODE_LANG'] || process.env['LANG']; + if (envLang) { + for (const lang of SUPPORTED_LANGUAGES) { + if (envLang.startsWith(lang.code)) return lang.code; + } + } + + try { + const locale = Intl.DateTimeFormat().resolvedOptions().locale; + for (const lang of SUPPORTED_LANGUAGES) { + if (locale.startsWith(lang.code)) return lang.code; + } + } catch { + // Fallback to default + } + + return 'en'; +} + +// Translation loading +async function loadTranslationsAsync( + lang: SupportedLanguage, +): Promise { + if (translationCache[lang]) { + return translationCache[lang]; + } + + const existingPromise = loadingPromises[lang]; + if (existingPromise) { + return existingPromise; + } + + const loadPromise = (async () => { + // Try user directory first (for custom language packs), then builtin directory + const searchDirs = [ + { dir: getUserLocalesDir(), isUser: true }, + { dir: getBuiltinLocalesDir(), isUser: false }, + ]; + + for (const { dir, isUser } of searchDirs) { + // Ensure directory exists + if (!fs.existsSync(dir)) { + continue; + } + + const jsPath = getLocalePath(lang, isUser); + if (!fs.existsSync(jsPath)) { + continue; + } + + try { + // Convert file path to file:// URL for cross-platform compatibility + const fileUrl = pathToFileURL(jsPath).href; + try { + const module = await import(fileUrl); + const result = module.default || module; + if ( + result && + typeof result === 'object' && + Object.keys(result).length > 0 + ) { + translationCache[lang] = result; + return result; + } else { + throw new Error('Module loaded but result is empty or invalid'); + } + } catch { + // For builtin locales, try alternative import method (relative path) + if (!isUser) { + try { + const module = await import(`./locales/${lang}.js`); + const result = module.default || module; + if ( + result && + typeof result === 'object' && + Object.keys(result).length > 0 + ) { + translationCache[lang] = result; + return result; + } + } catch { + // Continue to next directory + } + } + // If import failed, continue to next directory + continue; + } + } catch (error) { + // Log warning but continue to next directory + if (isUser) { + writeStderrLine( + `Failed to load translations from user directory for ${lang}: ${error instanceof Error ? error.message : String(error)}`, + ); + } else { + writeStderrLine( + `Failed to load JS translations for ${lang}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + // Continue to next directory + continue; + } + } + + // Return empty object if both directories fail + // Cache it to avoid repeated failed attempts + translationCache[lang] = {}; + return {}; + })(); + + loadingPromises[lang] = loadPromise; + + // Clean up promise after completion to allow retry on next call if needed + loadPromise.finally(() => { + delete loadingPromises[lang]; + }); + + return loadPromise; +} + +function loadTranslations(lang: SupportedLanguage): TranslationDict { + // Only return from cache (JS files require async loading) + return translationCache[lang] || {}; +} + +// String interpolation +function interpolate( + template: string, + params?: Record, +): string { + if (!params) return template; + return template.replace( + /\{\{(\w+)\}\}/g, + (match, key) => params[key] ?? match, + ); +} + +// Language setting helpers +function resolveLanguage(lang: SupportedLanguage | 'auto'): SupportedLanguage { + return lang === 'auto' ? detectSystemLanguage() : lang; +} + +// Public API +export function setLanguage(lang: SupportedLanguage | 'auto'): void { + const resolvedLang = resolveLanguage(lang); + currentLanguage = resolvedLang; + + // Try to load translations synchronously (from cache only) + const loaded = loadTranslations(resolvedLang); + translations = loaded; + + // Warn if translations are empty and JS file exists (requires async loading) + if (Object.keys(loaded).length === 0) { + const userJsPath = getLocalePath(resolvedLang, true); + const builtinJsPath = getLocalePath(resolvedLang, false); + if (fs.existsSync(userJsPath) || fs.existsSync(builtinJsPath)) { + writeStderrLine( + `Language file for ${resolvedLang} requires async loading. ` + + `Use setLanguageAsync() instead, or call initializeI18n() first.`, + ); + } + } +} + +export async function setLanguageAsync( + lang: SupportedLanguage | 'auto', +): Promise { + currentLanguage = resolveLanguage(lang); + translations = await loadTranslationsAsync(currentLanguage); +} + +export function getCurrentLanguage(): SupportedLanguage { + return currentLanguage; +} + +export function t(key: string, params?: Record): string { + const translation = translations[key] ?? key; + if (Array.isArray(translation)) { + return key; + } + return interpolate(translation, params); +} + +/** + * Get a translation that is an array of strings. + * @param key The translation key + * @returns The array of strings, or an empty array if not found or not an array + */ +export function ta(key: string): string[] { + const translation = translations[key]; + if (Array.isArray(translation)) { + return translation; + } + return []; +} + +export async function initializeI18n( + lang?: SupportedLanguage | 'auto', +): Promise { + await setLanguageAsync(lang ?? 'auto'); +} diff --git a/apps/airiscode-cli/src/i18n/languages.ts b/apps/airiscode-cli/src/i18n/languages.ts new file mode 100644 index 000000000..733f11863 --- /dev/null +++ b/apps/airiscode-cli/src/i18n/languages.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type SupportedLanguage = + | 'en' + | 'zh' + | 'ru' + | 'de' + | 'ja' + | 'pt' + | string; + +export interface LanguageDefinition { + /** The internal locale code used by the i18n system (e.g., 'en', 'zh'). */ + code: SupportedLanguage; + /** The standard name used in UI settings (e.g., 'en-US', 'zh-CN'). */ + id: string; + /** The full English name of the language (e.g., 'English', 'Chinese'). */ + fullName: string; + /** The native name of the language (e.g., 'English', '中文'). */ + nativeName?: string; +} + +export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [ + { + code: 'en', + id: 'en-US', + fullName: 'English', + nativeName: 'English', + }, + { + code: 'zh', + id: 'zh-CN', + fullName: 'Chinese', + nativeName: '中文', + }, + { + code: 'ru', + id: 'ru-RU', + fullName: 'Russian', + nativeName: 'Русский', + }, + { + code: 'de', + id: 'de-DE', + fullName: 'German', + nativeName: 'Deutsch', + }, + { + code: 'ja', + id: 'ja-JP', + fullName: 'Japanese', + nativeName: '日本語', + }, + { + code: 'pt', + id: 'pt-BR', + fullName: 'Portuguese', + nativeName: 'Português', + }, +]; + +/** + * Maps a locale code to its English language name. + * Used for LLM output language instructions. + */ +export function getLanguageNameFromLocale(locale: SupportedLanguage): string { + const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale); + return lang?.fullName || 'English'; +} + +/** + * Gets the language options for the settings schema. + */ +export function getLanguageSettingsOptions(): Array<{ + value: string; + label: string; +}> { + return [ + { value: 'auto', label: 'Auto (detect from system)' }, + ...SUPPORTED_LANGUAGES.map((l) => ({ + value: l.code, + label: l.nativeName + ? `${l.nativeName} (${l.fullName})` + : `${l.fullName} (${l.id})`, + })), + ]; +} + +/** + * Gets a string containing all supported language IDs (e.g., "en-US|zh-CN"). + */ +export function getSupportedLanguageIds(separator = '|'): string { + return SUPPORTED_LANGUAGES.map((l) => l.id).join(separator); +} diff --git a/apps/airiscode-cli/src/i18n/locales/de.js b/apps/airiscode-cli/src/i18n/locales/de.js new file mode 100644 index 000000000..6d45ba4ea --- /dev/null +++ b/apps/airiscode-cli/src/i18n/locales/de.js @@ -0,0 +1,1957 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// German translations for Qwen Code CLI +// Deutsche Übersetzungen für Qwen Code CLI + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ Anhänge verwalten', + '← → select, Delete to remove, ↓ to exit': + '← → auswählen, Entf zum Löschen, ↓ beenden', + 'Attachments: ': 'Anhänge: ', + + 'Basics:': 'Grundlagen:', + 'Add context': 'Kontext hinzufügen', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Verwenden Sie {{symbol}}, um Dateien als Kontext anzugeben (z.B. {{example}}), um bestimmte Dateien oder Ordner auszuwählen.', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Shell-Modus', + 'YOLO mode': 'YOLO-Modus', + 'plan mode': 'Planungsmodus', + 'auto-accept edits': 'Änderungen automatisch akzeptieren', + 'Accepting edits': 'Änderungen werden akzeptiert', + '(shift + tab to cycle)': '(Umschalt + Tab zum Wechseln)', + '(tab to cycle)': '(Tab zum Wechseln)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Shell-Befehle über {{symbol}} ausführen (z.B. {{example1}}) oder natürliche Sprache verwenden (z.B. {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'Server starten', + 'Commands:': 'Befehle:', + 'shell command': 'Shell-Befehl', + 'Model Context Protocol command (from external servers)': + 'Model Context Protocol Befehl (von externen Servern)', + 'Keyboard Shortcuts:': 'Tastenkürzel:', + 'Jump through words in the input': 'Wörter in der Eingabe überspringen', + 'Close dialogs, cancel requests, or quit application': + 'Dialoge schließen, Anfragen abbrechen oder Anwendung beenden', + 'New line': 'Neue Zeile', + 'New line (Alt+Enter works for certain linux distros)': + 'Neue Zeile (Alt+Enter funktioniert bei bestimmten Linux-Distributionen)', + 'Clear the screen': 'Bildschirm löschen', + 'Open input in external editor': 'Eingabe in externem Editor öffnen', + 'Send message': 'Nachricht senden', + 'Initializing...': 'Initialisierung...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Verbindung zu MCP-Servern wird hergestellt... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': + 'Nachricht eingeben oder @Pfad/zur/Datei', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Drücken Sie 'i' für den EINFÜGE-Modus und 'Esc' für den NORMAL-Modus.", + 'Cancel operation / Clear input (double press)': + 'Vorgang abbrechen / Eingabe löschen (doppelt drücken)', + 'Cycle approval modes': 'Genehmigungsmodi durchschalten', + 'Cycle through your prompt history': 'Eingabeverlauf durchblättern', + 'For a full list of shortcuts, see {{docPath}}': + 'Eine vollständige Liste der Tastenkürzel finden Sie unter {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'für Hilfe zu Qwen Code', + 'show version info': 'Versionsinformationen anzeigen', + 'submit a bug report': 'Fehlerbericht einreichen', + 'About Qwen Code': 'Über Qwen Code', + Status: 'Status', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'Qwen Code': 'Qwen Code', + Runtime: 'Laufzeit', + OS: 'Betriebssystem', + Auth: 'Authentifizierung', + 'CLI Version': 'CLI-Version', + 'Git Commit': 'Git-Commit', + Model: 'Modell', + 'Fast Model': 'Schnelles Modell', + Sandbox: 'Sandbox', + 'OS Platform': 'Betriebssystem', + 'OS Arch': 'OS-Architektur', + 'OS Release': 'OS-Version', + 'Node.js Version': 'Node.js-Version', + 'NPM Version': 'NPM-Version', + 'Session ID': 'Sitzungs-ID', + 'Auth Method': 'Authentifizierungsmethode', + 'Base URL': 'Basis-URL', + Proxy: 'Proxy', + 'Memory Usage': 'Speichernutzung', + 'IDE Client': 'IDE-Client', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]', + 'List available skills.': 'Verfügbare Skills auflisten.', + 'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:', + 'No tools available': 'Keine Werkzeuge verfügbar', + 'View or change the approval mode for tool usage': + 'Genehmigungsmodus für Werkzeugnutzung anzeigen oder ändern', + 'View or change the language setting': + 'Spracheinstellung anzeigen oder ändern', + 'change the theme': 'Design ändern', + 'Select Theme': 'Design auswählen', + Preview: 'Vorschau', + '(Use Enter to select, Tab to configure scope)': + '(Enter zum Auswählen, Tab zum Konfigurieren des Bereichs)', + '(Use Enter to apply scope, Tab to go back)': + '(Enter zum Anwenden des Bereichs, Tab zum Zurückgehen)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Design-Konfiguration aufgrund der NO_COLOR-Umgebungsvariable nicht verfügbar.', + 'Theme "{{themeName}}" not found.': 'Design "{{themeName}}" nicht gefunden.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Design "{{themeName}}" im ausgewählten Bereich nicht gefunden.', + 'Clear conversation history and free up context': + 'Gesprächsverlauf löschen und Kontext freigeben', + 'Compresses the context by replacing it with a summary.': + 'Komprimiert den Kontext durch Ersetzen mit einer Zusammenfassung.', + 'open full Qwen Code documentation in your browser': + 'Vollständige Qwen Code Dokumentation im Browser öffnen', + 'Configuration not available.': 'Konfiguration nicht verfügbar.', + 'change the auth method': 'Authentifizierungsmethode ändern', + 'Configure authentication information for login': + 'Authentifizierungsinformationen für die Anmeldung konfigurieren', + 'Copy the last result or code snippet to clipboard': + 'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Unteragenten für spezialisierte Aufgabendelegation verwalten.', + 'Manage existing subagents (view, edit, delete).': + 'Bestehende Unteragenten verwalten (anzeigen, bearbeiten, löschen).', + 'Create a new subagent with guided setup.': + 'Neuen Unteragenten mit geführter Einrichtung erstellen.', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: 'Agenten', + 'Choose Action': 'Aktion wählen', + 'Edit {{name}}': '{{name}} bearbeiten', + 'Edit Tools: {{name}}': 'Werkzeuge bearbeiten: {{name}}', + 'Edit Color: {{name}}': 'Farbe bearbeiten: {{name}}', + 'Delete {{name}}': '{{name}} löschen', + 'Unknown Step': 'Unbekannter Schritt', + 'Esc to close': 'Esc zum Schließen', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Schließen', + 'Esc to go back': 'Esc zum Zurückgehen', + 'Enter to confirm, Esc to cancel': 'Enter zum Bestätigen, Esc zum Abbrechen', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Zurückgehen', + 'Enter to submit, Esc to go back': 'Enter zum Absenden, Esc zum Zurückgehen', + 'Invalid step: {{step}}': 'Ungültiger Schritt: {{step}}', + 'No subagents found.': 'Keine Unteragenten gefunden.', + "Use '/agents create' to create your first subagent.": + "Verwenden Sie '/agents create', um Ihren ersten Unteragenten zu erstellen.", + '(built-in)': '(integriert)', + '(overridden by project level agent)': '(überschrieben durch Projektagent)', + 'Project Level ({{path}})': 'Projektebene ({{path}})', + 'User Level ({{path}})': 'Benutzerebene ({{path}})', + 'Built-in Agents': 'Integrierte Agenten', + 'Extension Agents': 'Erweiterungs-Agenten', + 'Using: {{count}} agents': 'Verwendet: {{count}} Agenten', + 'View Agent': 'Agent anzeigen', + 'Edit Agent': 'Agent bearbeiten', + 'Delete Agent': 'Agent löschen', + Back: 'Zurück', + 'No agent selected': 'Kein Agent ausgewählt', + 'File Path: ': 'Dateipfad: ', + 'Tools: ': 'Werkzeuge: ', + 'Color: ': 'Farbe: ', + 'Description:': 'Beschreibung:', + 'System Prompt:': 'System-Prompt:', + 'Open in editor': 'Im Editor öffnen', + 'Edit tools': 'Werkzeuge bearbeiten', + 'Edit color': 'Farbe bearbeiten', + '❌ Error:': '❌ Fehler:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Sind Sie sicher, dass Sie den Agenten "{{name}}" löschen möchten?', + // ============================================================================ + // Agents - Creation Wizard + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Projektebene (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'Benutzerebene (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Unteragent erfolgreich erstellt!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Unteragent "{{name}}" wurde auf {{level}}-Ebene gespeichert.', + 'Name: ': 'Name: ', + 'Location: ': 'Speicherort: ', + '❌ Error saving subagent:': '❌ Fehler beim Speichern des Unteragenten:', + 'Warnings:': 'Warnungen:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Name "{{name}}" existiert bereits auf {{level}}-Ebene - bestehender Unteragent wird überschrieben', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Name "{{name}}" existiert auf Benutzerebene - Projektebene hat Vorrang', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Name "{{name}}" existiert auf Projektebene - bestehender Unteragent hat Vorrang', + 'Description is over {{length}} characters': + 'Beschreibung ist über {{length}} Zeichen', + 'System prompt is over {{length}} characters': + 'System-Prompt ist über {{length}} Zeichen', + // Agents - Creation Wizard Steps + 'Step {{n}}: Choose Location': 'Schritt {{n}}: Speicherort wählen', + 'Step {{n}}: Choose Generation Method': + 'Schritt {{n}}: Generierungsmethode wählen', + 'Generate with Qwen Code (Recommended)': + 'Mit Qwen Code generieren (Empfohlen)', + 'Manual Creation': 'Manuelle Erstellung', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Beschreiben Sie, was dieser Unteragent tun soll und wann er verwendet werden soll. (Ausführliche Beschreibung für beste Ergebnisse)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'z.B. Experte für Code-Reviews, der Code nach Best Practices überprüft...', + 'Generating subagent configuration...': + 'Unteragent-Konfiguration wird generiert...', + 'Failed to generate subagent: {{error}}': + 'Fehler beim Generieren des Unteragenten: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Schritt {{n}}: Unteragent beschreiben', + 'Step {{n}}: Enter Subagent Name': 'Schritt {{n}}: Unteragent-Name eingeben', + 'Step {{n}}: Enter System Prompt': 'Schritt {{n}}: System-Prompt eingeben', + 'Step {{n}}: Enter Description': 'Schritt {{n}}: Beschreibung eingeben', + // Agents - Tool Selection + 'Step {{n}}: Select Tools': 'Schritt {{n}}: Werkzeuge auswählen', + 'All Tools (Default)': 'Alle Werkzeuge (Standard)', + 'All Tools': 'Alle Werkzeuge', + 'Read-only Tools': 'Nur-Lese-Werkzeuge', + 'Read & Edit Tools': 'Lese- und Bearbeitungswerkzeuge', + 'Read & Edit & Execution Tools': + 'Lese-, Bearbeitungs- und Ausführungswerkzeuge', + 'All tools selected, including MCP tools': + 'Alle Werkzeuge ausgewählt, einschließlich MCP-Werkzeuge', + 'Selected tools:': 'Ausgewählte Werkzeuge:', + 'Read-only tools:': 'Nur-Lese-Werkzeuge:', + 'Edit tools:': 'Bearbeitungswerkzeuge:', + 'Execution tools:': 'Ausführungswerkzeuge:', + 'Step {{n}}: Choose Background Color': + 'Schritt {{n}}: Hintergrundfarbe wählen', + 'Step {{n}}: Confirm and Save': 'Schritt {{n}}: Bestätigen und Speichern', + // Agents - Navigation & Instructions + 'Esc to cancel': 'Esc zum Abbrechen', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter zum Speichern, e zum Speichern und Bearbeiten, Esc zum Zurückgehen', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter zum Fortfahren, {{navigation}}Esc zum {{action}}', + cancel: 'Abbrechen', + 'go back': 'Zurückgehen', + '↑↓ to navigate, ': '↑↓ zum Navigieren, ', + 'Enter a clear, unique name for this subagent.': + 'Geben Sie einen eindeutigen Namen für diesen Unteragenten ein.', + 'e.g., Code Reviewer': 'z.B. Code-Reviewer', + 'Name cannot be empty.': 'Name darf nicht leer sein.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'Schreiben Sie den System-Prompt, der das Verhalten dieses Unteragenten definiert. Ausführlich für beste Ergebnisse.', + 'e.g., You are an expert code reviewer...': + 'z.B. Sie sind ein Experte für Code-Reviews...', + 'System prompt cannot be empty.': 'System-Prompt darf nicht leer sein.', + 'Describe when and how this subagent should be used.': + 'Beschreiben Sie, wann und wie dieser Unteragent verwendet werden soll.', + 'e.g., Reviews code for best practices and potential bugs.': + 'z.B. Überprüft Code auf Best Practices und mögliche Fehler.', + 'Description cannot be empty.': 'Beschreibung darf nicht leer sein.', + 'Failed to launch editor: {{error}}': + 'Fehler beim Starten des Editors: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Fehler beim Speichern und Bearbeiten des Unteragenten: {{error}}', + + // ============================================================================ + // Commands - General (continued) + // ============================================================================ + 'View and edit Qwen Code settings': + 'Qwen Code Einstellungen anzeigen und bearbeiten', + Settings: 'Einstellungen', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'Um Änderungen zu sehen, muss Qwen Code neu gestartet werden. Drücken Sie r, um jetzt zu beenden und Änderungen anzuwenden.', + 'The command "/{{command}}" is not supported in non-interactive mode.': + 'Der Befehl "/{{command}}" wird im nicht-interaktiven Modus nicht unterstützt.', + // ============================================================================ + // Settings Labels + // ============================================================================ + 'Vim Mode': 'Vim-Modus', + 'Disable Auto Update': 'Automatische Updates deaktivieren', + 'Attribution: commit': 'Attribution: Commit', + 'Terminal Bell Notification': 'Terminal-Signalton', + 'Enable Usage Statistics': 'Nutzungsstatistiken aktivieren', + Theme: 'Farbschema', + 'Preferred Editor': 'Bevorzugter Editor', + 'Auto-connect to IDE': 'Automatische Verbindung zur IDE', + 'Enable Prompt Completion': 'Eingabevervollständigung aktivieren', + 'Debug Keystroke Logging': 'Debug-Protokollierung von Tastatureingaben', + 'Language: UI': 'Sprache: Benutzeroberfläche', + 'Language: Model': 'Sprache: Modell', + 'Output Format': 'Ausgabeformat', + 'Hide Window Title': 'Fenstertitel ausblenden', + 'Show Status in Title': 'Status im Titel anzeigen', + 'Hide Tips': 'Tipps ausblenden', + 'Show Line Numbers in Code': 'Zeilennummern im Code anzeigen', + 'Show Citations': 'Quellenangaben anzeigen', + 'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche', + 'Show Welcome Back Dialog': 'Willkommen-zurück-Dialog anzeigen', + 'Enable User Feedback': 'Benutzerfeedback aktivieren', + 'How is Qwen doing this session? (optional)': + 'Wie macht sich Qwen in dieser Sitzung? (optional)', + Bad: 'Schlecht', + Fine: 'In Ordnung', + Good: 'Gut', + Dismiss: 'Ignorieren', + 'Not Sure Yet': 'Noch nicht sicher', + 'Any other key': 'Beliebige andere Taste', + 'Disable Loading Phrases': 'Ladesprüche deaktivieren', + 'Screen Reader Mode': 'Bildschirmleser-Modus', + 'IDE Mode': 'IDE-Modus', + 'Max Session Turns': 'Maximale Sitzungsrunden', + 'Skip Next Speaker Check': 'Nächste-Sprecher-Prüfung überspringen', + 'Skip Loop Detection': 'Schleifenerkennung überspringen', + 'Skip Startup Context': 'Startkontext überspringen', + 'Enable OpenAI Logging': 'OpenAI-Protokollierung aktivieren', + 'OpenAI Logging Directory': 'OpenAI-Protokollierungsverzeichnis', + Timeout: 'Zeitlimit', + 'Max Retries': 'Maximale Wiederholungen', + 'Disable Cache Control': 'Cache-Steuerung deaktivieren', + 'Memory Discovery Max Dirs': 'Maximale Verzeichnisse für Speichererkennung', + 'Load Memory From Include Directories': + 'Speicher aus Include-Verzeichnissen laden', + 'Respect .gitignore': '.gitignore beachten', + 'Respect .qwenignore': '.qwenignore beachten', + 'Enable Recursive File Search': 'Rekursive Dateisuche aktivieren', + 'Disable Fuzzy Search': 'Unscharfe Suche deaktivieren', + 'Interactive Shell (PTY)': 'Interaktive Shell (PTY)', + 'Show Color': 'Farbe anzeigen', + 'Auto Accept': 'Automatisch akzeptieren', + 'Use Ripgrep': 'Ripgrep verwenden', + 'Use Builtin Ripgrep': 'Integriertes Ripgrep verwenden', + 'Enable Tool Output Truncation': 'Werkzeugausgabe-Kürzung aktivieren', + 'Tool Output Truncation Threshold': + 'Schwellenwert für Werkzeugausgabe-Kürzung', + 'Tool Output Truncation Lines': 'Zeilen für Werkzeugausgabe-Kürzung', + 'Folder Trust': 'Ordnervertrauen', + 'Vision Model Preview': 'Vision-Modell-Vorschau', + 'Tool Schema Compliance': 'Werkzeug-Schema-Konformität', + // Settings enum options + 'Auto (detect from system)': 'Automatisch (vom System erkennen)', + Text: 'Text', + JSON: 'JSON', + Plan: 'Plan', + Default: 'Standard', + 'Auto Edit': 'Automatisch bearbeiten', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'Vim-Modus ein-/ausschalten', + 'check session stats. Usage: /stats [model|tools]': + 'Sitzungsstatistiken prüfen. Verwendung: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Modellspezifische Nutzungsstatistiken anzeigen.', + 'Show tool-specific usage statistics.': + 'Werkzeugspezifische Nutzungsstatistiken anzeigen.', + 'exit the cli': 'CLI beenden', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'MCP-Verwaltungsdialog öffnen oder mit OAuth-fähigem Server authentifizieren', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fähigen Servern authentifizieren', + 'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Verzeichnisse zum Arbeitsbereich hinzufügen. Komma zum Trennen mehrerer Pfade verwenden', + 'Show all directories in the workspace': + 'Alle Verzeichnisse im Arbeitsbereich anzeigen', + 'set external editor preference': 'Externen Editor festlegen', + 'Select Editor': 'Editor auswählen', + 'Editor Preference': 'Editor-Einstellung', + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.': + 'Diese Editoren werden derzeit unterstützt. Bitte beachten Sie, dass einige Editoren nicht im Sandbox-Modus verwendet werden können.', + 'Your preferred editor is:': 'Ihr bevorzugter Editor ist:', + 'Manage extensions': 'Erweiterungen verwalten', + 'Manage installed extensions': 'Installierte Erweiterungen verwalten', + 'List active extensions': 'Aktive Erweiterungen auflisten', + 'Update extensions. Usage: update |--all': + 'Erweiterungen aktualisieren. Verwendung: update |--all', + 'Disable an extension': 'Erweiterung deaktivieren', + 'Enable an extension': 'Erweiterung aktivieren', + 'Install an extension from a git repo or local path': + 'Erweiterung aus Git-Repository oder lokalem Pfad installieren', + 'Uninstall an extension': 'Erweiterung deinstallieren', + 'No extensions installed.': 'Keine Erweiterungen installiert.', + 'Usage: /extensions update |--all': + 'Verwendung: /extensions update |--all', + 'Extension "{{name}}" not found.': 'Erweiterung "{{name}}" nicht gefunden.', + 'No extensions to update.': 'Keine Erweiterungen zum Aktualisieren.', + 'Usage: /extensions install ': + 'Verwendung: /extensions install ', + 'Installing extension from "{{source}}"...': + 'Installiere Erweiterung von "{{source}}"...', + 'Extension "{{name}}" installed successfully.': + 'Erweiterung "{{name}}" erfolgreich installiert.', + 'Failed to install extension from "{{source}}": {{error}}': + 'Fehler beim Installieren der Erweiterung von "{{source}}": {{error}}', + 'Usage: /extensions uninstall ': + 'Verwendung: /extensions uninstall ', + 'Uninstalling extension "{{name}}"...': + 'Deinstalliere Erweiterung "{{name}}"...', + 'Extension "{{name}}" uninstalled successfully.': + 'Erweiterung "{{name}}" erfolgreich deinstalliert.', + 'Failed to uninstall extension "{{name}}": {{error}}': + 'Fehler beim Deinstallieren der Erweiterung "{{name}}": {{error}}', + 'Usage: /extensions {{command}} [--scope=]': + 'Verwendung: /extensions {{command}} [--scope=]', + 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"': + 'Nicht unterstützter Bereich "{{scope}}", sollte "user" oder "workspace" sein', + 'Extension "{{name}}" disabled for scope "{{scope}}"': + 'Erweiterung "{{name}}" für Bereich "{{scope}}" deaktiviert', + 'Extension "{{name}}" enabled for scope "{{scope}}"': + 'Erweiterung "{{name}}" für Bereich "{{scope}}" aktiviert', + 'Do you want to continue? [Y/n]: ': 'Möchten Sie fortfahren? [Y/n]: ', + 'Do you want to continue?': 'Möchten Sie fortfahren?', + 'Installing extension "{{name}}".': + 'Erweiterung "{{name}}" wird installiert.', + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**': + '**Erweiterungen können unerwartetes Verhalten verursachen. Stellen Sie sicher, dass Sie die Erweiterungsquelle untersucht haben und dem Autor vertrauen.**', + 'This extension will run the following MCP servers:': + 'Diese Erweiterung wird folgende MCP-Server ausführen:', + local: 'lokal', + remote: 'remote', + 'This extension will add the following commands: {{commands}}.': + 'Diese Erweiterung wird folgende Befehle hinzufügen: {{commands}}.', + 'This extension will append info to your QWEN.md context using {{fileName}}': + 'Diese Erweiterung wird Informationen zu Ihrem QWEN.md-Kontext mit {{fileName}} hinzufügen', + 'This extension will exclude the following core tools: {{tools}}': + 'Diese Erweiterung wird folgende Kernwerkzeuge ausschließen: {{tools}}', + 'This extension will install the following skills:': + 'Diese Erweiterung wird folgende Fähigkeiten installieren:', + 'This extension will install the following subagents:': + 'Diese Erweiterung wird folgende Unteragenten installieren:', + 'Installation cancelled for "{{name}}".': + 'Installation von "{{name}}" abgebrochen.', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Sie installieren eine Erweiterung von {{originSource}}. Einige Funktionen funktionieren möglicherweise nicht perfekt mit Qwen Code.', + '--ref and --auto-update are not applicable for marketplace extensions.': + '--ref und --auto-update sind nicht anwendbar für Marketplace-Erweiterungen.', + 'Extension "{{name}}" installed successfully and enabled.': + 'Erweiterung "{{name}}" erfolgreich installiert und aktiviert.', + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).': + 'Installiert eine Erweiterung von einer Git-Repository-URL, einem lokalen Pfad oder dem Claude-Marketplace (marketplace-url:plugin-name).', + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': + 'Die GitHub-URL, der lokale Pfad oder die Marketplace-Quelle (marketplace-url:plugin-name) der zu installierenden Erweiterung.', + 'The git ref to install from.': 'Die Git-Referenz für die Installation.', + 'Enable auto-update for this extension.': + 'Automatisches Update für diese Erweiterung aktivieren.', + 'Enable pre-release versions for this extension.': + 'Pre-Release-Versionen für diese Erweiterung aktivieren.', + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': + 'Sicherheitsrisiken der Erweiterungsinstallation bestätigen und Bestätigungsaufforderung überspringen.', + 'The source argument must be provided.': + 'Das Quellargument muss angegeben werden.', + 'Extension "{{name}}" successfully uninstalled.': + 'Erweiterung "{{name}}" erfolgreich deinstalliert.', + 'Uninstalls an extension.': 'Deinstalliert eine Erweiterung.', + 'The name or source path of the extension to uninstall.': + 'Der Name oder Quellpfad der zu deinstallierenden Erweiterung.', + 'Please include the name of the extension to uninstall as a positional argument.': + 'Bitte geben Sie den Namen der zu deinstallierenden Erweiterung als Positionsargument an.', + 'Enables an extension.': 'Aktiviert eine Erweiterung.', + 'The name of the extension to enable.': + 'Der Name der zu aktivierenden Erweiterung.', + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.': + 'Der Bereich, in dem die Erweiterung aktiviert werden soll. Wenn nicht gesetzt, wird sie in allen Bereichen aktiviert.', + 'Extension "{{name}}" successfully enabled for scope "{{scope}}".': + 'Erweiterung "{{name}}" erfolgreich für Bereich "{{scope}}" aktiviert.', + 'Extension "{{name}}" successfully enabled in all scopes.': + 'Erweiterung "{{name}}" erfolgreich in allen Bereichen aktiviert.', + 'Invalid scope: {{scope}}. Please use one of {{scopes}}.': + 'Ungültiger Bereich: {{scope}}. Bitte verwenden Sie einen von {{scopes}}.', + 'Disables an extension.': 'Deaktiviert eine Erweiterung.', + 'The name of the extension to disable.': + 'Der Name der zu deaktivierenden Erweiterung.', + 'The scope to disable the extenison in.': + 'Der Bereich, in dem die Erweiterung deaktiviert werden soll.', + 'Extension "{{name}}" successfully disabled for scope "{{scope}}".': + 'Erweiterung "{{name}}" erfolgreich für Bereich "{{scope}}" deaktiviert.', + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.': + 'Erweiterung "{{name}}" erfolgreich aktualisiert: {{oldVersion}} → {{newVersion}}.', + 'Unable to install extension "{{name}}" due to missing install metadata': + 'Erweiterung "{{name}}" kann aufgrund fehlender Installationsmetadaten nicht installiert werden', + 'Extension "{{name}}" is already up to date.': + 'Erweiterung "{{name}}" ist bereits aktuell.', + 'Updates all extensions or a named extension to the latest version.': + 'Aktualisiert alle Erweiterungen oder eine benannte Erweiterung auf die neueste Version.', + 'The name of the extension to update.': + 'Der Name der zu aktualisierenden Erweiterung.', + 'Update all extensions.': 'Alle Erweiterungen aktualisieren.', + 'Either an extension name or --all must be provided': + 'Entweder ein Erweiterungsname oder --all muss angegeben werden', + 'Lists installed extensions.': 'Listet installierte Erweiterungen auf.', + 'Path:': 'Pfad:', + 'Source:': 'Quelle:', + 'Type:': 'Typ:', + 'Ref:': 'Ref:', + 'Release tag:': 'Release-Tag:', + 'Enabled (User):': 'Aktiviert (Benutzer):', + 'Enabled (Workspace):': 'Aktiviert (Arbeitsbereich):', + 'Context files:': 'Kontextdateien:', + 'Skills:': 'Skills:', + 'Agents:': 'Agents:', + 'MCP servers:': 'MCP-Server:', + 'Link extension failed to install.': + 'Verknüpfte Erweiterung konnte nicht installiert werden.', + 'Extension "{{name}}" linked successfully and enabled.': + 'Erweiterung "{{name}}" erfolgreich verknüpft und aktiviert.', + 'Links an extension from a local path. Updates made to the local path will always be reflected.': + 'Verknüpft eine Erweiterung von einem lokalen Pfad. Änderungen am lokalen Pfad werden immer widergespiegelt.', + 'The name of the extension to link.': + 'Der Name der zu verknüpfenden Erweiterung.', + 'Set a specific setting for an extension.': + 'Legt eine bestimmte Einstellung für eine Erweiterung fest.', + 'Name of the extension to configure.': + 'Name der zu konfigurierenden Erweiterung.', + 'The setting to configure (name or env var).': + 'Die zu konfigurierende Einstellung (Name oder Umgebungsvariable).', + 'The scope to set the setting in.': + 'Der Bereich, in dem die Einstellung gesetzt werden soll.', + 'List all settings for an extension.': + 'Listet alle Einstellungen einer Erweiterung auf.', + 'Name of the extension.': 'Name der Erweiterung.', + 'Extension "{{name}}" has no settings to configure.': + 'Erweiterung "{{name}}" hat keine zu konfigurierenden Einstellungen.', + 'Settings for "{{name}}":': 'Einstellungen für "{{name}}":', + '(workspace)': '(Arbeitsbereich)', + '(user)': '(Benutzer)', + '[not set]': '[nicht gesetzt]', + '[value stored in keychain]': '[Wert in Schlüsselbund gespeichert]', + 'Manage extension settings.': 'Erweiterungseinstellungen verwalten.', + 'You need to specify a command (set or list).': + 'Sie müssen einen Befehl angeben (set oder list).', + // ============================================================================ + // Plugin Choice / Marketplace + // ============================================================================ + 'No plugins available in this marketplace.': + 'In diesem Marktplatz sind keine Plugins verfügbar.', + 'Select a plugin to install from marketplace "{{name}}":': + 'Wählen Sie ein Plugin zur Installation aus Marktplatz "{{name}}":', + 'Plugin selection cancelled.': 'Plugin-Auswahl abgebrochen.', + 'Select a plugin from "{{name}}"': 'Plugin aus "{{name}}" auswählen', + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': + 'Verwenden Sie ↑↓ oder j/k zum Navigieren, Enter zum Auswählen, Escape zum Abbrechen', + '{{count}} more above': '{{count}} weitere oben', + '{{count}} more below': '{{count}} weitere unten', + 'manage IDE integration': 'IDE-Integration verwalten', + 'check status of IDE integration': 'Status der IDE-Integration prüfen', + 'install required IDE companion for {{ideName}}': + 'Erforderlichen IDE-Begleiter für {{ideName}} installieren', + 'enable IDE integration': 'IDE-Integration aktivieren', + 'disable IDE integration': 'IDE-Integration deaktivieren', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'IDE-Integration wird in Ihrer aktuellen Umgebung nicht unterstützt. Um diese Funktion zu nutzen, führen Sie Qwen Code in einer dieser unterstützten IDEs aus: VS Code oder VS Code-Forks.', + 'Set up GitHub Actions': 'GitHub Actions einrichten', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Terminal-Tastenbelegungen für mehrzeilige Eingabe konfigurieren (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Bitte starten Sie Ihr Terminal neu, damit die Änderungen wirksam werden.', + 'Failed to configure terminal: {{error}}': + 'Fehler beim Konfigurieren des Terminals: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Konnte {{terminalName}}-Konfigurationspfad unter Windows nicht ermitteln: APPDATA-Umgebungsvariable ist nicht gesetzt.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json existiert, ist aber kein gültiges JSON-Array. Bitte korrigieren Sie die Datei manuell oder löschen Sie sie, um automatische Konfiguration zu ermöglichen.', + 'File: {{file}}': 'Datei: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Fehler beim Parsen von {{terminalName}} keybindings.json. Die Datei enthält ungültiges JSON. Bitte korrigieren Sie die Datei manuell oder löschen Sie sie, um automatische Konfiguration zu ermöglichen.', + 'Error: {{error}}': 'Fehler: {{error}}', + 'Shift+Enter binding already exists': + 'Umschalt+Enter-Belegung existiert bereits', + 'Ctrl+Enter binding already exists': 'Strg+Enter-Belegung existiert bereits', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Bestehende Tastenbelegungen erkannt. Keine Änderungen, um Konflikte zu vermeiden.', + 'Please check and modify manually if needed: {{file}}': + 'Bitte prüfen und bei Bedarf manuell ändern: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Umschalt+Enter und Strg+Enter Tastenbelegungen zu {{terminalName}} hinzugefügt.', + 'Modified: {{file}}': 'Geändert: {{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}}-Tastenbelegungen bereits konfiguriert.', + 'Failed to configure {{terminalName}}.': + 'Fehler beim Konfigurieren von {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Ihr Terminal ist bereits für optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Qwen Code-Hooks verwalten', + 'List all configured hooks': 'Alle konfigurierten Hooks auflisten', + 'Enable a disabled hook': 'Einen deaktivierten Hook aktivieren', + 'Disable an active hook': 'Einen aktiven Hook deaktivieren', + // Hooks - Dialog + Hooks: 'Hooks', + 'Loading hooks...': 'Hooks werden geladen...', + 'Error loading hooks:': 'Fehler beim Laden der Hooks:', + 'Press Escape to close': 'Escape zum Schließen drücken', + 'Press Escape, Ctrl+C, or Ctrl+D to cancel': + 'Escape, Ctrl+C oder Ctrl+D zum Abbrechen', + 'Press Space, Enter, or Escape to dismiss': + 'Leertaste, Enter oder Escape zum Schließen', + 'No hook selected': 'Kein Hook ausgewählt', + // Hooks - List Step + 'No hook events found.': 'Keine Hook-Ereignisse gefunden.', + '{{count}} hook configured': '{{count}} Hook konfiguriert', + '{{count}} hooks configured': '{{count}} Hooks konfiguriert', + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.': + 'Dieses Menü ist schreibgeschützt. Um Hooks hinzuzufügen oder zu ändern, bearbeiten Sie settings.json direkt oder fragen Sie Qwen Code.', + 'Enter to select · Esc to cancel': 'Enter zum Auswählen · Esc zum Abbrechen', + // Hooks - Detail Step + 'Exit codes:': 'Exit-Codes:', + 'Configured hooks:': 'Konfigurierte Hooks:', + 'No hooks configured for this event.': + 'Für dieses Ereignis sind keine Hooks konfiguriert.', + 'To add hooks, edit settings.json directly or ask Qwen.': + 'Um Hooks hinzuzufügen, bearbeiten Sie settings.json direkt oder fragen Sie Qwen.', + 'Enter to select · Esc to go back': 'Enter zum Auswählen · Esc zum Zurück', + // Hooks - Config Detail Step + 'Hook details': 'Hook-Details', + 'Event:': 'Ereignis:', + 'Extension:': 'Erweiterung:', + 'Desc:': 'Beschreibung:', + 'No hook config selected': 'Keine Hook-Konfiguration ausgewählt', + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.': + 'Um diesen Hook zu ändern oder zu entfernen, bearbeiten Sie settings.json direkt oder fragen Sie Qwen.', + // Hooks - Disabled Step + 'Hook Configuration - Disabled': 'Hook-Konfiguration - Deaktiviert', + 'All hooks are currently disabled. You have {{count}} that are not running.': + 'Alle Hooks sind derzeit deaktiviert. Sie haben {{count}} die nicht ausgeführt werden.', + '{{count}} configured hook': '{{count}} konfigurierter Hook', + '{{count}} configured hooks': '{{count}} konfigurierte Hooks', + 'When hooks are disabled:': 'Wenn Hooks deaktiviert sind:', + 'No hook commands will execute': 'Keine Hook-Befehle werden ausgeführt', + 'StatusLine will not be displayed': 'StatusLine wird nicht angezeigt', + 'Tool operations will proceed without hook validation': + 'Tool-Operationen werden ohne Hook-Validierung fortgesetzt', + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.': + 'Um Hooks wieder zu aktivieren, entfernen Sie "disableAllHooks" aus settings.json oder fragen Sie Qwen Code.', + // Hooks - Source + Project: 'Projekt', + User: 'Benutzer', + System: 'System', + Extension: 'Erweiterung', + 'Local Settings': 'Lokale Einstellungen', + 'User Settings': 'Benutzereinstellungen', + 'System Settings': 'Systemeinstellungen', + Extensions: 'Erweiterungen', + // Hooks - Status + '✓ Enabled': '✓ Aktiviert', + '✗ Disabled': '✗ Deaktiviert', + // Hooks - Event Descriptions (short) + 'Before tool execution': 'Vor der Tool-Ausführung', + 'After tool execution': 'Nach der Tool-Ausführung', + 'After tool execution fails': 'Wenn die Tool-Ausführung fehlschlägt', + 'When notifications are sent': 'Wenn Benachrichtigungen gesendet werden', + 'When the user submits a prompt': 'Wenn der Benutzer einen Prompt absendet', + 'When a new session is started': 'Wenn eine neue Sitzung gestartet wird', + 'Right before Qwen Code concludes its response': + 'Direkt bevor Qwen Code seine Antwort abschließt', + 'When a subagent (Agent tool call) is started': + 'Wenn ein Subagent (Agent-Tool-Aufruf) gestartet wird', + 'Right before a subagent concludes its response': + 'Direkt bevor ein Subagent seine Antwort abschließt', + 'Before conversation compaction': 'Vor der Gesprächskomprimierung', + 'When a session is ending': 'Wenn eine Sitzung endet', + 'When a permission dialog is displayed': + 'Wenn ein Berechtigungsdialog angezeigt wird', + // Hooks - Event Descriptions (detailed) + 'Input to command is JSON of tool call arguments.': + 'Die Eingabe an den Befehl ist JSON der Tool-Aufruf-Argumente.', + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).': + 'Die Eingabe an den Befehl ist JSON mit den Feldern "inputs" (Tool-Aufruf-Argumente) und "response" (Tool-Aufruf-Antwort).', + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.': + 'Die Eingabe an den Befehl ist JSON mit tool_name, tool_input, tool_use_id, error, error_type, is_interrupt und is_timeout.', + 'Input to command is JSON with notification message and type.': + 'Die Eingabe an den Befehl ist JSON mit Benachrichtigungsnachricht und -typ.', + 'Input to command is JSON with original user prompt text.': + 'Die Eingabe an den Befehl ist JSON mit dem ursprünglichen Benutzer-Prompt-Text.', + 'Input to command is JSON with session start source.': + 'Die Eingabe an den Befehl ist JSON mit der Sitzungsstart-Quelle.', + 'Input to command is JSON with session end reason.': + 'Die Eingabe an den Befehl ist JSON mit dem Sitzungsende-Grund.', + 'Input to command is JSON with agent_id and agent_type.': + 'Die Eingabe an den Befehl ist JSON mit agent_id und agent_type.', + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.': + 'Die Eingabe an den Befehl ist JSON mit agent_id, agent_type und agent_transcript_path.', + 'Input to command is JSON with compaction details.': + 'Die Eingabe an den Befehl ist JSON mit Komprimierungsdetails.', + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.': + 'Die Eingabe an den Befehl ist JSON mit tool_name, tool_input und tool_use_id. Ausgabe ist JSON mit hookSpecificOutput, das die Entscheidung zum Zulassen oder Ablehnen enthält.', + // Hooks - Exit Code Descriptions + 'stdout/stderr not shown': 'stdout/stderr nicht angezeigt', + 'show stderr to model and continue conversation': + 'stderr dem Modell anzeigen und Konversation fortsetzen', + 'show stderr to user only': 'stderr nur dem Benutzer anzeigen', + 'stdout shown in transcript mode (ctrl+o)': + 'stdout im Transkriptmodus angezeigt (ctrl+o)', + 'show stderr to model immediately': 'stderr sofort dem Modell anzeigen', + 'show stderr to user only but continue with tool call': + 'stderr nur dem Benutzer anzeigen, aber mit Tool-Aufruf fortfahren', + 'block processing, erase original prompt, and show stderr to user only': + 'Verarbeitung blockieren, ursprünglichen Prompt löschen und stderr nur dem Benutzer anzeigen', + 'stdout shown to Qwen': 'stdout dem Qwen anzeigen', + 'show stderr to user only (blocking errors ignored)': + 'stderr nur dem Benutzer anzeigen (Blockierungsfehler ignoriert)', + 'command completes successfully': 'Befehl erfolgreich abgeschlossen', + 'stdout shown to subagent': 'stdout dem Subagenten anzeigen', + 'show stderr to subagent and continue having it run': + 'stderr dem Subagenten anzeigen und ihn weiterlaufen lassen', + 'stdout appended as custom compact instructions': + 'stdout als benutzerdefinierte Komprimierungsanweisungen angehängt', + 'block compaction': 'Komprimierung blockieren', + 'show stderr to user only but continue with compaction': + 'stderr nur dem Benutzer anzeigen, aber mit Komprimierung fortfahren', + 'use hook decision if provided': + 'Hook-Entscheidung verwenden, falls bereitgestellt', + // Hooks - Messages + 'Config not loaded.': 'Konfiguration nicht geladen.', + 'Hooks are not enabled. Enable hooks in settings to use this feature.': + 'Hooks sind nicht aktiviert. Aktivieren Sie Hooks in den Einstellungen, um diese Funktion zu nutzen.', + 'No hooks configured. Add hooks in your settings.json file.': + 'Keine Hooks konfiguriert. Fügen Sie Hooks in Ihrer settings.json-Datei hinzu.', + 'Configured Hooks ({{count}} total)': + 'Konfigurierte Hooks ({{count}} insgesamt)', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Den Nachrichtenverlauf der aktuellen Sitzung in eine Datei exportieren', + 'Export session to HTML format': 'Sitzung in das HTML-Format exportieren', + 'Export session to JSON format': 'Sitzung in das JSON-Format exportieren', + 'Export session to JSONL format (one message per line)': + 'Sitzung in das JSONL-Format exportieren (eine Nachricht pro Zeile)', + 'Export session to markdown format': + 'Sitzung in das Markdown-Format exportieren', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'Personalisierte Programmier-Einblicke aus Ihrem Chatverlauf generieren', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Eine vorherige Sitzung fortsetzen', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Einen Tool-Aufruf wiederherstellen. Dadurch werden Konversations- und Dateiverlauf auf den Zustand zurückgesetzt, in dem der Tool-Aufruf vorgeschlagen wurde', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Terminal-Typ konnte nicht erkannt werden. Unterstützte Terminals: VS Code, Cursor, Windsurf und Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Terminal "{{terminal}}" wird noch nicht unterstützt.', + + // ============================================================================ + // Commands - Language + // ============================================================================ + 'Invalid language. Available: {{options}}': + 'Ungültige Sprache. Verfügbar: {{options}}', + 'Language subcommands do not accept additional arguments.': + 'Sprach-Unterbefehle akzeptieren keine zusätzlichen Argumente.', + 'Current UI language: {{lang}}': 'Aktuelle UI-Sprache: {{lang}}', + 'Current LLM output language: {{lang}}': + 'Aktuelle LLM-Ausgabesprache: {{lang}}', + 'LLM output language not set': 'LLM-Ausgabesprache nicht festgelegt', + 'Set UI language': 'UI-Sprache festlegen', + 'Set LLM output language': 'LLM-Ausgabesprache festlegen', + 'Usage: /language ui [{{options}}]': 'Verwendung: /language ui [{{options}}]', + 'Usage: /language output ': + 'Verwendung: /language output ', + 'Example: /language output 中文': 'Beispiel: /language output Deutsch', + 'Example: /language output English': 'Beispiel: /language output Englisch', + 'Example: /language output 日本語': 'Beispiel: /language output Japanisch', + 'Example: /language output Português': + 'Beispiel: /language output Portugiesisch', + 'UI language changed to {{lang}}': 'UI-Sprache geändert zu {{lang}}', + 'LLM output language set to {{lang}}': + 'LLM-Ausgabesprache auf {{lang}} gesetzt', + 'LLM output language rule file generated at {{path}}': + 'LLM-Ausgabesprach-Regeldatei generiert unter {{path}}', + 'Please restart the application for the changes to take effect.': + 'Bitte starten Sie die Anwendung neu, damit die Änderungen wirksam werden.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Fehler beim Generieren der LLM-Ausgabesprach-Regeldatei: {{error}}', + 'Invalid command. Available subcommands:': + 'Ungültiger Befehl. Verfügbare Unterbefehle:', + 'Available subcommands:': 'Verfügbare Unterbefehle:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'Um zusätzliche UI-Sprachpakete anzufordern, öffnen Sie bitte ein Issue auf GitHub.', + 'Available options:': 'Verfügbare Optionen:', + 'Set UI language to {{name}}': 'UI-Sprache auf {{name}} setzen', + + // ============================================================================ + // Commands - Approval Mode + // ============================================================================ + 'Tool Approval Mode': 'Werkzeug-Genehmigungsmodus', + 'Current approval mode: {{mode}}': 'Aktueller Genehmigungsmodus: {{mode}}', + 'Available approval modes:': 'Verfügbare Genehmigungsmodi:', + 'Approval mode changed to: {{mode}}': + 'Genehmigungsmodus geändert zu: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Genehmigungsmodus geändert zu: {{mode}} (gespeichert in {{scope}} Einstellungen{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Verwendung: /approval-mode [--session|--user|--project]', + + 'Scope subcommands do not accept additional arguments.': + 'Bereichs-Unterbefehle akzeptieren keine zusätzlichen Argumente.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Planungsmodus - Nur analysieren, keine Dateien ändern oder Befehle ausführen', + 'Default mode - Require approval for file edits or shell commands': + 'Standardmodus - Genehmigung für Dateibearbeitungen oder Shell-Befehle erforderlich', + 'Auto-edit mode - Automatically approve file edits': + 'Automatischer Bearbeitungsmodus - Dateibearbeitungen automatisch genehmigen', + 'YOLO mode - Automatically approve all tools': + 'YOLO-Modus - Alle Werkzeuge automatisch genehmigen', + '{{mode}} mode': '{{mode}}-Modus', + 'Settings service is not available; unable to persist the approval mode.': + 'Einstellungsdienst nicht verfügbar; Genehmigungsmodus kann nicht gespeichert werden.', + 'Failed to save approval mode: {{error}}': + 'Fehler beim Speichern des Genehmigungsmodus: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Fehler beim Ändern des Genehmigungsmodus: {{error}}', + 'Apply to current session only (temporary)': + 'Nur auf aktuelle Sitzung anwenden (temporär)', + 'Persist for this project/workspace': + 'Für dieses Projekt/Arbeitsbereich speichern', + 'Persist for this user on this machine': + 'Für diesen Benutzer auf diesem Computer speichern', + 'Analyze only, do not modify files or execute commands': + 'Nur analysieren, keine Dateien ändern oder Befehle ausführen', + 'Require approval for file edits or shell commands': + 'Genehmigung für Dateibearbeitungen oder Shell-Befehle erforderlich', + 'Automatically approve file edits': + 'Dateibearbeitungen automatisch genehmigen', + 'Automatically approve all tools': 'Alle Werkzeuge automatisch genehmigen', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Änderung hat keine Wirkung.', + 'Apply To': 'Anwenden auf', + 'Workspace Settings': 'Arbeitsbereich-Einstellungen', + + // ============================================================================ + // Commands - Memory + // ============================================================================ + 'Commands for interacting with memory.': + 'Befehle für die Interaktion mit dem Speicher.', + 'Show the current memory contents.': 'Aktuellen Speicherinhalt anzeigen.', + 'Show project-level memory contents.': + 'Projektebene-Speicherinhalt anzeigen.', + 'Show global memory contents.': 'Globalen Speicherinhalt anzeigen.', + 'Add content to project-level memory.': + 'Inhalt zum Projektebene-Speicher hinzufügen.', + 'Add content to global memory.': 'Inhalt zum globalen Speicher hinzufügen.', + 'Refresh the memory from the source.': + 'Speicher aus der Quelle aktualisieren.', + 'Usage: /memory add --project ': + 'Verwendung: /memory add --project ', + 'Usage: /memory add --global ': + 'Verwendung: /memory add --global ', + 'Attempting to save to project memory: "{{text}}"': + 'Versuche im Projektspeicher zu speichern: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Versuche im globalen Speicher zu speichern: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Aktueller Speicherinhalt aus {{count}} Datei(en):', + 'Memory is currently empty.': 'Speicher ist derzeit leer.', + 'Project memory file not found or is currently empty.': + 'Projektspeicherdatei nicht gefunden oder derzeit leer.', + 'Global memory file not found or is currently empty.': + 'Globale Speicherdatei nicht gefunden oder derzeit leer.', + 'Global memory is currently empty.': 'Globaler Speicher ist derzeit leer.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Globaler Speicherinhalt:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Projektspeicherinhalt von {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': 'Projektspeicher ist derzeit leer.', + 'Refreshing memory from source files...': + 'Speicher wird aus Quelldateien aktualisiert...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Inhalt zum Speicher hinzufügen. --global für globalen Speicher oder --project für Projektspeicher verwenden.', + 'Usage: /memory add [--global|--project] ': + 'Verwendung: /memory add [--global|--project] ', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Versuche im Speicher {{scope}} zu speichern: "{{fact}}"', + + // ============================================================================ + // Commands - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Mit einem OAuth-fähigen MCP-Server authentifizieren', + 'List configured MCP servers and tools': + 'Konfigurierte MCP-Server und Werkzeuge auflisten', + 'Restarts MCP servers.': 'MCP-Server neu starten.', + 'Could not retrieve tool registry.': + 'Werkzeugregister konnte nicht abgerufen werden.', + 'No MCP servers configured with OAuth authentication.': + 'Keine MCP-Server mit OAuth-Authentifizierung konfiguriert.', + 'MCP servers with OAuth authentication:': + 'MCP-Server mit OAuth-Authentifizierung:', + 'Use /mcp auth to authenticate.': + 'Verwenden Sie /mcp auth zur Authentifizierung.', + "MCP server '{{name}}' not found.": "MCP-Server '{{name}}' nicht gefunden.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Erfolgreich authentifiziert und Werkzeuge für '{{name}}' aktualisiert.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Authentifizierung mit MCP-Server '{{name}}' fehlgeschlagen: {{error}}", + "Re-discovering tools from '{{name}}'...": + "Werkzeuge von '{{name}}' werden neu erkannt...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "{{count}} Werkzeug(e) von '{{name}}' entdeckt.", + 'Authentication complete. Returning to server details...': + 'Authentifizierung abgeschlossen. Zurück zu den Serverdetails...', + 'Authentication successful.': 'Authentifizierung erfolgreich.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Falls der Browser sich nicht öffnet, kopieren Sie diese URL und fügen Sie sie in Ihren Browser ein:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Stellen Sie sicher, dass Sie die VOLLSTÄNDIGE URL kopieren – sie kann über mehrere Zeilen gehen.', + + // ============================================================================ + // Commands - Chat + // ============================================================================ + 'Manage conversation history.': 'Gesprächsverlauf verwalten.', + 'List saved conversation checkpoints': + 'Gespeicherte Gesprächsprüfpunkte auflisten', + 'No saved conversation checkpoints found.': + 'Keine gespeicherten Gesprächsprüfpunkte gefunden.', + 'List of saved conversations:': 'Liste gespeicherter Gespräche:', + 'Note: Newest last, oldest first': 'Hinweis: Neueste zuletzt, älteste zuerst', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Aktuelles Gespräch als Prüfpunkt speichern. Verwendung: /chat save ', + 'Missing tag. Usage: /chat save ': + 'Tag fehlt. Verwendung: /chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Gesprächsprüfpunkt löschen. Verwendung: /chat delete ', + 'Missing tag. Usage: /chat delete ': + 'Tag fehlt. Verwendung: /chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Gesprächsprüfpunkt '{{tag}}' wurde gelöscht.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Fehler: Kein Prüfpunkt mit Tag '{{tag}}' gefunden.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Gespräch von einem Prüfpunkt fortsetzen. Verwendung: /chat resume ', + 'Missing tag. Usage: /chat resume ': + 'Tag fehlt. Verwendung: /chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + 'Kein gespeicherter Prüfpunkt mit Tag gefunden: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'Ein Prüfpunkt mit dem Tag {{tag}} existiert bereits. Möchten Sie ihn überschreiben?', + 'No chat client available to save conversation.': + 'Kein Chat-Client verfügbar, um Gespräch zu speichern.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Gesprächsprüfpunkt gespeichert mit Tag: {{tag}}.', + 'No conversation found to save.': 'Kein Gespräch zum Speichern gefunden.', + 'No chat client available to share conversation.': + 'Kein Chat-Client verfügbar, um Gespräch zu teilen.', + 'Invalid file format. Only .md and .json are supported.': + 'Ungültiges Dateiformat. Nur .md und .json werden unterstützt.', + 'Error sharing conversation: {{error}}': + 'Fehler beim Teilen des Gesprächs: {{error}}', + 'Conversation shared to {{filePath}}': 'Gespräch geteilt nach {{filePath}}', + 'No conversation found to share.': 'Kein Gespräch zum Teilen gefunden.', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + 'Aktuelles Gespräch in eine Markdown- oder JSON-Datei teilen. Verwendung: /chat share ', + + // ============================================================================ + // Commands - Summary + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Projektzusammenfassung generieren und in .qwen/PROJECT_SUMMARY.md speichern', + 'No chat client available to generate summary.': + 'Kein Chat-Client verfügbar, um Zusammenfassung zu generieren.', + 'Already generating summary, wait for previous request to complete': + 'Zusammenfassung wird bereits generiert, warten Sie auf Abschluss der vorherigen Anfrage', + 'No conversation found to summarize.': + 'Kein Gespräch zum Zusammenfassen gefunden.', + 'Failed to generate project context summary: {{error}}': + 'Fehler beim Generieren der Projektkontextzusammenfassung: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'Projektzusammenfassung gespeichert unter {{filePathForDisplay}}.', + 'Saving project summary...': 'Projektzusammenfassung wird gespeichert...', + 'Generating project summary...': 'Projektzusammenfassung wird generiert...', + 'Failed to generate summary - no text content received from LLM response': + 'Fehler beim Generieren der Zusammenfassung - kein Textinhalt von LLM-Antwort erhalten', + + // ============================================================================ + // Commands - Model + // ============================================================================ + 'Switch the model for this session': 'Modell für diese Sitzung wechseln', + 'Set fast model for background tasks': + 'Schnelles Modell für Hintergrundaufgaben festlegen', + 'Content generator configuration not available.': + 'Inhaltsgenerator-Konfiguration nicht verfügbar.', + 'Authentication type not available.': + 'Authentifizierungstyp nicht verfügbar.', + 'No models available for the current authentication type ({{authType}}).': + 'Keine Modelle für den aktuellen Authentifizierungstyp ({{authType}}) verfügbar.', + + // ============================================================================ + // Commands - Clear + // ============================================================================ + 'Starting a new session, resetting chat, and clearing terminal.': + 'Neue Sitzung wird gestartet, Chat wird zurückgesetzt und Terminal wird gelöscht.', + 'Starting a new session and clearing.': + 'Neue Sitzung wird gestartet und gelöscht.', + + // ============================================================================ + // Commands - Compress + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Komprimierung läuft bereits, warten Sie auf Abschluss der vorherigen Anfrage', + 'Failed to compress chat history.': + 'Fehler beim Komprimieren des Chatverlaufs.', + 'Failed to compress chat history: {{error}}': + 'Fehler beim Komprimieren des Chatverlaufs: {{error}}', + 'Compressing chat history': 'Chatverlauf wird komprimiert', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'Chatverlauf komprimiert von {{originalTokens}} auf {{newTokens}} Token.', + 'Compression was not beneficial for this history size.': + 'Komprimierung war für diese Verlaufsgröße nicht vorteilhaft.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Chatverlauf-Komprimierung hat die Größe nicht reduziert. Dies kann auf Probleme mit dem Komprimierungs-Prompt hindeuten.', + 'Could not compress chat history due to a token counting error.': + 'Chatverlauf konnte aufgrund eines Token-Zählfehlers nicht komprimiert werden.', + 'Chat history is already compressed.': 'Chatverlauf ist bereits komprimiert.', + + // ============================================================================ + // Commands - Directory + // ============================================================================ + 'Configuration is not available.': 'Konfiguration ist nicht verfügbar.', + 'Please provide at least one path to add.': + 'Bitte geben Sie mindestens einen Pfad zum Hinzufügen an.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'Der Befehl /directory add wird in restriktiven Sandbox-Profilen nicht unterstützt. Bitte verwenden Sie --include-directories beim Starten der Sitzung.', + "Error adding '{{path}}': {{error}}": + "Fehler beim Hinzufügen von '{{path}}': {{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + 'QWEN.md-Dateien aus folgenden Verzeichnissen erfolgreich hinzugefügt, falls vorhanden:\n- {{directories}}', + 'Error refreshing memory: {{error}}': + 'Fehler beim Aktualisieren des Speichers: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Verzeichnisse erfolgreich hinzugefügt:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Aktuelle Arbeitsbereichsverzeichnisse:\n{{directories}}', + + // ============================================================================ + // Commands - Docs + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Bitte öffnen Sie folgende URL in Ihrem Browser, um die Dokumentation anzusehen:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Dokumentation wird in Ihrem Browser geöffnet: {{url}}', + + // ============================================================================ + // Dialogs - Tool Confirmation + // ============================================================================ + 'Do you want to proceed?': 'Möchten Sie fortfahren?', + 'Yes, allow once': 'Ja, einmal erlauben', + 'Allow always': 'Immer erlauben', + Yes: 'Ja', + No: 'Nein', + 'No (esc)': 'Nein (Esc)', + 'Yes, allow always for this session': 'Ja, für diese Sitzung immer erlauben', + + // MCP Management Dialog (translations for MCP UI components) + 'Manage MCP servers': 'MCP-Server verwalten', + 'Server Detail': 'Serverdetails', + 'Disable Server': 'Server deaktivieren', + Tools: 'Werkzeuge', + 'Tool Detail': 'Werkzeugdetails', + 'MCP Management': 'MCP-Verwaltung', + 'Loading...': 'Lädt...', + 'Unknown step': 'Unbekannter Schritt', + 'Esc to back': 'Esc zurück', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ navigieren · Enter auswählen · Esc schließen', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ navigieren · Enter auswählen · Esc zurück', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ navigieren · Enter bestätigen · Esc zurück', + 'User Settings (global)': 'Benutzereinstellungen (global)', + 'Workspace Settings (project-specific)': + 'Arbeitsbereichseinstellungen (projektspezifisch)', + 'Disable server:': 'Server deaktivieren:', + 'Select where to add the server to the exclude list:': + 'Wählen Sie, wo der Server zur Ausschlussliste hinzugefügt werden soll:', + 'Press Enter to confirm, Esc to cancel': + 'Enter zum Bestätigen, Esc zum Abbrechen', + Disable: 'Deaktivieren', + Enable: 'Aktivieren', + Authenticate: 'Authentifizieren', + 'Re-authenticate': 'Erneut authentifizieren', + 'Clear Authentication': 'Authentifizierung löschen', + disabled: 'deaktiviert', + 'Server:': 'Server:', + Reconnect: 'Neu verbinden', + 'View tools': 'Werkzeuge anzeigen', + 'Status:': 'Status:', + 'Command:': 'Befehl:', + 'Working Directory:': 'Arbeitsverzeichnis:', + 'Capabilities:': 'Fähigkeiten:', + 'No server selected': 'Kein Server ausgewählt', + '(disabled)': '(deaktiviert)', + 'Error:': 'Fehler:', + tool: 'Werkzeug', + tools: 'Werkzeuge', + connected: 'verbunden', + connecting: 'verbindet', + disconnected: 'getrennt', + error: 'Fehler', + + // MCP Server List + 'User MCPs': 'Benutzer-MCPs', + 'Project MCPs': 'Projekt-MCPs', + 'Extension MCPs': 'Erweiterungs-MCPs', + server: 'Server', + servers: 'Server', + 'Add MCP servers to your settings to get started.': + 'Fügen Sie MCP-Server zu Ihren Einstellungen hinzu, um zu beginnen.', + 'Run qwen --debug to see error logs': + 'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen', + + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth-Authentifizierung', + 'Press Enter to start authentication, Esc to go back': + 'Drücken Sie Enter, um die Authentifizierung zu starten, Esc zum Zurückgehen', + 'Authenticating... Please complete the login in your browser.': + 'Authentifizierung läuft... Bitte schließen Sie die Anmeldung in Ihrem Browser ab.', + 'Press Enter or Esc to go back': 'Drücken Sie Enter oder Esc zum Zurückgehen', + + // MCP Tool List + 'No tools available for this server.': + 'Keine Werkzeuge für diesen Server verfügbar.', + destructive: 'destruktiv', + 'read-only': 'schreibgeschützt', + 'open-world': 'offene Welt', + idempotent: 'idempotent', + 'Tools for {{name}}': 'Werkzeuge für {{name}}', + 'Tools for {{serverName}}': 'Werkzeuge für {{serverName}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'erforderlich', + Type: 'Typ', + Enum: 'Aufzählung', + Parameters: 'Parameter', + 'No tool selected': 'Kein Werkzeug ausgewählt', + Annotations: 'Anmerkungen', + Title: 'Titel', + 'Read Only': 'Schreibgeschützt', + Destructive: 'Destruktiv', + Idempotent: 'Idempotent', + 'Open World': 'Offene Welt', + Server: 'Server', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} ungültige Werkzeuge', + invalid: 'ungültig', + 'invalid: {{reason}}': 'ungültig: {{reason}}', + 'missing name': 'Name fehlt', + 'missing description': 'Beschreibung fehlt', + '(unnamed)': '(unbenannt)', + 'Warning: This tool cannot be called by the LLM': + 'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden', + Reason: 'Grund', + 'Tools must have both name and description to be used by the LLM.': + 'Werkzeuge müssen sowohl einen Namen als auch eine Beschreibung haben, um vom LLM verwendet zu werden.', + 'Modify in progress:': 'Änderung in Bearbeitung:', + 'Save and close external editor to continue': + 'Speichern und externen Editor schließen, um fortzufahren', + 'Apply this change?': 'Diese Änderung anwenden?', + 'Yes, allow always': 'Ja, immer erlauben', + 'Modify with external editor': 'Mit externem Editor bearbeiten', + 'No, suggest changes (esc)': 'Nein, Änderungen vorschlagen (Esc)', + "Allow execution of: '{{command}}'?": + "Ausführung erlauben von: '{{command}}'?", + 'Yes, allow always ...': 'Ja, immer erlauben ...', + 'Always allow in this project': 'In diesem Projekt immer erlauben', + 'Always allow {{action}} in this project': + '{{action}} in diesem Projekt immer erlauben', + 'Always allow for this user': 'Für diesen Benutzer immer erlauben', + 'Always allow {{action}} for this user': + '{{action}} für diesen Benutzer immer erlauben', + 'Yes, restore previous mode ({{mode}})': + 'Ja, vorherigen Modus wiederherstellen ({{mode}})', + 'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren', + 'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen', + 'No, keep planning (esc)': 'Nein, weiter planen (Esc)', + 'URLs to fetch:': 'Abzurufende URLs:', + 'MCP Server: {{server}}': 'MCP-Server: {{server}}', + 'Tool: {{tool}}': 'Werkzeug: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Ausführung des MCP-Werkzeugs "{{tool}}" von Server "{{server}}" erlauben?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Ja, Werkzeug "{{tool}}" von Server "{{server}}" immer erlauben', + 'Yes, always allow all tools from server "{{server}}"': + 'Ja, alle Werkzeuge von Server "{{server}}" immer erlauben', + + // ============================================================================ + // Dialogs - Shell Confirmation + // ============================================================================ + 'Shell Command Execution': 'Shell-Befehlsausführung', + 'A custom command wants to run the following shell commands:': + 'Ein benutzerdefinierter Befehl möchte folgende Shell-Befehle ausführen:', + + // ============================================================================ + // Dialogs - Pro Quota + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Pro-Kontingentlimit für {{model}} erreicht.', + 'Change auth (executes the /auth command)': + 'Authentifizierung ändern (führt den /auth-Befehl aus)', + 'Continue with {{model}}': 'Mit {{model}} fortfahren', + + // ============================================================================ + // Dialogs - Welcome Back + // ============================================================================ + 'Current Plan:': 'Aktueller Plan:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Fortschritt: {{done}}/{{total}} Aufgaben abgeschlossen', + ', {{inProgress}} in progress': ', {{inProgress}} in Bearbeitung', + 'Pending Tasks:': 'Ausstehende Aufgaben:', + 'What would you like to do?': 'Was möchten Sie tun?', + 'Choose how to proceed with your session:': + 'Wählen Sie, wie Sie mit Ihrer Sitzung fortfahren möchten:', + 'Start new chat session': 'Neue Chat-Sitzung starten', + 'Continue previous conversation': 'Vorheriges Gespräch fortsetzen', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 Willkommen zurück! (Zuletzt aktualisiert: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Gesamtziel:', + + // ============================================================================ + // Dialogs - Auth + // ============================================================================ + 'Get started': 'Loslegen', + 'Select Authentication Method': 'Authentifizierungsmethode auswählen', + 'OpenAI API key is required to use OpenAI authentication.': + 'OpenAI API-Schlüssel ist für die OpenAI-Authentifizierung erforderlich.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'Sie müssen eine Authentifizierungsmethode wählen, um fortzufahren. Drücken Sie erneut Strg+C zum Beenden.', + 'Terms of Services and Privacy Notice': + 'Nutzungsbedingungen und Datenschutzhinweis', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Kostenpflichtig \u00B7 Bis zu 6.000 Anfragen/5 Std. \u00B7 Alle Alibaba Cloud Coding Plan Modelle', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Eigenen API-Schlüssel verwenden', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Anmeldung fehlgeschlagen. Meldung: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Authentifizierung ist auf {{enforcedType}} festgelegt, aber Sie verwenden derzeit {{currentType}}.', + 'Please visit this URL to authorize:': + 'Bitte besuchen Sie diese URL zur Autorisierung:', + 'Or scan the QR code below:': 'Oder scannen Sie den QR-Code unten:', + 'Waiting for authorization': 'Warten auf Autorisierung', + 'Time remaining:': 'Verbleibende Zeit:', + '(Press ESC or CTRL+C to cancel)': '(ESC oder STRG+C zum Abbrechen drücken)', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuth-Token abgelaufen (über {{seconds}} Sekunden). Bitte wählen Sie erneut eine Authentifizierungsmethode.', + 'Press any key to return to authentication type selection.': + 'Drücken Sie eine beliebige Taste, um zur Authentifizierungstypauswahl zurückzukehren.', + 'Authentication timed out. Please try again.': + 'Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Warten auf Authentifizierung... (ESC oder STRG+C zum Abbrechen drücken)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + 'API-Schlüssel für OpenAI-kompatible Authentifizierung fehlt. Setzen Sie settings.security.auth.apiKey oder die Umgebungsvariable {{envKeyHint}}.', + '{{envKeyHint}} environment variable not found.': + 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden.', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden. Bitte legen Sie sie in Ihrer .env-Datei oder den Systemumgebungsvariablen fest.', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden (oder setzen Sie settings.security.auth.apiKey). Bitte legen Sie sie in Ihrer .env-Datei oder den Systemumgebungsvariablen fest.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'API-Schlüssel für OpenAI-kompatible Authentifizierung fehlt. Setzen Sie die Umgebungsvariable {{envKeyHint}}.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic-Anbieter fehlt erforderliche baseUrl in modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'Umgebungsvariable ANTHROPIC_BASE_URL wurde nicht gefunden.', + 'Invalid auth method selected.': + 'Ungültige Authentifizierungsmethode ausgewählt.', + 'Failed to authenticate. Message: {{message}}': + 'Authentifizierung fehlgeschlagen. Meldung: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Erfolgreich mit {{authType}}-Anmeldedaten authentifiziert.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Ungültiger QWEN_DEFAULT_AUTH_TYPE-Wert: "{{value}}". Gültige Werte sind: {{validValues}}', + 'OpenAI Configuration Required': 'OpenAI-Konfiguration erforderlich', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Bitte geben Sie Ihre OpenAI-Konfiguration ein. Sie können einen API-Schlüssel erhalten von', + 'API Key:': 'API-Schlüssel:', + 'Invalid credentials: {{errorMessage}}': + 'Ungültige Anmeldedaten: {{errorMessage}}', + 'Failed to validate credentials': + 'Anmeldedaten konnten nicht validiert werden', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Enter zum Fortfahren, Tab/↑↓ zum Navigieren, Esc zum Abbrechen', + + // ============================================================================ + // Dialogs - Model + // ============================================================================ + 'Select Model': 'Modell auswählen', + '(Press Esc to close)': '(Esc zum Schließen drücken)', + 'Current (effective) configuration': 'Aktuelle (wirksame) Konfiguration', + AuthType: 'Authentifizierungstyp', + 'API Key': 'API-Schlüssel', + unset: 'nicht gesetzt', + '(default)': '(Standard)', + '(set)': '(gesetzt)', + '(not set)': '(nicht gesetzt)', + Modality: 'Modalität', + 'Context Window': 'Kontextfenster', + text: 'Text', + 'text-only': 'nur Text', + image: 'Bild', + pdf: 'PDF', + audio: 'Audio', + video: 'Video', + 'not set': 'nicht gesetzt', + none: 'keine', + unknown: 'unbekannt', + "Failed to switch model to '{{modelId}}'.\n\n{{error}}": + "Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}", + 'Qwen 3.6 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.6 Plus — effizientes Hybridmodell mit führender Programmierleistung', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'Das neueste Qwen Vision Modell von Alibaba Cloud ModelStudio (Version: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Dialogs - Permissions + // ============================================================================ + 'Manage folder trust settings': 'Ordnervertrauenseinstellungen verwalten', + 'Manage permission rules': 'Berechtigungsregeln verwalten', + Allow: 'Erlauben', + Ask: 'Fragen', + Deny: 'Verweigern', + Workspace: 'Arbeitsbereich', + "Qwen Code won't ask before using allowed tools.": + 'Qwen Code fragt nicht, bevor erlaubte Tools verwendet werden.', + 'Qwen Code will ask before using these tools.': + 'Qwen Code fragt, bevor diese Tools verwendet werden.', + 'Qwen Code is not allowed to use denied tools.': + 'Qwen Code darf verweigerte Tools nicht verwenden.', + 'Manage trusted directories for this workspace.': + 'Vertrauenswürdige Verzeichnisse für diesen Arbeitsbereich verwalten.', + 'Any use of the {{tool}} tool': 'Jede Verwendung des {{tool}}-Tools', + "{{tool}} commands matching '{{pattern}}'": + "{{tool}}-Befehle, die '{{pattern}}' entsprechen", + 'From user settings': 'Aus Benutzereinstellungen', + 'From project settings': 'Aus Projekteinstellungen', + 'From session': 'Aus Sitzung', + 'Project settings (local)': 'Projekteinstellungen (lokal)', + 'Saved in .qwen/settings.local.json': + 'Gespeichert in .qwen/settings.local.json', + 'Project settings': 'Projekteinstellungen', + 'Checked in at .qwen/settings.json': 'Eingecheckt in .qwen/settings.json', + 'User settings': 'Benutzereinstellungen', + 'Saved in at ~/.qwen/settings.json': 'Gespeichert in ~/.qwen/settings.json', + 'Add a new rule…': 'Neue Regel hinzufügen…', + 'Add {{type}} permission rule': '{{type}}-Berechtigungsregel hinzufügen', + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.': + 'Berechtigungsregeln sind ein Toolname, optional gefolgt von einem Bezeichner in Klammern.', + 'e.g.,': 'z.B.', + or: 'oder', + 'Enter permission rule…': 'Berechtigungsregel eingeben…', + 'Enter to submit · Esc to cancel': 'Enter zum Absenden · Esc zum Abbrechen', + 'Where should this rule be saved?': 'Wo soll diese Regel gespeichert werden?', + 'Enter to confirm · Esc to cancel': + 'Enter zum Bestätigen · Esc zum Abbrechen', + 'Delete {{type}} rule?': '{{type}}-Regel löschen?', + 'Are you sure you want to delete this permission rule?': + 'Sind Sie sicher, dass Sie diese Berechtigungsregel löschen möchten?', + 'Permissions:': 'Berechtigungen:', + '(←/→ or tab to cycle)': '(←/→ oder Tab zum Wechseln)', + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel': + '↑↓ navigieren · Enter auswählen · Tippen suchen · Esc abbrechen', + 'Search…': 'Suche…', + 'Use /trust to manage folder trust settings for this workspace.': + 'Verwenden Sie /trust, um die Ordnervertrauenseinstellungen für diesen Arbeitsbereich zu verwalten.', + // Workspace directory management + 'Add directory…': 'Verzeichnis hinzufügen…', + 'Add directory to workspace': 'Verzeichnis zum Arbeitsbereich hinzufügen', + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.': + 'Qwen Code kann Dateien im Arbeitsbereich lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.', + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.': + 'Qwen Code kann Dateien in diesem Verzeichnis lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.', + 'Enter the path to the directory:': 'Pfad zum Verzeichnis eingeben:', + 'Enter directory path…': 'Verzeichnispfad eingeben…', + 'Tab to complete · Enter to add · Esc to cancel': + 'Tab zum Vervollständigen · Enter zum Hinzufügen · Esc zum Abbrechen', + 'Remove directory?': 'Verzeichnis entfernen?', + 'Are you sure you want to remove this directory from the workspace?': + 'Möchten Sie dieses Verzeichnis wirklich aus dem Arbeitsbereich entfernen?', + ' (Original working directory)': ' (Ursprüngliches Arbeitsverzeichnis)', + ' (from settings)': ' (aus Einstellungen)', + 'Directory does not exist.': 'Verzeichnis existiert nicht.', + 'Path is not a directory.': 'Pfad ist kein Verzeichnis.', + 'This directory is already in the workspace.': + 'Dieses Verzeichnis ist bereits im Arbeitsbereich.', + 'Already covered by existing directory: {{dir}}': + 'Bereits durch vorhandenes Verzeichnis abgedeckt: {{dir}}', + + // ============================================================================ + // Status Bar + // ============================================================================ + 'Using:': 'Verwendet:', + '{{count}} open file': '{{count}} geöffnete Datei', + '{{count}} open files': '{{count}} geöffnete Dateien', + '(ctrl+g to view)': '(Strg+G zum Anzeigen)', + '{{count}} {{name}} file': '{{count}} {{name}}-Datei', + '{{count}} {{name}} files': '{{count}} {{name}}-Dateien', + '{{count}} MCP server': '{{count}} MCP-Server', + '{{count}} MCP servers': '{{count}} MCP-Server', + '{{count}} Blocked': '{{count}} blockiert', + '(ctrl+t to view)': '(Strg+T zum Anzeigen)', + '(ctrl+t to toggle)': '(Strg+T zum Umschalten)', + 'Press Ctrl+C again to exit.': 'Drücken Sie erneut Strg+C zum Beenden.', + 'Press Ctrl+D again to exit.': 'Drücken Sie erneut Strg+D zum Beenden.', + 'Press Esc again to clear.': 'Drücken Sie erneut Esc zum Löschen.', + + // ============================================================================ + // MCP Status + // ============================================================================ + 'No MCP servers configured.': 'Keine MCP-Server konfiguriert.', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP-Server werden gestartet ({{count}} werden initialisiert)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Hinweis: Der erste Start kann länger dauern. Werkzeugverfügbarkeit wird automatisch aktualisiert.', + 'Configured MCP servers:': 'Konfigurierte MCP-Server:', + Ready: 'Bereit', + 'Starting... (first startup may take longer)': + 'Wird gestartet... (erster Start kann länger dauern)', + Disconnected: 'Getrennt', + '{{count}} tool': '{{count}} Werkzeug', + '{{count}} tools': '{{count}} Werkzeuge', + '{{count}} prompt': '{{count}} Prompt', + '{{count}} prompts': '{{count}} Prompts', + '(from {{extensionName}})': '(von {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth abgelaufen', + 'OAuth not authenticated': 'OAuth nicht authentifiziert', + 'tools and prompts will appear when ready': + 'Werkzeuge und Prompts werden angezeigt, wenn bereit', + '{{count}} tools cached': '{{count}} Werkzeuge zwischengespeichert', + 'Tools:': 'Werkzeuge:', + 'Parameters:': 'Parameter:', + 'Prompts:': 'Prompts:', + Blocked: 'Blockiert', + '💡 Tips:': '💡 Tipps:', + Use: 'Verwenden', + 'to show server and tool descriptions': + 'um Server- und Werkzeugbeschreibungen anzuzeigen', + 'to show tool parameter schemas': 'um Werkzeug-Parameter-Schemas anzuzeigen', + 'to hide descriptions': 'um Beschreibungen auszublenden', + 'to authenticate with OAuth-enabled servers': + 'um sich bei OAuth-fähigen Servern zu authentifizieren', + Press: 'Drücken Sie', + 'to toggle tool descriptions on/off': + 'um Werkzeugbeschreibungen ein-/auszuschalten', + "Starting OAuth authentication for MCP server '{{name}}'...": + "OAuth-Authentifizierung für MCP-Server '{{name}}' wird gestartet...", + 'Restarting MCP servers...': 'MCP-Server werden neu gestartet...', + + // ============================================================================ + // Startup Tips + // ============================================================================ + 'Tips for getting started:': 'Tipps zum Einstieg:', + '1. Ask questions, edit files, or run commands.': + '1. Stellen Sie Fragen, bearbeiten Sie Dateien oder führen Sie Befehle aus.', + '2. Be specific for the best results.': + '2. Seien Sie spezifisch für die besten Ergebnisse.', + 'files to customize your interactions with Qwen Code.': + 'Dateien, um Ihre Interaktionen mit Qwen Code anzupassen.', + 'for more information.': 'für weitere Informationen.', + + // ============================================================================ + // Exit Screen / Stats + // ============================================================================ + 'Agent powering down. Goodbye!': + 'Agent wird heruntergefahren. Auf Wiedersehen!', + 'To continue this session, run': + 'Um diese Sitzung fortzusetzen, führen Sie aus', + 'Interaction Summary': 'Interaktionszusammenfassung', + 'Session ID:': 'Sitzungs-ID:', + 'Tool Calls:': 'Werkzeugaufrufe:', + 'Success Rate:': 'Erfolgsrate:', + 'User Agreement:': 'Benutzerzustimmung:', + reviewed: 'überprüft', + 'Code Changes:': 'Codeänderungen:', + Performance: 'Leistung', + 'Wall Time:': 'Gesamtzeit:', + 'Agent Active:': 'Agent aktiv:', + 'API Time:': 'API-Zeit:', + 'Tool Time:': 'Werkzeugzeit:', + 'Session Stats': 'Sitzungsstatistiken', + 'Model Usage': 'Modellnutzung', + Reqs: 'Anfragen', + 'Input Tokens': 'Eingabe-Token', + 'Output Tokens': 'Ausgabe-Token', + 'Savings Highlight:': 'Einsparungen:', + 'of input tokens were served from the cache, reducing costs.': + 'der Eingabe-Token wurden aus dem Cache bedient, was die Kosten reduziert.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Tipp: Für eine vollständige Token-Aufschlüsselung führen Sie `/stats model` aus.', + 'Model Stats For Nerds': 'Modellstatistiken für Nerds', + 'Tool Stats For Nerds': 'Werkzeugstatistiken für Nerds', + Metric: 'Metrik', + API: 'API', + Requests: 'Anfragen', + Errors: 'Fehler', + 'Avg Latency': 'Durchschn. Latenz', + Tokens: 'Token', + Total: 'Gesamt', + Prompt: 'Prompt', + Cached: 'Zwischengespeichert', + Thoughts: 'Gedanken', + Tool: 'Werkzeug', + Output: 'Ausgabe', + 'No API calls have been made in this session.': + 'In dieser Sitzung wurden keine API-Aufrufe gemacht.', + 'Tool Name': 'Werkzeugname', + Calls: 'Aufrufe', + 'Success Rate': 'Erfolgsrate', + 'Avg Duration': 'Durchschn. Dauer', + 'User Decision Summary': 'Benutzerentscheidungs-Zusammenfassung', + 'Total Reviewed Suggestions:': 'Insgesamt überprüfter Vorschläge:', + ' » Accepted:': ' » Akzeptiert:', + ' » Rejected:': ' » Abgelehnt:', + ' » Modified:': ' » Geändert:', + ' Overall Agreement Rate:': ' Gesamtzustimmungsrate:', + 'No tool calls have been made in this session.': + 'In dieser Sitzung wurden keine Werkzeugaufrufe gemacht.', + 'Session start time is unavailable, cannot calculate stats.': + 'Sitzungsstartzeit nicht verfügbar, Statistiken können nicht berechnet werden.', + + // ============================================================================ + // Command Format Migration + // ============================================================================ + 'Command Format Migration': 'Befehlsformat-Migration', + 'Found {{count}} TOML command file:': '{{count}} TOML-Befehlsdatei gefunden:', + 'Found {{count}} TOML command files:': + '{{count}} TOML-Befehlsdateien gefunden:', + '... and {{count}} more': '... und {{count}} weitere', + 'The TOML format is deprecated. Would you like to migrate them to Markdown format?': + 'Das TOML-Format ist veraltet. Möchten Sie sie ins Markdown-Format migrieren?', + '(Backups will be created and original files will be preserved)': + '(Backups werden erstellt und Originaldateien werden beibehalten)', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': 'Warten auf Benutzerbestätigung...', + '(esc to cancel, {{time}})': '(Esc zum Abbrechen, {{time}})', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + WITTY_LOADING_PHRASES: [ + 'Auf gut Glück!', + 'Genialität wird ausgeliefert...', + 'Die Serifen werden aufgemalt...', + 'Durch den Schleimpilz navigieren...', + 'Die digitalen Geister werden befragt...', + 'Splines werden retikuliert...', + 'Die KI-Hamster werden aufgewärmt...', + 'Die Zaubermuschel wird befragt...', + 'Witzige Erwiderung wird generiert...', + 'Die Algorithmen werden poliert...', + 'Perfektion braucht Zeit (mein Code auch)...', + 'Frische Bytes werden gebrüht...', + 'Elektronen werden gezählt...', + 'Kognitive Prozessoren werden aktiviert...', + 'Auf Syntaxfehler im Universum wird geprüft...', + 'Einen Moment, Humor wird optimiert...', + 'Pointen werden gemischt...', + 'Neuronale Netze werden entwirrt...', + 'Brillanz wird kompiliert...', + 'wit.exe wird geladen...', + 'Die Wolke der Weisheit wird beschworen...', + 'Eine witzige Antwort wird vorbereitet...', + 'Einen Moment, ich debugge die Realität...', + 'Die Optionen werden verwirrt...', + 'Kosmische Frequenzen werden eingestellt...', + 'Eine Antwort wird erstellt, die Ihrer Geduld würdig ist...', + 'Die Einsen und Nullen werden kompiliert...', + 'Abhängigkeiten werden aufgelöst... und existenzielle Krisen...', + 'Erinnerungen werden defragmentiert... sowohl RAM als auch persönliche...', + 'Das Humor-Modul wird neu gestartet...', + 'Das Wesentliche wird zwischengespeichert (hauptsächlich Katzen-Memes)...', + 'Für lächerliche Geschwindigkeit wird optimiert', + 'Bits werden getauscht... sagen Sie es nicht den Bytes...', + 'Garbage Collection läuft... bin gleich zurück...', + 'Das Internet wird zusammengebaut...', + 'Kaffee wird in Code umgewandelt...', + 'Die Syntax der Realität wird aktualisiert...', + 'Die Synapsen werden neu verdrahtet...', + 'Ein verlegtes Semikolon wird gesucht...', + 'Die Zahnräder werden geschmiert...', + 'Die Server werden vorgeheizt...', + 'Der Fluxkompensator wird kalibriert...', + 'Der Unwahrscheinlichkeitsantrieb wird aktiviert...', + 'Die Macht wird kanalisiert...', + 'Die Sterne werden für optimale Antwort ausgerichtet...', + 'So sagen wir alle...', + 'Die nächste große Idee wird geladen...', + 'Einen Moment, ich bin in der Zone...', + 'Bereite mich vor, Sie mit Brillanz zu blenden...', + 'Einen Augenblick, ich poliere meinen Witz...', + 'Halten Sie durch, ich erschaffe ein Meisterwerk...', + 'Einen Moment, ich debugge das Universum...', + 'Einen Moment, ich richte die Pixel aus...', + 'Einen Moment, ich optimiere den Humor...', + 'Einen Moment, ich tune die Algorithmen...', + 'Warp-Geschwindigkeit aktiviert...', + 'Mehr Dilithium-Kristalle werden gesucht...', + 'Keine Panik...', + 'Dem weißen Kaninchen wird gefolgt...', + 'Die Wahrheit ist hier drin... irgendwo...', + 'Auf die Kassette wird gepustet...', + 'Ladevorgang... Machen Sie eine Fassrolle!', + 'Auf den Respawn wird gewartet...', + 'Der Kessel-Flug wird in weniger als 12 Parsec beendet...', + 'Der Kuchen ist keine Lüge, er lädt nur noch...', + 'Am Charaktererstellungsbildschirm wird herumgefummelt...', + 'Einen Moment, ich suche das richtige Meme...', + "'A' wird zum Fortfahren gedrückt...", + 'Digitale Katzen werden gehütet...', + 'Die Pixel werden poliert...', + 'Ein passender Ladebildschirm-Witz wird gesucht...', + 'Ich lenke Sie mit diesem witzigen Spruch ab...', + 'Fast da... wahrscheinlich...', + 'Unsere Hamster arbeiten so schnell sie können...', + 'Cloudy wird am Kopf gestreichelt...', + 'Die Katze wird gestreichelt...', + 'Meinen Chef rickrollen...', + 'Never gonna give you up, never gonna let you down...', + 'Auf den Bass wird geschlagen...', + 'Die Schnozbeeren werden probiert...', + "I'm going the distance, I'm going for speed...", + 'Ist dies das wahre Leben? Ist dies nur Fantasie?...', + 'Ich habe ein gutes Gefühl dabei...', + 'Den Bären wird gestupst...', + 'Recherche zu den neuesten Memes...', + 'Überlege, wie ich das witziger machen kann...', + 'Hmmm... lassen Sie mich nachdenken...', + 'Wie nennt man einen Fisch ohne Augen? Ein Fsh...', + 'Warum ging der Computer zur Therapie? Er hatte zu viele Bytes...', + 'Warum mögen Programmierer keine Natur? Sie hat zu viele Bugs...', + 'Warum bevorzugen Programmierer den Dunkelmodus? Weil Licht Bugs anzieht...', + 'Warum ging der Entwickler pleite? Er hat seinen ganzen Cache aufgebraucht...', + 'Was kann man mit einem kaputten Bleistift machen? Nichts, er ist sinnlos...', + 'Perkussive Wartung wird angewendet...', + 'Die richtige USB-Ausrichtung wird gesucht...', + 'Es wird sichergestellt, dass der magische Rauch in den Kabeln bleibt...', + 'Versuche Vim zu beenden...', + 'Das Hamsterrad wird angeworfen...', + 'Das ist kein Bug, das ist ein undokumentiertes Feature...', + 'Engage.', + 'Ich komme wieder... mit einer Antwort.', + 'Mein anderer Prozess ist eine TARDIS...', + 'Mit dem Maschinengeist wird kommuniziert...', + 'Die Gedanken marinieren lassen...', + 'Gerade erinnert, wo ich meine Schlüssel hingelegt habe...', + 'Über die Kugel wird nachgedacht...', + 'Ich habe Dinge gesehen, die Sie nicht glauben würden... wie einen Benutzer, der Lademeldungen liest.', + 'Nachdenklicher Blick wird initiiert...', + 'Was ist der Lieblingssnack eines Computers? Mikrochips.', + 'Warum tragen Java-Entwickler Brillen? Weil sie nicht C#.', + 'Der Laser wird aufgeladen... pew pew!', + 'Durch Null wird geteilt... nur Spaß!', + 'Suche nach einem erwachsenen Aufseh... ich meine, Verarbeitung.', + 'Es piept und boopt.', + 'Pufferung... weil auch KIs einen Moment brauchen.', + 'Quantenteilchen werden für schnellere Antwort verschränkt...', + 'Das Chrom wird poliert... an den Algorithmen.', + 'Sind Sie nicht unterhalten? (Arbeite daran!)', + 'Die Code-Gremlins werden beschworen... zum Helfen, natürlich.', + 'Warte nur auf das Einwahlton-Ende...', + 'Das Humor-O-Meter wird neu kalibriert.', + 'Mein anderer Ladebildschirm ist noch lustiger.', + 'Ziemlich sicher, dass irgendwo eine Katze über die Tastatur läuft...', + 'Verbessern... Verbessern... Lädt noch.', + 'Das ist kein Bug, das ist ein Feature... dieses Ladebildschirms.', + 'Haben Sie versucht, es aus- und wieder einzuschalten? (Den Ladebildschirm, nicht mich.)', + 'Zusätzliche Pylonen werden gebaut...', + ], + + // ============================================================================ + // Extension Settings Input + // ============================================================================ + 'Enter value...': 'Wert eingeben...', + 'Enter sensitive value...': 'Sensiblen Wert eingeben...', + 'Press Enter to submit, Escape to cancel': + 'Enter zum Absenden, Escape zum Abbrechen drücken', + + // ============================================================================ + // Command Migration Tool + // ============================================================================ + 'Markdown file already exists: {{filename}}': + 'Markdown-Datei existiert bereits: {{filename}}', + 'TOML Command Format Deprecation Notice': + 'TOML-Befehlsformat Veraltet-Hinweis', + 'Found {{count}} command file(s) in TOML format:': + '{{count}} Befehlsdatei(en) im TOML-Format gefunden:', + 'The TOML format for commands is being deprecated in favor of Markdown format.': + 'Das TOML-Format für Befehle wird zugunsten des Markdown-Formats eingestellt.', + 'Markdown format is more readable and easier to edit.': + 'Das Markdown-Format ist lesbarer und einfacher zu bearbeiten.', + 'You can migrate these files automatically using:': + 'Sie können diese Dateien automatisch migrieren mit:', + 'Or manually convert each file:': 'Oder jede Datei manuell konvertieren:', + 'TOML: prompt = "..." / description = "..."': + 'TOML: prompt = "..." / description = "..."', + 'Markdown: YAML frontmatter + content': 'Markdown: YAML-Frontmatter + Inhalt', + 'The migration tool will:': 'Das Migrationstool wird:', + 'Convert TOML files to Markdown': 'TOML-Dateien in Markdown konvertieren', + 'Create backups of original files': + 'Sicherungen der Originaldateien erstellen', + 'Preserve all command functionality': 'Alle Befehlsfunktionen beibehalten', + 'TOML format will continue to work for now, but migration is recommended.': + 'Das TOML-Format funktioniert vorerst weiter, aber eine Migration wird empfohlen.', + + // ============================================================================ + // Extensions - Explore Command + // ============================================================================ + 'Open extensions page in your browser': 'Erweiterungsseite im Browser öffnen', + 'Unknown extensions source: {{source}}.': + 'Unbekannte Erweiterungsquelle: {{source}}.', + 'Would open extensions page in your browser: {{url}} (skipped in test environment)': + 'Würde Erweiterungsseite im Browser öffnen: {{url}} (übersprungen in Testumgebung)', + 'View available extensions at {{url}}': + 'Verfügbare Erweiterungen ansehen unter {{url}}', + 'Opening extensions page in your browser: {{url}}': + 'Erweiterungsseite wird im Browser geöffnet: {{url}}', + 'Failed to open browser. Check out the extensions gallery at {{url}}': + 'Browser konnte nicht geöffnet werden. Besuchen Sie die Erweiterungsgalerie unter {{url}}', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Verwenden Sie /compress, wenn die Unterhaltung lang wird, um den Verlauf zusammenzufassen und Kontext freizugeben.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'Starten Sie eine neue Idee mit /clear oder /new; die vorherige Sitzung bleibt im Verlauf verfügbar.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Verwenden Sie /bug, um Probleme an die Betreuer zu melden, wenn etwas schiefgeht.', + 'Switch auth type quickly with /auth.': + 'Wechseln Sie den Authentifizierungstyp schnell mit /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Sie können beliebige Shell-Befehle in Qwen Code mit ! ausführen (z. B. !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'Geben Sie / ein, um das Befehlsmenü zu öffnen; Tab vervollständigt Slash-Befehle und gespeicherte Prompts.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'Sie können eine frühere Unterhaltung mit qwen --continue oder qwen --resume fortsetzen.', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + 'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.', + 'You can switch permission mode quickly with Tab or /approval-mode.': + 'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.', + 'Try /insight to generate personalized insights from your chat history.': + 'Probieren Sie /insight, um personalisierte Erkenntnisse aus Ihrem Chatverlauf zu erstellen.', + + // ============================================================================ + // Custom API Key Configuration + // ============================================================================ + 'You can configure your API key and models in settings.json': + 'Sie können Ihren API-Schlüssel und Modelle in settings.json konfigurieren', + 'Refer to the documentation for setup instructions': + 'Einrichtungsanweisungen finden Sie in der Dokumentation', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'API key cannot be empty.': 'API-Schlüssel darf nicht leer sein.', + 'You can get your Coding Plan API key here': + 'Sie können Ihren Coding-Plan-API-Schlüssel hier erhalten', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'Neue Modellkonfigurationen sind für Alibaba Cloud Coding Plan verfügbar. Jetzt aktualisieren?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan API-Schlüssel nicht gefunden. Bitte authentifizieren Sie sich erneut mit Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Fehler beim Aktualisieren der Coding Plan-Konfiguration: {{message}}', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of ModelStudio Coding Plan and you're all set!": + 'Fügen Sie Ihren ModelStudio Coding Plan API-Schlüssel ein und Sie sind bereit!', + Custom: 'Benutzerdefiniert', + 'More instructions about configuring `modelProviders` manually.': + 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', + 'Select API-KEY configuration mode:': + 'API-KEY-Konfigurationsmodus auswählen:', + '(Press Escape to go back)': '(Escape drücken zum Zurückgehen)', + '(Press Enter to submit, Escape to cancel)': + '(Enter zum Absenden, Escape zum Abbrechen)', + 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', + 'Select Region for Coding Plan': 'Region für Coding Plan auswählen', + 'Choose based on where your account is registered': + 'Wählen Sie basierend auf dem Registrierungsort Ihres Kontos', + 'Enter Coding Plan API Key': 'Coding-Plan-API-Schlüssel eingeben', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Kontextnutzung', + 'No API response yet. Send a message to see actual usage.': + 'Noch keine API-Antwort. Senden Sie eine Nachricht, um die tatsächliche Nutzung anzuzeigen.', + 'Estimated pre-conversation overhead': + 'Geschätzte Vorabkosten vor der Unterhaltung', + 'Context window': 'Kontextfenster', + tokens: 'Tokens', + Used: 'Verwendet', + Free: 'Frei', + 'Autocompact buffer': 'Autokomprimierungs-Puffer', + 'Usage by category': 'Verwendung nach Kategorie', + 'System prompt': 'System-Prompt', + 'Built-in tools': 'Integrierte Tools', + 'MCP tools': 'MCP-Tools', + 'Memory files': 'Speicherdateien', + Skills: 'Fähigkeiten', + Messages: 'Nachrichten', + 'Show context window usage breakdown.': + 'Zeigt die Aufschlüsselung der Kontextfenster-Nutzung an.', + 'Run /context detail for per-item breakdown.': + 'Führen Sie /context detail für eine Aufschlüsselung nach Elementen aus.', + active: 'aktiv', + 'body loaded': 'Inhalt geladen', + memory: 'Speicher', + '{{region}} configuration updated successfully.': + '{{region}}-Konfiguration erfolgreich aktualisiert.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Tipp: Verwenden Sie /model, um zwischen verfügbaren Coding Plan-Modellen zu wechseln.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Bitte beantworten Sie die folgende(n) Frage(n):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Benutzerfragen können im nicht-interaktiven Modus nicht gestellt werden. Bitte führen Sie das Tool im interaktiven Modus aus.', + 'User declined to answer the questions.': + 'Benutzer hat die Beantwortung der Fragen abgelehnt.', + 'User has provided the following answers:': + 'Benutzer hat die folgenden Antworten bereitgestellt:', + 'Failed to process user answers:': + 'Fehler beim Verarbeiten der Benutzerantworten:', + 'Type something...': 'Etwas eingeben...', + Submit: 'Senden', + 'Submit answers': 'Antworten senden', + Cancel: 'Abbrechen', + 'Your answers:': 'Ihre Antworten:', + '(not answered)': '(nicht beantwortet)', + 'Ready to submit your answers?': 'Bereit, Ihre Antworten zu senden?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navigieren | ←/→: Tabs wechseln | Enter: Auswählen', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigieren | ←/→: Tabs wechseln | Space/Enter: Umschalten | Esc: Abbrechen', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Authenticate using Alibaba Cloud Coding Plan': + 'Mit Alibaba Cloud Coding Plan authentifizieren', + 'Region for Coding Plan (china/global)': + 'Region für Coding Plan (china/global)', + 'API key for Coding Plan': 'API-Schlüssel für Coding Plan', + 'Show current authentication status': + 'Aktuellen Authentifizierungsstatus anzeigen', + 'Authentication completed successfully.': + 'Authentifizierung erfolgreich abgeschlossen.', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Alibaba Cloud Coding Plan-Authentifizierung wird verarbeitet...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Erfolgreich mit Alibaba Cloud Coding Plan authentifiziert.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Authentifizierung mit Coding Plan fehlgeschlagen: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Global', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Region für Coding Plan auswählen:', + 'Enter your Coding Plan API key: ': + 'Geben Sie Ihren Coding Plan API-Schlüssel ein: ', + 'Select authentication method:': 'Authentifizierungsmethode auswählen:', + '\n=== Authentication Status ===\n': '\n=== Authentifizierungsstatus ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ Keine Authentifizierungsmethode konfiguriert.\n', + 'Run one of the following commands to get started:\n': + 'Führen Sie einen der folgenden Befehle aus, um zu beginnen:\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Mit Alibaba Cloud Coding Plan authentifizieren\n', + 'Or simply run:': 'Oder einfach ausführen:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Interaktive Authentifizierungseinrichtung\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Authentifizierungsmethode: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Global - Alibaba Cloud', + ' Region: {{region}}': ' Region: {{region}}', + ' Current Model: {{model}}': ' Aktuelles Modell: {{model}}', + ' Config Version: {{version}}': ' Konfigurationsversion: {{version}}', + ' Status: API key configured\n': ' Status: API-Schlüssel konfiguriert\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Authentifizierungsmethode: Alibaba Cloud Coding Plan (Unvollständig)', + ' Issue: API key not found in environment or settings\n': + ' Problem: API-Schlüssel nicht in Umgebung oder Einstellungen gefunden\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Führen Sie `qwen auth coding-plan` aus, um neu zu konfigurieren.\n', + '✓ Authentication Method: {{type}}': '✓ Authentifizierungsmethode: {{type}}', + ' Status: Configured\n': ' Status: Konfiguriert\n', + 'Failed to check authentication status: {{error}}': + 'Authentifizierungsstatus konnte nicht überprüft werden: {{error}}', + 'Select an option:': 'Option auswählen:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n', + verbose: 'ausführlich', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Vollständige Tool-Ausgabe und Denkprozess im ausführlichen Modus anzeigen (mit Strg+O umschalten).', + 'Press Ctrl+O to show full tool output': + 'Strg+O für vollständige Tool-Ausgabe drücken', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', + + "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", +}; diff --git a/apps/airiscode-cli/src/i18n/locales/en.js b/apps/airiscode-cli/src/i18n/locales/en.js new file mode 100644 index 000000000..a256c3a65 --- /dev/null +++ b/apps/airiscode-cli/src/i18n/locales/en.js @@ -0,0 +1,1999 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// English translations for Qwen Code CLI +// The key serves as both the translation key and the default English text + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ to manage attachments', + '← → select, Delete to remove, ↓ to exit': + '← → select, Delete to remove, ↓ to exit', + 'Attachments: ': 'Attachments: ', + + 'Basics:': 'Basics:', + 'Add context': 'Add context', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Shell mode', + 'YOLO mode': 'YOLO mode', + 'plan mode': 'plan mode', + 'auto-accept edits': 'auto-accept edits', + 'Accepting edits': 'Accepting edits', + '(shift + tab to cycle)': '(shift + tab to cycle)', + '(tab to cycle)': '(tab to cycle)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'Commands:', + 'shell command': 'shell command', + 'Model Context Protocol command (from external servers)': + 'Model Context Protocol command (from external servers)', + 'Keyboard Shortcuts:': 'Keyboard Shortcuts:', + 'Toggle this help display': 'Toggle this help display', + 'Toggle shell mode': 'Toggle shell mode', + 'Open command menu': 'Open command menu', + 'Add file context': 'Add file context', + 'Accept suggestion / Autocomplete': 'Accept suggestion / Autocomplete', + 'Reverse search history': 'Reverse search history', + 'Press ? again to close': 'Press ? again to close', + // Keyboard shortcuts panel descriptions + 'for shell mode': 'for shell mode', + 'for commands': 'for commands', + 'for file paths': 'for file paths', + 'to clear input': 'to clear input', + 'to cycle approvals': 'to cycle approvals', + 'to quit': 'to quit', + 'for newline': 'for newline', + 'to clear screen': 'to clear screen', + 'to search history': 'to search history', + 'to paste images': 'to paste images', + 'for external editor': 'for external editor', + 'Jump through words in the input': 'Jump through words in the input', + 'Close dialogs, cancel requests, or quit application': + 'Close dialogs, cancel requests, or quit application', + 'New line': 'New line', + 'New line (Alt+Enter works for certain linux distros)': + 'New line (Alt+Enter works for certain linux distros)', + 'Clear the screen': 'Clear the screen', + 'Open input in external editor': 'Open input in external editor', + 'Send message': 'Send message', + 'Initializing...': 'Initializing...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Connecting to MCP servers... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'Type your message or @path/to/file', + '? for shortcuts': '? for shortcuts', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.", + 'Cancel operation / Clear input (double press)': + 'Cancel operation / Clear input (double press)', + 'Cycle approval modes': 'Cycle approval modes', + 'Cycle through your prompt history': 'Cycle through your prompt history', + 'For a full list of shortcuts, see {{docPath}}': + 'For a full list of shortcuts, see {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'for help on Qwen Code', + 'show version info': 'show version info', + 'submit a bug report': 'submit a bug report', + 'About Qwen Code': 'About Qwen Code', + Status: 'Status', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'Qwen Code': 'Qwen Code', + Runtime: 'Runtime', + OS: 'OS', + Auth: 'Auth', + 'CLI Version': 'CLI Version', + 'Git Commit': 'Git Commit', + Model: 'Model', + 'Fast Model': 'Fast Model', + Sandbox: 'Sandbox', + 'OS Platform': 'OS Platform', + 'OS Arch': 'OS Arch', + 'OS Release': 'OS Release', + 'Node.js Version': 'Node.js Version', + 'NPM Version': 'NPM Version', + 'Session ID': 'Session ID', + 'Auth Method': 'Auth Method', + 'Base URL': 'Base URL', + Proxy: 'Proxy', + 'Memory Usage': 'Memory Usage', + 'IDE Client': 'IDE Client', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Analyzes the project and creates a tailored QWEN.md file.', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]', + 'List available skills.': 'List available skills.', + 'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:', + 'No tools available': 'No tools available', + 'View or change the approval mode for tool usage': + 'View or change the approval mode for tool usage', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}', + 'Approval mode set to "{{mode}}"': 'Approval mode set to "{{mode}}"', + 'View or change the language setting': 'View or change the language setting', + 'change the theme': 'change the theme', + 'Select Theme': 'Select Theme', + Preview: 'Preview', + '(Use Enter to select, Tab to configure scope)': + '(Use Enter to select, Tab to configure scope)', + '(Use Enter to apply scope, Tab to go back)': + '(Use Enter to apply scope, Tab to go back)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Theme configuration unavailable due to NO_COLOR env variable.', + 'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Theme "{{themeName}}" not found in selected scope.', + 'Clear conversation history and free up context': + 'Clear conversation history and free up context', + 'Compresses the context by replacing it with a summary.': + 'Compresses the context by replacing it with a summary.', + 'open full Qwen Code documentation in your browser': + 'open full Qwen Code documentation in your browser', + 'Configuration not available.': 'Configuration not available.', + 'change the auth method': 'change the auth method', + 'Configure authentication information for login': + 'Configure authentication information for login', + 'Copy the last result or code snippet to clipboard': + 'Copy the last result or code snippet to clipboard', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Manage subagents for specialized task delegation.', + 'Manage existing subagents (view, edit, delete).': + 'Manage existing subagents (view, edit, delete).', + 'Create a new subagent with guided setup.': + 'Create a new subagent with guided setup.', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: 'Agents', + 'Choose Action': 'Choose Action', + 'Edit {{name}}': 'Edit {{name}}', + 'Edit Tools: {{name}}': 'Edit Tools: {{name}}', + 'Edit Color: {{name}}': 'Edit Color: {{name}}', + 'Delete {{name}}': 'Delete {{name}}', + 'Unknown Step': 'Unknown Step', + 'Esc to close': 'Esc to close', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter to select, ↑↓ to navigate, Esc to close', + 'Esc to go back': 'Esc to go back', + 'Enter to confirm, Esc to cancel': 'Enter to confirm, Esc to cancel', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter to select, ↑↓ to navigate, Esc to go back', + 'Enter to submit, Esc to go back': 'Enter to submit, Esc to go back', + 'Invalid step: {{step}}': 'Invalid step: {{step}}', + 'No subagents found.': 'No subagents found.', + "Use '/agents create' to create your first subagent.": + "Use '/agents create' to create your first subagent.", + '(built-in)': '(built-in)', + '(overridden by project level agent)': '(overridden by project level agent)', + 'Project Level ({{path}})': 'Project Level ({{path}})', + 'User Level ({{path}})': 'User Level ({{path}})', + 'Built-in Agents': 'Built-in Agents', + 'Extension Agents': 'Extension Agents', + 'Using: {{count}} agents': 'Using: {{count}} agents', + 'View Agent': 'View Agent', + 'Edit Agent': 'Edit Agent', + 'Delete Agent': 'Delete Agent', + Back: 'Back', + 'No agent selected': 'No agent selected', + 'File Path: ': 'File Path: ', + 'Tools: ': 'Tools: ', + 'Color: ': 'Color: ', + 'Description:': 'Description:', + 'System Prompt:': 'System Prompt:', + 'Open in editor': 'Open in editor', + 'Edit tools': 'Edit tools', + 'Edit color': 'Edit color', + '❌ Error:': '❌ Error:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Are you sure you want to delete agent "{{name}}"?', + // ============================================================================ + // Agents - Creation Wizard + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Project Level (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'User Level (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Subagent Created Successfully!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Subagent "{{name}}" has been saved to {{level}} level.', + 'Name: ': 'Name: ', + 'Location: ': 'Location: ', + '❌ Error saving subagent:': '❌ Error saving subagent:', + 'Warnings:': 'Warnings:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Name "{{name}}" exists at user level - project level will take precedence', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Name "{{name}}" exists at project level - existing subagent will take precedence', + 'Description is over {{length}} characters': + 'Description is over {{length}} characters', + 'System prompt is over {{length}} characters': + 'System prompt is over {{length}} characters', + // Agents - Creation Wizard Steps + 'Step {{n}}: Choose Location': 'Step {{n}}: Choose Location', + 'Step {{n}}: Choose Generation Method': + 'Step {{n}}: Choose Generation Method', + 'Generate with Qwen Code (Recommended)': + 'Generate with Qwen Code (Recommended)', + 'Manual Creation': 'Manual Creation', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'e.g., Expert code reviewer that reviews code based on best practices...', + 'Generating subagent configuration...': + 'Generating subagent configuration...', + 'Failed to generate subagent: {{error}}': + 'Failed to generate subagent: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Step {{n}}: Describe Your Subagent', + 'Step {{n}}: Enter Subagent Name': 'Step {{n}}: Enter Subagent Name', + 'Step {{n}}: Enter System Prompt': 'Step {{n}}: Enter System Prompt', + 'Step {{n}}: Enter Description': 'Step {{n}}: Enter Description', + // Agents - Tool Selection + 'Step {{n}}: Select Tools': 'Step {{n}}: Select Tools', + 'All Tools (Default)': 'All Tools (Default)', + 'All Tools': 'All Tools', + 'Read-only Tools': 'Read-only Tools', + 'Read & Edit Tools': 'Read & Edit Tools', + 'Read & Edit & Execution Tools': 'Read & Edit & Execution Tools', + 'All tools selected, including MCP tools': + 'All tools selected, including MCP tools', + 'Selected tools:': 'Selected tools:', + 'Read-only tools:': 'Read-only tools:', + 'Edit tools:': 'Edit tools:', + 'Execution tools:': 'Execution tools:', + 'Step {{n}}: Choose Background Color': 'Step {{n}}: Choose Background Color', + 'Step {{n}}: Confirm and Save': 'Step {{n}}: Confirm and Save', + // Agents - Navigation & Instructions + 'Esc to cancel': 'Esc to cancel', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Press Enter to save, e to save and edit, Esc to go back', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Press Enter to continue, {{navigation}}Esc to {{action}}', + cancel: 'cancel', + 'go back': 'go back', + '↑↓ to navigate, ': '↑↓ to navigate, ', + 'Enter a clear, unique name for this subagent.': + 'Enter a clear, unique name for this subagent.', + 'e.g., Code Reviewer': 'e.g., Code Reviewer', + 'Name cannot be empty.': 'Name cannot be empty.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.", + 'e.g., You are an expert code reviewer...': + 'e.g., You are an expert code reviewer...', + 'System prompt cannot be empty.': 'System prompt cannot be empty.', + 'Describe when and how this subagent should be used.': + 'Describe when and how this subagent should be used.', + 'e.g., Reviews code for best practices and potential bugs.': + 'e.g., Reviews code for best practices and potential bugs.', + 'Description cannot be empty.': 'Description cannot be empty.', + 'Failed to launch editor: {{error}}': 'Failed to launch editor: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Failed to save and edit subagent: {{error}}', + + // ============================================================================ + // Extensions - Management Dialog + // ============================================================================ + 'Manage Extensions': 'Manage Extensions', + 'Extension Details': 'Extension Details', + 'View Extension': 'View Extension', + 'Update Extension': 'Update Extension', + 'Disable Extension': 'Disable Extension', + 'Enable Extension': 'Enable Extension', + 'Uninstall Extension': 'Uninstall Extension', + 'Select Scope': 'Select Scope', + 'User Scope': 'User Scope', + 'Workspace Scope': 'Workspace Scope', + 'No extensions found.': 'No extensions found.', + Active: 'Active', + Disabled: 'Disabled', + 'Update available': 'Update available', + 'Up to date': 'Up to date', + 'Checking...': 'Checking...', + 'Updating...': 'Updating...', + Unknown: 'Unknown', + Error: 'Error', + 'Version:': 'Version:', + 'Status:': 'Status:', + 'Are you sure you want to uninstall extension "{{name}}"?': + 'Are you sure you want to uninstall extension "{{name}}"?', + 'This action cannot be undone.': 'This action cannot be undone.', + 'Extension "{{name}}" disabled successfully.': + 'Extension "{{name}}" disabled successfully.', + 'Extension "{{name}}" enabled successfully.': + 'Extension "{{name}}" enabled successfully.', + 'Extension "{{name}}" updated successfully.': + 'Extension "{{name}}" updated successfully.', + 'Failed to update extension "{{name}}": {{error}}': + 'Failed to update extension "{{name}}": {{error}}', + 'Select the scope for this action:': 'Select the scope for this action:', + 'User - Applies to all projects': 'User - Applies to all projects', + 'Workspace - Applies to current project only': + 'Workspace - Applies to current project only', + // Extension dialog - missing keys + 'Name:': 'Name:', + 'MCP Servers:': 'MCP Servers:', + 'Settings:': 'Settings:', + active: 'active', + disabled: 'disabled', + 'View Details': 'View Details', + 'Update failed:': 'Update failed:', + 'Updating {{name}}...': 'Updating {{name}}...', + 'Update complete!': 'Update complete!', + 'User (global)': 'User (global)', + 'Workspace (project-specific)': 'Workspace (project-specific)', + 'Disable "{{name}}" - Select Scope': 'Disable "{{name}}" - Select Scope', + 'Enable "{{name}}" - Select Scope': 'Enable "{{name}}" - Select Scope', + 'No extension selected': 'No extension selected', + 'Press Y/Enter to confirm, N/Esc to cancel': + 'Press Y/Enter to confirm, N/Esc to cancel', + 'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter to confirm, N/Esc to cancel', + '{{count}} extensions installed': '{{count}} extensions installed', + "Use '/extensions install' to install your first extension.": + "Use '/extensions install' to install your first extension.", + // Update status values + 'up to date': 'up to date', + 'update available': 'update available', + 'checking...': 'checking...', + 'not updatable': 'not updatable', + error: 'error', + + // ============================================================================ + // Commands - General (continued) + // ============================================================================ + 'View and edit Qwen Code settings': 'View and edit Qwen Code settings', + Settings: 'Settings', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.', + 'The command "/{{command}}" is not supported in non-interactive mode.': + 'The command "/{{command}}" is not supported in non-interactive mode.', + // ============================================================================ + // Settings Labels + // ============================================================================ + 'Vim Mode': 'Vim Mode', + 'Disable Auto Update': 'Disable Auto Update', + 'Attribution: commit': 'Attribution: commit', + 'Terminal Bell Notification': 'Terminal Bell Notification', + 'Enable Usage Statistics': 'Enable Usage Statistics', + Theme: 'Theme', + 'Preferred Editor': 'Preferred Editor', + 'Auto-connect to IDE': 'Auto-connect to IDE', + 'Enable Prompt Completion': 'Enable Prompt Completion', + 'Debug Keystroke Logging': 'Debug Keystroke Logging', + 'Language: UI': 'Language: UI', + 'Language: Model': 'Language: Model', + 'Output Format': 'Output Format', + 'Hide Window Title': 'Hide Window Title', + 'Show Status in Title': 'Show Status in Title', + 'Hide Tips': 'Hide Tips', + 'Show Line Numbers in Code': 'Show Line Numbers in Code', + 'Show Citations': 'Show Citations', + 'Custom Witty Phrases': 'Custom Witty Phrases', + 'Show Welcome Back Dialog': 'Show Welcome Back Dialog', + 'Enable User Feedback': 'Enable User Feedback', + 'How is Qwen doing this session? (optional)': + 'How is Qwen doing this session? (optional)', + Bad: 'Bad', + Fine: 'Fine', + Good: 'Good', + Dismiss: 'Dismiss', + 'Not Sure Yet': 'Not Sure Yet', + 'Any other key': 'Any other key', + 'Disable Loading Phrases': 'Disable Loading Phrases', + 'Screen Reader Mode': 'Screen Reader Mode', + 'IDE Mode': 'IDE Mode', + 'Max Session Turns': 'Max Session Turns', + 'Skip Next Speaker Check': 'Skip Next Speaker Check', + 'Skip Loop Detection': 'Skip Loop Detection', + 'Skip Startup Context': 'Skip Startup Context', + 'Enable OpenAI Logging': 'Enable OpenAI Logging', + 'OpenAI Logging Directory': 'OpenAI Logging Directory', + Timeout: 'Timeout', + 'Max Retries': 'Max Retries', + 'Disable Cache Control': 'Disable Cache Control', + 'Memory Discovery Max Dirs': 'Memory Discovery Max Dirs', + 'Load Memory From Include Directories': + 'Load Memory From Include Directories', + 'Respect .gitignore': 'Respect .gitignore', + 'Respect .qwenignore': 'Respect .qwenignore', + 'Enable Recursive File Search': 'Enable Recursive File Search', + 'Disable Fuzzy Search': 'Disable Fuzzy Search', + 'Interactive Shell (PTY)': 'Interactive Shell (PTY)', + 'Show Color': 'Show Color', + 'Auto Accept': 'Auto Accept', + 'Use Ripgrep': 'Use Ripgrep', + 'Use Builtin Ripgrep': 'Use Builtin Ripgrep', + 'Enable Tool Output Truncation': 'Enable Tool Output Truncation', + 'Tool Output Truncation Threshold': 'Tool Output Truncation Threshold', + 'Tool Output Truncation Lines': 'Tool Output Truncation Lines', + 'Folder Trust': 'Folder Trust', + 'Vision Model Preview': 'Vision Model Preview', + 'Tool Schema Compliance': 'Tool Schema Compliance', + // Settings enum options + 'Auto (detect from system)': 'Auto (detect from system)', + Text: 'Text', + JSON: 'JSON', + Plan: 'Plan', + Default: 'Default', + 'Auto Edit': 'Auto Edit', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'toggle vim mode on/off', + 'check session stats. Usage: /stats [model|tools]': + 'check session stats. Usage: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Show model-specific usage statistics.', + 'Show tool-specific usage statistics.': + 'Show tool-specific usage statistics.', + 'exit the cli': 'exit the cli', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers', + 'Manage workspace directories': 'Manage workspace directories', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Add directories to the workspace. Use comma to separate multiple paths', + 'Show all directories in the workspace': + 'Show all directories in the workspace', + 'set external editor preference': 'set external editor preference', + 'Select Editor': 'Select Editor', + 'Editor Preference': 'Editor Preference', + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.': + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.', + 'Your preferred editor is:': 'Your preferred editor is:', + 'Manage extensions': 'Manage extensions', + 'Manage installed extensions': 'Manage installed extensions', + 'List active extensions': 'List active extensions', + 'Update extensions. Usage: update |--all': + 'Update extensions. Usage: update |--all', + 'Disable an extension': 'Disable an extension', + 'Enable an extension': 'Enable an extension', + 'Install an extension from a git repo or local path': + 'Install an extension from a git repo or local path', + 'Uninstall an extension': 'Uninstall an extension', + 'No extensions installed.': 'No extensions installed.', + 'Usage: /extensions update |--all': + 'Usage: /extensions update |--all', + 'Extension "{{name}}" not found.': 'Extension "{{name}}" not found.', + 'No extensions to update.': 'No extensions to update.', + 'Usage: /extensions install ': 'Usage: /extensions install ', + 'Installing extension from "{{source}}"...': + 'Installing extension from "{{source}}"...', + 'Extension "{{name}}" installed successfully.': + 'Extension "{{name}}" installed successfully.', + 'Failed to install extension from "{{source}}": {{error}}': + 'Failed to install extension from "{{source}}": {{error}}', + 'Usage: /extensions uninstall ': + 'Usage: /extensions uninstall ', + 'Uninstalling extension "{{name}}"...': + 'Uninstalling extension "{{name}}"...', + 'Extension "{{name}}" uninstalled successfully.': + 'Extension "{{name}}" uninstalled successfully.', + 'Failed to uninstall extension "{{name}}": {{error}}': + 'Failed to uninstall extension "{{name}}": {{error}}', + 'Usage: /extensions {{command}} [--scope=]': + 'Usage: /extensions {{command}} [--scope=]', + 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"': + 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"', + 'Extension "{{name}}" disabled for scope "{{scope}}"': + 'Extension "{{name}}" disabled for scope "{{scope}}"', + 'Extension "{{name}}" enabled for scope "{{scope}}"': + 'Extension "{{name}}" enabled for scope "{{scope}}"', + 'Do you want to continue? [Y/n]: ': 'Do you want to continue? [Y/n]: ', + 'Do you want to continue?': 'Do you want to continue?', + 'Installing extension "{{name}}".': 'Installing extension "{{name}}".', + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**': + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**', + 'This extension will run the following MCP servers:': + 'This extension will run the following MCP servers:', + local: 'local', + remote: 'remote', + 'This extension will add the following commands: {{commands}}.': + 'This extension will add the following commands: {{commands}}.', + 'This extension will append info to your QWEN.md context using {{fileName}}': + 'This extension will append info to your QWEN.md context using {{fileName}}', + 'This extension will exclude the following core tools: {{tools}}': + 'This extension will exclude the following core tools: {{tools}}', + 'This extension will install the following skills:': + 'This extension will install the following skills:', + 'This extension will install the following subagents:': + 'This extension will install the following subagents:', + 'Installation cancelled for "{{name}}".': + 'Installation cancelled for "{{name}}".', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.', + '--ref and --auto-update are not applicable for marketplace extensions.': + '--ref and --auto-update are not applicable for marketplace extensions.', + 'Extension "{{name}}" installed successfully and enabled.': + 'Extension "{{name}}" installed successfully and enabled.', + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).': + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).', + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.', + 'The git ref to install from.': 'The git ref to install from.', + 'Enable auto-update for this extension.': + 'Enable auto-update for this extension.', + 'Enable pre-release versions for this extension.': + 'Enable pre-release versions for this extension.', + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', + 'The source argument must be provided.': + 'The source argument must be provided.', + 'Extension "{{name}}" successfully uninstalled.': + 'Extension "{{name}}" successfully uninstalled.', + 'Uninstalls an extension.': 'Uninstalls an extension.', + 'The name or source path of the extension to uninstall.': + 'The name or source path of the extension to uninstall.', + 'Please include the name of the extension to uninstall as a positional argument.': + 'Please include the name of the extension to uninstall as a positional argument.', + 'Enables an extension.': 'Enables an extension.', + 'The name of the extension to enable.': + 'The name of the extension to enable.', + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.': + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.', + 'Extension "{{name}}" successfully enabled for scope "{{scope}}".': + 'Extension "{{name}}" successfully enabled for scope "{{scope}}".', + 'Extension "{{name}}" successfully enabled in all scopes.': + 'Extension "{{name}}" successfully enabled in all scopes.', + 'Invalid scope: {{scope}}. Please use one of {{scopes}}.': + 'Invalid scope: {{scope}}. Please use one of {{scopes}}.', + 'Disables an extension.': 'Disables an extension.', + 'The name of the extension to disable.': + 'The name of the extension to disable.', + 'The scope to disable the extenison in.': + 'The scope to disable the extenison in.', + 'Extension "{{name}}" successfully disabled for scope "{{scope}}".': + 'Extension "{{name}}" successfully disabled for scope "{{scope}}".', + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.': + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.', + 'Unable to install extension "{{name}}" due to missing install metadata': + 'Unable to install extension "{{name}}" due to missing install metadata', + 'Extension "{{name}}" is already up to date.': + 'Extension "{{name}}" is already up to date.', + 'Updates all extensions or a named extension to the latest version.': + 'Updates all extensions or a named extension to the latest version.', + 'Update all extensions.': 'Update all extensions.', + 'Either an extension name or --all must be provided': + 'Either an extension name or --all must be provided', + 'Lists installed extensions.': 'Lists installed extensions.', + 'Path:': 'Path:', + 'Source:': 'Source:', + 'Type:': 'Type:', + 'Ref:': 'Ref:', + 'Release tag:': 'Release tag:', + 'Enabled (User):': 'Enabled (User):', + 'Enabled (Workspace):': 'Enabled (Workspace):', + 'Context files:': 'Context files:', + 'Skills:': 'Skills:', + 'Agents:': 'Agents:', + 'MCP servers:': 'MCP servers:', + 'Link extension failed to install.': 'Link extension failed to install.', + 'Extension "{{name}}" linked successfully and enabled.': + 'Extension "{{name}}" linked successfully and enabled.', + 'Links an extension from a local path. Updates made to the local path will always be reflected.': + 'Links an extension from a local path. Updates made to the local path will always be reflected.', + 'The name of the extension to link.': 'The name of the extension to link.', + 'Set a specific setting for an extension.': + 'Set a specific setting for an extension.', + 'Name of the extension to configure.': 'Name of the extension to configure.', + 'The setting to configure (name or env var).': + 'The setting to configure (name or env var).', + 'The scope to set the setting in.': 'The scope to set the setting in.', + 'List all settings for an extension.': 'List all settings for an extension.', + 'Name of the extension.': 'Name of the extension.', + 'Extension "{{name}}" has no settings to configure.': + 'Extension "{{name}}" has no settings to configure.', + 'Settings for "{{name}}":': 'Settings for "{{name}}":', + '(workspace)': '(workspace)', + '(user)': '(user)', + '[not set]': '[not set]', + '[value stored in keychain]': '[value stored in keychain]', + 'Value:': 'Value:', + 'Manage extension settings.': 'Manage extension settings.', + 'You need to specify a command (set or list).': + 'You need to specify a command (set or list).', + // ============================================================================ + // Plugin Choice / Marketplace + // ============================================================================ + 'No plugins available in this marketplace.': + 'No plugins available in this marketplace.', + 'Select a plugin to install from marketplace "{{name}}":': + 'Select a plugin to install from marketplace "{{name}}":', + 'Plugin selection cancelled.': 'Plugin selection cancelled.', + 'Select a plugin from "{{name}}"': 'Select a plugin from "{{name}}"', + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel', + '{{count}} more above': '{{count}} more above', + '{{count}} more below': '{{count}} more below', + 'manage IDE integration': 'manage IDE integration', + 'check status of IDE integration': 'check status of IDE integration', + 'install required IDE companion for {{ideName}}': + 'install required IDE companion for {{ideName}}', + 'enable IDE integration': 'enable IDE integration', + 'disable IDE integration': 'disable IDE integration', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.', + 'Set up GitHub Actions': 'Set up GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Please restart your terminal for the changes to take effect.', + 'Failed to configure terminal: {{error}}': + 'Failed to configure terminal: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.', + 'File: {{file}}': 'File: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.', + 'Error: {{error}}': 'Error: {{error}}', + 'Shift+Enter binding already exists': 'Shift+Enter binding already exists', + 'Ctrl+Enter binding already exists': 'Ctrl+Enter binding already exists', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Existing keybindings detected. Will not modify to avoid conflicts.', + 'Please check and modify manually if needed: {{file}}': + 'Please check and modify manually if needed: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.', + 'Modified: {{file}}': 'Modified: {{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}} keybindings already configured.', + 'Failed to configure {{terminalName}}.': + 'Failed to configure {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Manage Qwen Code hooks', + 'List all configured hooks': 'List all configured hooks', + 'Enable a disabled hook': 'Enable a disabled hook', + 'Disable an active hook': 'Disable an active hook', + // Hooks - Dialog + Hooks: 'Hooks', + 'Loading hooks...': 'Loading hooks...', + 'Error loading hooks:': 'Error loading hooks:', + 'Press Escape to close': 'Press Escape to close', + 'Press Escape, Ctrl+C, or Ctrl+D to cancel': + 'Press Escape, Ctrl+C, or Ctrl+D to cancel', + 'Press Space, Enter, or Escape to dismiss': + 'Press Space, Enter, or Escape to dismiss', + 'No hook selected': 'No hook selected', + // Hooks - List Step + 'No hook events found.': 'No hook events found.', + '{{count}} hook configured': '{{count}} hook configured', + '{{count}} hooks configured': '{{count}} hooks configured', + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.': + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.', + 'Enter to select · Esc to cancel': 'Enter to select · Esc to cancel', + // Hooks - Detail Step + 'Exit codes:': 'Exit codes:', + 'Configured hooks:': 'Configured hooks:', + 'No hooks configured for this event.': 'No hooks configured for this event.', + 'To add hooks, edit settings.json directly or ask Qwen.': + 'To add hooks, edit settings.json directly or ask Qwen.', + 'Enter to select · Esc to go back': 'Enter to select · Esc to go back', + // Hooks - Config Detail Step + 'Hook details': 'Hook details', + 'Event:': 'Event:', + 'Extension:': 'Extension:', + 'Desc:': 'Desc:', + 'No hook config selected': 'No hook config selected', + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.': + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.', + // Hooks - Disabled Step + 'Hook Configuration - Disabled': 'Hook Configuration - Disabled', + 'All hooks are currently disabled. You have {{count}} that are not running.': + 'All hooks are currently disabled. You have {{count}} that are not running.', + '{{count}} configured hook': '{{count}} configured hook', + '{{count}} configured hooks': '{{count}} configured hooks', + 'When hooks are disabled:': 'When hooks are disabled:', + 'No hook commands will execute': 'No hook commands will execute', + 'StatusLine will not be displayed': 'StatusLine will not be displayed', + 'Tool operations will proceed without hook validation': + 'Tool operations will proceed without hook validation', + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.': + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.', + // Hooks - Source + Project: 'Project', + User: 'User', + System: 'System', + Extension: 'Extension', + 'Local Settings': 'Local Settings', + 'User Settings': 'User Settings', + 'System Settings': 'System Settings', + Extensions: 'Extensions', + // Hooks - Status + '✓ Enabled': '✓ Enabled', + '✗ Disabled': '✗ Disabled', + // Hooks - Event Descriptions (short) + 'Before tool execution': 'Before tool execution', + 'After tool execution': 'After tool execution', + 'After tool execution fails': 'After tool execution fails', + 'When notifications are sent': 'When notifications are sent', + 'When the user submits a prompt': 'When the user submits a prompt', + 'When a new session is started': 'When a new session is started', + 'Right before Qwen Code concludes its response': + 'Right before Qwen Code concludes its response', + 'When a subagent (Agent tool call) is started': + 'When a subagent (Agent tool call) is started', + 'Right before a subagent concludes its response': + 'Right before a subagent concludes its response', + 'Before conversation compaction': 'Before conversation compaction', + 'When a session is ending': 'When a session is ending', + 'When a permission dialog is displayed': + 'When a permission dialog is displayed', + // Hooks - Event Descriptions (detailed) + 'Input to command is JSON of tool call arguments.': + 'Input to command is JSON of tool call arguments.', + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).': + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).', + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.': + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.', + 'Input to command is JSON with notification message and type.': + 'Input to command is JSON with notification message and type.', + 'Input to command is JSON with original user prompt text.': + 'Input to command is JSON with original user prompt text.', + 'Input to command is JSON with session start source.': + 'Input to command is JSON with session start source.', + 'Input to command is JSON with session end reason.': + 'Input to command is JSON with session end reason.', + 'Input to command is JSON with agent_id and agent_type.': + 'Input to command is JSON with agent_id and agent_type.', + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.': + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.', + 'Input to command is JSON with compaction details.': + 'Input to command is JSON with compaction details.', + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.': + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.', + // Hooks - Exit Code Descriptions + 'stdout/stderr not shown': 'stdout/stderr not shown', + 'show stderr to model and continue conversation': + 'show stderr to model and continue conversation', + 'show stderr to user only': 'show stderr to user only', + 'stdout shown in transcript mode (ctrl+o)': + 'stdout shown in transcript mode (ctrl+o)', + 'show stderr to model immediately': 'show stderr to model immediately', + 'show stderr to user only but continue with tool call': + 'show stderr to user only but continue with tool call', + 'block processing, erase original prompt, and show stderr to user only': + 'block processing, erase original prompt, and show stderr to user only', + 'stdout shown to Qwen': 'stdout shown to Qwen', + 'show stderr to user only (blocking errors ignored)': + 'show stderr to user only (blocking errors ignored)', + 'command completes successfully': 'command completes successfully', + 'stdout shown to subagent': 'stdout shown to subagent', + 'show stderr to subagent and continue having it run': + 'show stderr to subagent and continue having it run', + 'stdout appended as custom compact instructions': + 'stdout appended as custom compact instructions', + 'block compaction': 'block compaction', + 'show stderr to user only but continue with compaction': + 'show stderr to user only but continue with compaction', + 'use hook decision if provided': 'use hook decision if provided', + // Hooks - Messages + 'Config not loaded.': 'Config not loaded.', + 'Hooks are not enabled. Enable hooks in settings to use this feature.': + 'Hooks are not enabled. Enable hooks in settings to use this feature.', + 'No hooks configured. Add hooks in your settings.json file.': + 'No hooks configured. Add hooks in your settings.json file.', + 'Configured Hooks ({{count}} total)': 'Configured Hooks ({{count}} total)', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Export current session message history to a file', + 'Export session to HTML format': 'Export session to HTML format', + 'Export session to JSON format': 'Export session to JSON format', + 'Export session to JSONL format (one message per line)': + 'Export session to JSONL format (one message per line)', + 'Export session to markdown format': 'Export session to markdown format', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'generate personalized programming insights from your chat history', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Resume a previous session', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Terminal "{{terminal}}" is not supported yet.', + + // ============================================================================ + // Commands - Language + // ============================================================================ + 'Invalid language. Available: {{options}}': + 'Invalid language. Available: {{options}}', + 'Language subcommands do not accept additional arguments.': + 'Language subcommands do not accept additional arguments.', + 'Current UI language: {{lang}}': 'Current UI language: {{lang}}', + 'Current LLM output language: {{lang}}': + 'Current LLM output language: {{lang}}', + 'LLM output language not set': 'LLM output language not set', + 'Set UI language': 'Set UI language', + 'Set LLM output language': 'Set LLM output language', + 'Usage: /language ui [{{options}}]': 'Usage: /language ui [{{options}}]', + 'Usage: /language output ': 'Usage: /language output ', + 'Example: /language output 中文': 'Example: /language output 中文', + 'Example: /language output English': 'Example: /language output English', + 'Example: /language output 日本語': 'Example: /language output 日本語', + 'Example: /language output Português': 'Example: /language output Português', + 'UI language changed to {{lang}}': 'UI language changed to {{lang}}', + 'LLM output language set to {{lang}}': 'LLM output language set to {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'LLM output language rule file generated at {{path}}', + 'Please restart the application for the changes to take effect.': + 'Please restart the application for the changes to take effect.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Failed to generate LLM output language rule file: {{error}}', + 'Invalid command. Available subcommands:': + 'Invalid command. Available subcommands:', + 'Available subcommands:': 'Available subcommands:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'To request additional UI language packs, please open an issue on GitHub.', + 'Available options:': 'Available options:', + 'Set UI language to {{name}}': 'Set UI language to {{name}}', + + // ============================================================================ + // Commands - Approval Mode + // ============================================================================ + 'Tool Approval Mode': 'Tool Approval Mode', + 'Current approval mode: {{mode}}': 'Current approval mode: {{mode}}', + 'Available approval modes:': 'Available approval modes:', + 'Approval mode changed to: {{mode}}': 'Approval mode changed to: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Usage: /approval-mode [--session|--user|--project]', + + 'Scope subcommands do not accept additional arguments.': + 'Scope subcommands do not accept additional arguments.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Plan mode - Analyze only, do not modify files or execute commands', + 'Default mode - Require approval for file edits or shell commands': + 'Default mode - Require approval for file edits or shell commands', + 'Auto-edit mode - Automatically approve file edits': + 'Auto-edit mode - Automatically approve file edits', + 'YOLO mode - Automatically approve all tools': + 'YOLO mode - Automatically approve all tools', + '{{mode}} mode': '{{mode}} mode', + 'Settings service is not available; unable to persist the approval mode.': + 'Settings service is not available; unable to persist the approval mode.', + 'Failed to save approval mode: {{error}}': + 'Failed to save approval mode: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Failed to change approval mode: {{error}}', + 'Apply to current session only (temporary)': + 'Apply to current session only (temporary)', + 'Persist for this project/workspace': 'Persist for this project/workspace', + 'Persist for this user on this machine': + 'Persist for this user on this machine', + 'Analyze only, do not modify files or execute commands': + 'Analyze only, do not modify files or execute commands', + 'Require approval for file edits or shell commands': + 'Require approval for file edits or shell commands', + 'Automatically approve file edits': 'Automatically approve file edits', + 'Automatically approve all tools': 'Automatically approve all tools', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Workspace approval mode exists and takes priority. User-level change will have no effect.', + 'Apply To': 'Apply To', + 'Workspace Settings': 'Workspace Settings', + + // ============================================================================ + // Commands - Memory + // ============================================================================ + 'Commands for interacting with memory.': + 'Commands for interacting with memory.', + 'Show the current memory contents.': 'Show the current memory contents.', + 'Show project-level memory contents.': 'Show project-level memory contents.', + 'Show global memory contents.': 'Show global memory contents.', + 'Add content to project-level memory.': + 'Add content to project-level memory.', + 'Add content to global memory.': 'Add content to global memory.', + 'Refresh the memory from the source.': 'Refresh the memory from the source.', + 'Usage: /memory add --project ': + 'Usage: /memory add --project ', + 'Usage: /memory add --global ': + 'Usage: /memory add --global ', + 'Attempting to save to project memory: "{{text}}"': + 'Attempting to save to project memory: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Attempting to save to global memory: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Current memory content from {{count}} file(s):', + 'Memory is currently empty.': 'Memory is currently empty.', + 'Project memory file not found or is currently empty.': + 'Project memory file not found or is currently empty.', + 'Global memory file not found or is currently empty.': + 'Global memory file not found or is currently empty.', + 'Global memory is currently empty.': 'Global memory is currently empty.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Global memory content:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': 'Project memory is currently empty.', + 'Refreshing memory from source files...': + 'Refreshing memory from source files...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Add content to the memory. Use --global for global memory or --project for project memory.', + 'Usage: /memory add [--global|--project] ': + 'Usage: /memory add [--global|--project] ', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Attempting to save to memory {{scope}}: "{{fact}}"', + + // ============================================================================ + // Commands - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Authenticate with an OAuth-enabled MCP server', + 'List configured MCP servers and tools': + 'List configured MCP servers and tools', + 'Restarts MCP servers.': 'Restarts MCP servers.', + 'Open MCP management dialog': 'Open MCP management dialog', + 'Could not retrieve tool registry.': 'Could not retrieve tool registry.', + 'No MCP servers configured with OAuth authentication.': + 'No MCP servers configured with OAuth authentication.', + 'MCP servers with OAuth authentication:': + 'MCP servers with OAuth authentication:', + 'Use /mcp auth to authenticate.': + 'Use /mcp auth to authenticate.', + "MCP server '{{name}}' not found.": "MCP server '{{name}}' not found.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Successfully authenticated and refreshed tools for '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Failed to authenticate with MCP server '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "Re-discovering tools from '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "Discovered {{count}} tool(s) from '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Authentication complete. Returning to server details...', + 'Authentication successful.': 'Authentication successful.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'If the browser does not open, copy and paste this URL into your browser:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.', + + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'Manage MCP servers': 'Manage MCP servers', + 'Server Detail': 'Server Detail', + 'Disable Server': 'Disable Server', + Tools: 'Tools', + 'Tool Detail': 'Tool Detail', + 'MCP Management': 'MCP Management', + 'Loading...': 'Loading...', + 'Unknown step': 'Unknown step', + 'Esc to back': 'Esc to back', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ to navigate · Enter to select · Esc to close', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ to navigate · Enter to select · Esc to back', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ to navigate · Enter to confirm · Esc to back', + 'User Settings (global)': 'User Settings (global)', + 'Workspace Settings (project-specific)': + 'Workspace Settings (project-specific)', + 'Disable server:': 'Disable server:', + 'Select where to add the server to the exclude list:': + 'Select where to add the server to the exclude list:', + 'Press Enter to confirm, Esc to cancel': + 'Press Enter to confirm, Esc to cancel', + 'View tools': 'View tools', + Reconnect: 'Reconnect', + Enable: 'Enable', + Disable: 'Disable', + Authenticate: 'Authenticate', + 'Re-authenticate': 'Re-authenticate', + 'Clear Authentication': 'Clear Authentication', + 'Server:': 'Server:', + 'Command:': 'Command:', + 'Working Directory:': 'Working Directory:', + 'Capabilities:': 'Capabilities:', + 'No server selected': 'No server selected', + prompts: 'prompts', + '(disabled)': '(disabled)', + 'Error:': 'Error:', + tool: 'tool', + tools: 'tools', + connected: 'connected', + connecting: 'connecting', + disconnected: 'disconnected', + + // MCP Server List + 'User MCPs': 'User MCPs', + 'Project MCPs': 'Project MCPs', + 'Extension MCPs': 'Extension MCPs', + server: 'server', + servers: 'servers', + 'Add MCP servers to your settings to get started.': + 'Add MCP servers to your settings to get started.', + 'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs', + + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth Authentication', + 'Press Enter to start authentication, Esc to go back': + 'Press Enter to start authentication, Esc to go back', + 'Authenticating... Please complete the login in your browser.': + 'Authenticating... Please complete the login in your browser.', + 'Press Enter or Esc to go back': 'Press Enter or Esc to go back', + + // MCP Tool List + 'No tools available for this server.': 'No tools available for this server.', + destructive: 'destructive', + 'read-only': 'read-only', + 'open-world': 'open-world', + idempotent: 'idempotent', + 'Tools for {{name}}': 'Tools for {{name}}', + 'Tools for {{serverName}}': 'Tools for {{serverName}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'required', + Type: 'Type', + Enum: 'Enum', + Parameters: 'Parameters', + 'No tool selected': 'No tool selected', + Annotations: 'Annotations', + Title: 'Title', + 'Read Only': 'Read Only', + Destructive: 'Destructive', + Idempotent: 'Idempotent', + 'Open World': 'Open World', + Server: 'Server', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} invalid tools', + invalid: 'invalid', + 'invalid: {{reason}}': 'invalid: {{reason}}', + 'missing name': 'missing name', + 'missing description': 'missing description', + '(unnamed)': '(unnamed)', + 'Warning: This tool cannot be called by the LLM': + 'Warning: This tool cannot be called by the LLM', + Reason: 'Reason', + 'Tools must have both name and description to be used by the LLM.': + 'Tools must have both name and description to be used by the LLM.', + + // ============================================================================ + // Commands - Chat + // ============================================================================ + 'Manage conversation history.': 'Manage conversation history.', + 'List saved conversation checkpoints': 'List saved conversation checkpoints', + 'No saved conversation checkpoints found.': + 'No saved conversation checkpoints found.', + 'List of saved conversations:': 'List of saved conversations:', + 'Note: Newest last, oldest first': 'Note: Newest last, oldest first', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Save the current conversation as a checkpoint. Usage: /chat save ', + 'Missing tag. Usage: /chat save ': + 'Missing tag. Usage: /chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Delete a conversation checkpoint. Usage: /chat delete ', + 'Missing tag. Usage: /chat delete ': + 'Missing tag. Usage: /chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Conversation checkpoint '{{tag}}' has been deleted.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Error: No checkpoint found with tag '{{tag}}'.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Resume a conversation from a checkpoint. Usage: /chat resume ', + 'Missing tag. Usage: /chat resume ': + 'Missing tag. Usage: /chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + 'No saved checkpoint found with tag: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?', + 'No chat client available to save conversation.': + 'No chat client available to save conversation.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Conversation checkpoint saved with tag: {{tag}}.', + 'No conversation found to save.': 'No conversation found to save.', + 'No chat client available to share conversation.': + 'No chat client available to share conversation.', + 'Invalid file format. Only .md and .json are supported.': + 'Invalid file format. Only .md and .json are supported.', + 'Error sharing conversation: {{error}}': + 'Error sharing conversation: {{error}}', + 'Conversation shared to {{filePath}}': 'Conversation shared to {{filePath}}', + 'No conversation found to share.': 'No conversation found to share.', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + 'Share the current conversation to a markdown or json file. Usage: /chat share ', + + // ============================================================================ + // Commands - Summary + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'No chat client available to generate summary.', + 'Already generating summary, wait for previous request to complete': + 'Already generating summary, wait for previous request to complete', + 'No conversation found to summarize.': 'No conversation found to summarize.', + 'Failed to generate project context summary: {{error}}': + 'Failed to generate project context summary: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'Saved project summary to {{filePathForDisplay}}.', + 'Saving project summary...': 'Saving project summary...', + 'Generating project summary...': 'Generating project summary...', + 'Failed to generate summary - no text content received from LLM response': + 'Failed to generate summary - no text content received from LLM response', + + // ============================================================================ + // Commands - Model + // ============================================================================ + 'Switch the model for this session': 'Switch the model for this session', + 'Set fast model for background tasks': 'Set fast model for background tasks', + 'Content generator configuration not available.': + 'Content generator configuration not available.', + 'Authentication type not available.': 'Authentication type not available.', + 'No models available for the current authentication type ({{authType}}).': + 'No models available for the current authentication type ({{authType}}).', + + // ============================================================================ + // Commands - Clear + // ============================================================================ + 'Starting a new session, resetting chat, and clearing terminal.': + 'Starting a new session, resetting chat, and clearing terminal.', + 'Starting a new session and clearing.': + 'Starting a new session and clearing.', + + // ============================================================================ + // Commands - Compress + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Already compressing, wait for previous request to complete', + 'Failed to compress chat history.': 'Failed to compress chat history.', + 'Failed to compress chat history: {{error}}': + 'Failed to compress chat history: {{error}}', + 'Compressing chat history': 'Compressing chat history', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.', + 'Compression was not beneficial for this history size.': + 'Compression was not beneficial for this history size.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.', + 'Could not compress chat history due to a token counting error.': + 'Could not compress chat history due to a token counting error.', + 'Chat history is already compressed.': 'Chat history is already compressed.', + + // ============================================================================ + // Commands - Directory + // ============================================================================ + 'Configuration is not available.': 'Configuration is not available.', + 'Please provide at least one path to add.': + 'Please provide at least one path to add.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + "Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}', + 'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Successfully added directories:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Current workspace directories:\n{{directories}}', + + // ============================================================================ + // Commands - Docs + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Please open the following URL in your browser to view the documentation:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Opening documentation in your browser: {{url}}', + + // ============================================================================ + // Dialogs - Tool Confirmation + // ============================================================================ + 'Do you want to proceed?': 'Do you want to proceed?', + 'Yes, allow once': 'Yes, allow once', + 'Allow always': 'Allow always', + Yes: 'Yes', + No: 'No', + 'No (esc)': 'No (esc)', + 'Yes, allow always for this session': 'Yes, allow always for this session', + 'Modify in progress:': 'Modify in progress:', + 'Save and close external editor to continue': + 'Save and close external editor to continue', + 'Apply this change?': 'Apply this change?', + 'Yes, allow always': 'Yes, allow always', + 'Modify with external editor': 'Modify with external editor', + 'No, suggest changes (esc)': 'No, suggest changes (esc)', + "Allow execution of: '{{command}}'?": "Allow execution of: '{{command}}'?", + 'Yes, allow always ...': 'Yes, allow always ...', + 'Always allow in this project': 'Always allow in this project', + 'Always allow {{action}} in this project': + 'Always allow {{action}} in this project', + 'Always allow for this user': 'Always allow for this user', + 'Always allow {{action}} for this user': + 'Always allow {{action}} for this user', + 'Yes, restore previous mode ({{mode}})': + 'Yes, restore previous mode ({{mode}})', + 'Yes, and auto-accept edits': 'Yes, and auto-accept edits', + 'Yes, and manually approve edits': 'Yes, and manually approve edits', + 'No, keep planning (esc)': 'No, keep planning (esc)', + 'URLs to fetch:': 'URLs to fetch:', + 'MCP Server: {{server}}': 'MCP Server: {{server}}', + 'Tool: {{tool}}': 'Tool: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Yes, always allow tool "{{tool}}" from server "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'Yes, always allow all tools from server "{{server}}"', + + // ============================================================================ + // Dialogs - Shell Confirmation + // ============================================================================ + 'Shell Command Execution': 'Shell Command Execution', + 'A custom command wants to run the following shell commands:': + 'A custom command wants to run the following shell commands:', + + // ============================================================================ + // Dialogs - Pro Quota + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Pro quota limit reached for {{model}}.', + 'Change auth (executes the /auth command)': + 'Change auth (executes the /auth command)', + 'Continue with {{model}}': 'Continue with {{model}}', + + // ============================================================================ + // Dialogs - Welcome Back + // ============================================================================ + 'Current Plan:': 'Current Plan:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Progress: {{done}}/{{total}} tasks completed', + ', {{inProgress}} in progress': ', {{inProgress}} in progress', + 'Pending Tasks:': 'Pending Tasks:', + 'What would you like to do?': 'What would you like to do?', + 'Choose how to proceed with your session:': + 'Choose how to proceed with your session:', + 'Start new chat session': 'Start new chat session', + 'Continue previous conversation': 'Continue previous conversation', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 Welcome back! (Last updated: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Overall Goal:', + + // ============================================================================ + // Dialogs - Auth + // ============================================================================ + 'Get started': 'Get started', + 'Select Authentication Method': 'Select Authentication Method', + 'OpenAI API key is required to use OpenAI authentication.': + 'OpenAI API key is required to use OpenAI authentication.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'You must select an auth method to proceed. Press Ctrl+C again to exit.', + 'Terms of Services and Privacy Notice': + 'Terms of Services and Privacy Notice', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Bring your own API key', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Use coding plan credentials or your own api-keys/providers.', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Failed to login. Message: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.', + 'Please visit this URL to authorize:': 'Please visit this URL to authorize:', + 'Or scan the QR code below:': 'Or scan the QR code below:', + 'Waiting for authorization': 'Waiting for authorization', + 'Time remaining:': 'Time remaining:', + '(Press ESC or CTRL+C to cancel)': '(Press ESC or CTRL+C to cancel)', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.', + 'Press any key to return to authentication type selection.': + 'Press any key to return to authentication type selection.', + 'Authentication timed out. Please try again.': + 'Authentication timed out. Please try again.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Waiting for auth... (Press ESC or CTRL+C to cancel)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.', + '{{envKeyHint}} environment variable not found.': + '{{envKeyHint}} environment variable not found.', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'ANTHROPIC_BASE_URL environment variable not found.', + 'Invalid auth method selected.': 'Invalid auth method selected.', + 'Failed to authenticate. Message: {{message}}': + 'Failed to authenticate. Message: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Authenticated successfully with {{authType}} credentials.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}', + 'OpenAI Configuration Required': 'OpenAI Configuration Required', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Please enter your OpenAI configuration. You can get an API key from', + 'API Key:': 'API Key:', + 'Invalid credentials: {{errorMessage}}': + 'Invalid credentials: {{errorMessage}}', + 'Failed to validate credentials': 'Failed to validate credentials', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel', + + // ============================================================================ + // Dialogs - Model + // ============================================================================ + 'Select Model': 'Select Model', + '(Press Esc to close)': '(Press Esc to close)', + 'Current (effective) configuration': 'Current (effective) configuration', + AuthType: 'AuthType', + 'API Key': 'API Key', + unset: 'unset', + '(default)': '(default)', + '(set)': '(set)', + '(not set)': '(not set)', + Modality: 'Modality', + 'Context Window': 'Context Window', + text: 'text', + 'text-only': 'text-only', + image: 'image', + pdf: 'pdf', + audio: 'audio', + video: 'video', + 'not set': 'not set', + none: 'none', + unknown: 'unknown', + "Failed to switch model to '{{modelId}}'.\n\n{{error}}": + "Failed to switch model to '{{modelId}}'.\n\n{{error}}", + 'Qwen 3.6 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.6 Plus — efficient hybrid model with leading coding performance', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Dialogs - Permissions + // ============================================================================ + 'Manage folder trust settings': 'Manage folder trust settings', + 'Manage permission rules': 'Manage permission rules', + Allow: 'Allow', + Ask: 'Ask', + Deny: 'Deny', + Workspace: 'Workspace', + "Qwen Code won't ask before using allowed tools.": + "Qwen Code won't ask before using allowed tools.", + 'Qwen Code will ask before using these tools.': + 'Qwen Code will ask before using these tools.', + 'Qwen Code is not allowed to use denied tools.': + 'Qwen Code is not allowed to use denied tools.', + 'Manage trusted directories for this workspace.': + 'Manage trusted directories for this workspace.', + 'Any use of the {{tool}} tool': 'Any use of the {{tool}} tool', + "{{tool}} commands matching '{{pattern}}'": + "{{tool}} commands matching '{{pattern}}'", + 'From user settings': 'From user settings', + 'From project settings': 'From project settings', + 'From session': 'From session', + 'Project settings (local)': 'Project settings (local)', + 'Saved in .qwen/settings.local.json': 'Saved in .qwen/settings.local.json', + 'Project settings': 'Project settings', + 'Checked in at .qwen/settings.json': 'Checked in at .qwen/settings.json', + 'User settings': 'User settings', + 'Saved in at ~/.qwen/settings.json': 'Saved in at ~/.qwen/settings.json', + 'Add a new rule…': 'Add a new rule…', + 'Add {{type}} permission rule': 'Add {{type}} permission rule', + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.': + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.', + 'e.g.,': 'e.g.,', + or: 'or', + 'Enter permission rule…': 'Enter permission rule…', + 'Enter to submit · Esc to cancel': 'Enter to submit · Esc to cancel', + 'Where should this rule be saved?': 'Where should this rule be saved?', + 'Enter to confirm · Esc to cancel': 'Enter to confirm · Esc to cancel', + 'Delete {{type}} rule?': 'Delete {{type}} rule?', + 'Are you sure you want to delete this permission rule?': + 'Are you sure you want to delete this permission rule?', + 'Permissions:': 'Permissions:', + '(←/→ or tab to cycle)': '(←/→ or tab to cycle)', + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel': + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel', + 'Search…': 'Search…', + 'Use /trust to manage folder trust settings for this workspace.': + 'Use /trust to manage folder trust settings for this workspace.', + // Workspace directory management + 'Add directory…': 'Add directory…', + 'Add directory to workspace': 'Add directory to workspace', + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.': + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.', + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.': + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.', + 'Enter the path to the directory:': 'Enter the path to the directory:', + 'Enter directory path…': 'Enter directory path…', + 'Tab to complete · Enter to add · Esc to cancel': + 'Tab to complete · Enter to add · Esc to cancel', + 'Remove directory?': 'Remove directory?', + 'Are you sure you want to remove this directory from the workspace?': + 'Are you sure you want to remove this directory from the workspace?', + ' (Original working directory)': ' (Original working directory)', + ' (from settings)': ' (from settings)', + 'Directory does not exist.': 'Directory does not exist.', + 'Path is not a directory.': 'Path is not a directory.', + 'This directory is already in the workspace.': + 'This directory is already in the workspace.', + 'Already covered by existing directory: {{dir}}': + 'Already covered by existing directory: {{dir}}', + + // ============================================================================ + // Status Bar + // ============================================================================ + 'Using:': 'Using:', + '{{count}} open file': '{{count}} open file', + '{{count}} open files': '{{count}} open files', + '(ctrl+g to view)': '(ctrl+g to view)', + '{{count}} {{name}} file': '{{count}} {{name}} file', + '{{count}} {{name}} files': '{{count}} {{name}} files', + '{{count}} MCP server': '{{count}} MCP server', + '{{count}} MCP servers': '{{count}} MCP servers', + '{{count}} Blocked': '{{count}} Blocked', + '(ctrl+t to view)': '(ctrl+t to view)', + '(ctrl+t to toggle)': '(ctrl+t to toggle)', + 'Press Ctrl+C again to exit.': 'Press Ctrl+C again to exit.', + 'Press Ctrl+D again to exit.': 'Press Ctrl+D again to exit.', + 'Press Esc again to clear.': 'Press Esc again to clear.', + + // ============================================================================ + // MCP Status + // ============================================================================ + 'No MCP servers configured.': 'No MCP servers configured.', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP servers are starting up ({{count}} initializing)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Note: First startup may take longer. Tool availability will update automatically.', + 'Configured MCP servers:': 'Configured MCP servers:', + Ready: 'Ready', + 'Starting... (first startup may take longer)': + 'Starting... (first startup may take longer)', + Disconnected: 'Disconnected', + '{{count}} tool': '{{count}} tool', + '{{count}} tools': '{{count}} tools', + '{{count}} prompt': '{{count}} prompt', + '{{count}} prompts': '{{count}} prompts', + '(from {{extensionName}})': '(from {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth expired', + 'OAuth not authenticated': 'OAuth not authenticated', + 'tools and prompts will appear when ready': + 'tools and prompts will appear when ready', + '{{count}} tools cached': '{{count}} tools cached', + 'Tools:': 'Tools:', + 'Parameters:': 'Parameters:', + 'Prompts:': 'Prompts:', + Blocked: 'Blocked', + '💡 Tips:': '💡 Tips:', + Use: 'Use', + 'to show server and tool descriptions': + 'to show server and tool descriptions', + 'to show tool parameter schemas': 'to show tool parameter schemas', + 'to hide descriptions': 'to hide descriptions', + 'to authenticate with OAuth-enabled servers': + 'to authenticate with OAuth-enabled servers', + Press: 'Press', + 'to toggle tool descriptions on/off': 'to toggle tool descriptions on/off', + "Starting OAuth authentication for MCP server '{{name}}'...": + "Starting OAuth authentication for MCP server '{{name}}'...", + 'Restarting MCP servers...': 'Restarting MCP servers...', + + // ============================================================================ + // Startup Tips + // ============================================================================ + 'Tips:': 'Tips:', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Use /compress when the conversation gets long to summarize history and free up context.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'Start a fresh idea with /clear or /new; the previous session stays available in history.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Use /bug to submit issues to the maintainers when something goes off.', + 'Switch auth type quickly with /auth.': + 'Switch auth type quickly with /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'You can resume a previous conversation by running qwen --continue or qwen --resume.', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.', + 'You can switch permission mode quickly with Tab or /approval-mode.': + 'You can switch permission mode quickly with Tab or /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Try /insight to generate personalized insights from your chat history.', + + // ============================================================================ + // Exit Screen / Stats + // ============================================================================ + 'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!', + 'To continue this session, run': 'To continue this session, run', + 'Interaction Summary': 'Interaction Summary', + 'Session ID:': 'Session ID:', + 'Tool Calls:': 'Tool Calls:', + 'Success Rate:': 'Success Rate:', + 'User Agreement:': 'User Agreement:', + reviewed: 'reviewed', + 'Code Changes:': 'Code Changes:', + Performance: 'Performance', + 'Wall Time:': 'Wall Time:', + 'Agent Active:': 'Agent Active:', + 'API Time:': 'API Time:', + 'Tool Time:': 'Tool Time:', + 'Session Stats': 'Session Stats', + 'Model Usage': 'Model Usage', + Reqs: 'Reqs', + 'Input Tokens': 'Input Tokens', + 'Output Tokens': 'Output Tokens', + 'Savings Highlight:': 'Savings Highlight:', + 'of input tokens were served from the cache, reducing costs.': + 'of input tokens were served from the cache, reducing costs.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Tip: For a full token breakdown, run `/stats model`.', + 'Model Stats For Nerds': 'Model Stats For Nerds', + 'Tool Stats For Nerds': 'Tool Stats For Nerds', + Metric: 'Metric', + API: 'API', + Requests: 'Requests', + Errors: 'Errors', + 'Avg Latency': 'Avg Latency', + Tokens: 'Tokens', + Total: 'Total', + Prompt: 'Prompt', + Cached: 'Cached', + Thoughts: 'Thoughts', + Tool: 'Tool', + Output: 'Output', + 'No API calls have been made in this session.': + 'No API calls have been made in this session.', + 'Tool Name': 'Tool Name', + Calls: 'Calls', + 'Success Rate': 'Success Rate', + 'Avg Duration': 'Avg Duration', + 'User Decision Summary': 'User Decision Summary', + 'Total Reviewed Suggestions:': 'Total Reviewed Suggestions:', + ' » Accepted:': ' » Accepted:', + ' » Rejected:': ' » Rejected:', + ' » Modified:': ' » Modified:', + ' Overall Agreement Rate:': ' Overall Agreement Rate:', + 'No tool calls have been made in this session.': + 'No tool calls have been made in this session.', + 'Session start time is unavailable, cannot calculate stats.': + 'Session start time is unavailable, cannot calculate stats.', + + // ============================================================================ + // Command Format Migration + // ============================================================================ + 'Command Format Migration': 'Command Format Migration', + 'Found {{count}} TOML command file:': 'Found {{count}} TOML command file:', + 'Found {{count}} TOML command files:': 'Found {{count}} TOML command files:', + '... and {{count}} more': '... and {{count}} more', + 'The TOML format is deprecated. Would you like to migrate them to Markdown format?': + 'The TOML format is deprecated. Would you like to migrate them to Markdown format?', + '(Backups will be created and original files will be preserved)': + '(Backups will be created and original files will be preserved)', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': 'Waiting for user confirmation...', + '(esc to cancel, {{time}})': '(esc to cancel, {{time}})', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + WITTY_LOADING_PHRASES: [ + "I'm Feeling Lucky", + 'Shipping awesomeness... ', + 'Painting the serifs back on...', + 'Navigating the slime mold...', + 'Consulting the digital spirits...', + 'Reticulating splines...', + 'Warming up the AI hamsters...', + 'Asking the magic conch shell...', + 'Generating witty retort...', + 'Polishing the algorithms...', + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...', + 'Counting electrons...', + 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...', + 'Shuffling punchlines...', + 'Untangling neural nets...', + 'Compiling brilliance...', + 'Loading wit.exe...', + 'Summoning the cloud of wisdom...', + 'Preparing a witty response...', + "Just a sec, I'm debugging reality...", + 'Confuzzling the options...', + 'Tuning the cosmic frequencies...', + 'Crafting a response worthy of your patience...', + 'Compiling the 1s and 0s...', + 'Resolving dependencies... and existential crises...', + 'Defragmenting memories... both RAM and personal...', + 'Rebooting the humor module...', + 'Caching the essentials (mostly cat memes)...', + 'Optimizing for ludicrous speed', + "Swapping bits... don't tell the bytes...", + 'Garbage collecting... be right back...', + 'Assembling the interwebs...', + 'Converting coffee into code...', + 'Updating the syntax for reality...', + 'Rewiring the synapses...', + 'Looking for a misplaced semicolon...', + "Greasin' the cogs of the machine...", + 'Pre-heating the servers...', + 'Calibrating the flux capacitor...', + 'Engaging the improbability drive...', + 'Channeling the Force...', + 'Aligning the stars for optimal response...', + 'So say we all...', + 'Loading the next great idea...', + "Just a moment, I'm in the zone...", + 'Preparing to dazzle you with brilliance...', + "Just a tick, I'm polishing my wit...", + "Hold tight, I'm crafting a masterpiece...", + "Just a jiffy, I'm debugging the universe...", + "Just a moment, I'm aligning the pixels...", + "Just a sec, I'm optimizing the humor...", + "Just a moment, I'm tuning the algorithms...", + 'Warp speed engaged...', + 'Mining for more Dilithium crystals...', + "Don't panic...", + 'Following the white rabbit...', + 'The truth is in here... somewhere...', + 'Blowing on the cartridge...', + 'Loading... Do a barrel roll!', + 'Waiting for the respawn...', + 'Finishing the Kessel Run in less than 12 parsecs...', + "The cake is not a lie, it's just still loading...", + 'Fiddling with the character creation screen...', + "Just a moment, I'm finding the right meme...", + "Pressing 'A' to continue...", + 'Herding digital cats...', + 'Polishing the pixels...', + 'Finding a suitable loading screen pun...', + 'Distracting you with this witty phrase...', + 'Almost there... probably...', + 'Our hamsters are working as fast as they can...', + 'Giving Cloudy a pat on the head...', + 'Petting the cat...', + 'Rickrolling my boss...', + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...', + 'Tasting the snozberries...', + "I'm going the distance, I'm going for speed...", + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...", + 'Poking the bear...', + 'Doing research on the latest memes...', + 'Figuring out how to make this more witty...', + 'Hmmm... let me think...', + 'What do you call a fish with no eyes? A fsh...', + 'Why did the computer go to therapy? It had too many bytes...', + "Why don't programmers like nature? It has too many bugs...", + 'Why do programmers prefer dark mode? Because light attracts bugs...', + 'Why did the developer go broke? Because they used up all their cache...', + "What can you do with a broken pencil? Nothing, it's pointless...", + 'Applying percussive maintenance...', + 'Searching for the correct USB orientation...', + 'Ensuring the magic smoke stays inside the wires...', + 'Trying to exit Vim...', + 'Spinning up the hamster wheel...', + "That's not a bug, it's an undocumented feature...", + 'Engage.', + "I'll be back... with an answer.", + 'My other process is a TARDIS...', + 'Communing with the machine spirit...', + 'Letting the thoughts marinate...', + 'Just remembered where I put my keys...', + 'Pondering the orb...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.", + 'Initiating thoughtful gaze...', + "What's a computer's favorite snack? Microchips.", + "Why do Java developers wear glasses? Because they don't C#.", + 'Charging the laser... pew pew!', + 'Dividing by zero... just kidding!', + 'Looking for an adult superviso... I mean, processing.', + 'Making it go beep boop.', + 'Buffering... because even AIs need a moment.', + 'Entangling quantum particles for a faster response...', + 'Polishing the chrome... on the algorithms.', + 'Are you not entertained? (Working on it!)', + 'Summoning the code gremlins... to help, of course.', + 'Just waiting for the dial-up tone to finish...', + 'Recalibrating the humor-o-meter.', + 'My other loading screen is even funnier.', + "Pretty sure there's a cat walking on the keyboard somewhere...", + 'Enhancing... Enhancing... Still loading.', + "It's not a bug, it's a feature... of this loading screen.", + 'Have you tried turning it off and on again? (The loading screen, not me.)', + 'Constructing additional pylons...', + ], + + // ============================================================================ + // Extension Settings Input + // ============================================================================ + 'Enter value...': 'Enter value...', + 'Enter sensitive value...': 'Enter sensitive value...', + 'Press Enter to submit, Escape to cancel': + 'Press Enter to submit, Escape to cancel', + + // ============================================================================ + // Command Migration Tool + // ============================================================================ + 'Markdown file already exists: {{filename}}': + 'Markdown file already exists: {{filename}}', + 'TOML Command Format Deprecation Notice': + 'TOML Command Format Deprecation Notice', + 'Found {{count}} command file(s) in TOML format:': + 'Found {{count}} command file(s) in TOML format:', + 'The TOML format for commands is being deprecated in favor of Markdown format.': + 'The TOML format for commands is being deprecated in favor of Markdown format.', + 'Markdown format is more readable and easier to edit.': + 'Markdown format is more readable and easier to edit.', + 'You can migrate these files automatically using:': + 'You can migrate these files automatically using:', + 'Or manually convert each file:': 'Or manually convert each file:', + 'TOML: prompt = "..." / description = "..."': + 'TOML: prompt = "..." / description = "..."', + 'Markdown: YAML frontmatter + content': + 'Markdown: YAML frontmatter + content', + 'The migration tool will:': 'The migration tool will:', + 'Convert TOML files to Markdown': 'Convert TOML files to Markdown', + 'Create backups of original files': 'Create backups of original files', + 'Preserve all command functionality': 'Preserve all command functionality', + 'TOML format will continue to work for now, but migration is recommended.': + 'TOML format will continue to work for now, but migration is recommended.', + + // ============================================================================ + // Extensions - Explore Command + // ============================================================================ + 'Open extensions page in your browser': + 'Open extensions page in your browser', + 'Unknown extensions source: {{source}}.': + 'Unknown extensions source: {{source}}.', + 'Would open extensions page in your browser: {{url}} (skipped in test environment)': + 'Would open extensions page in your browser: {{url}} (skipped in test environment)', + 'View available extensions at {{url}}': + 'View available extensions at {{url}}', + 'Opening extensions page in your browser: {{url}}': + 'Opening extensions page in your browser: {{url}}', + 'Failed to open browser. Check out the extensions gallery at {{url}}': + 'Failed to open browser. Check out the extensions gallery at {{url}}', + + // ============================================================================ + // Retry / Rate Limit + // ============================================================================ + 'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}', + 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})': + 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})', + 'Press Ctrl+Y to retry': 'Press Ctrl+Y to retry', + 'No failed request to retry.': 'No failed request to retry.', + 'to retry last request': 'to retry last request', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'API key cannot be empty.': 'API key cannot be empty.', + 'You can get your Coding Plan API key here': + 'You can get your Coding Plan API key here', + 'API key is stored in settings.env. You can migrate it to a .env file for better security.': + 'API key is stored in settings.env. You can migrate it to a .env file for better security.', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan configuration updated successfully. New models are now available.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Failed to update Coding Plan configuration: {{message}}', + + // ============================================================================ + // Custom API Key Configuration + // ============================================================================ + 'You can configure your API key and models in settings.json': + 'You can configure your API key and models in settings.json', + 'Refer to the documentation for setup instructions': + 'Refer to the documentation for setup instructions', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of ModelStudio Coding Plan and you're all set!": + "Paste your api key of ModelStudio Coding Plan and you're all set!", + Custom: 'Custom', + 'More instructions about configuring `modelProviders` manually.': + 'More instructions about configuring `modelProviders` manually.', + 'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:', + '(Press Escape to go back)': '(Press Escape to go back)', + '(Press Enter to submit, Escape to cancel)': + '(Press Enter to submit, Escape to cancel)', + 'Select Region for Coding Plan': 'Select Region for Coding Plan', + 'Choose based on where your account is registered': + 'Choose based on where your account is registered', + 'Enter Coding Plan API Key': 'Enter Coding Plan API Key', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'New model configurations are available for {{region}}. Update now?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Context Usage', + 'No API response yet. Send a message to see actual usage.': + 'No API response yet. Send a message to see actual usage.', + 'Estimated pre-conversation overhead': 'Estimated pre-conversation overhead', + 'Context window': 'Context window', + tokens: 'tokens', + Used: 'Used', + Free: 'Free', + 'Autocompact buffer': 'Autocompact buffer', + 'Usage by category': 'Usage by category', + 'System prompt': 'System prompt', + 'Built-in tools': 'Built-in tools', + 'MCP tools': 'MCP tools', + 'Memory files': 'Memory files', + Skills: 'Skills', + Messages: 'Messages', + 'Show context window usage breakdown.': + 'Show context window usage breakdown.', + 'Run /context detail for per-item breakdown.': + 'Run /context detail for per-item breakdown.', + 'body loaded': 'body loaded', + memory: 'memory', + '{{region}} configuration updated successfully.': + '{{region}} configuration updated successfully.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Tip: Use /model to switch between available Coding Plan models.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Please answer the following question(s):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.', + 'User declined to answer the questions.': + 'User declined to answer the questions.', + 'User has provided the following answers:': + 'User has provided the following answers:', + 'Failed to process user answers:': 'Failed to process user answers:', + 'Type something...': 'Type something...', + Submit: 'Submit', + 'Submit answers': 'Submit answers', + Cancel: 'Cancel', + 'Your answers:': 'Your answers:', + '(not answered)': '(not answered)', + 'Ready to submit your answers?': 'Ready to submit your answers?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navigate | Enter: Select | Esc: Cancel', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Authenticate using Alibaba Cloud Coding Plan': + 'Authenticate using Alibaba Cloud Coding Plan', + 'Region for Coding Plan (china/global)': + 'Region for Coding Plan (china/global)', + 'API key for Coding Plan': 'API key for Coding Plan', + 'Show current authentication status': 'Show current authentication status', + 'Authentication completed successfully.': + 'Authentication completed successfully.', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Processing Alibaba Cloud Coding Plan authentication...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Successfully authenticated with Alibaba Cloud Coding Plan.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Failed to authenticate with Coding Plan: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Global', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Select region for Coding Plan:', + 'Enter your Coding Plan API key: ': 'Enter your Coding Plan API key: ', + 'Select authentication method:': 'Select authentication method:', + '\n=== Authentication Status ===\n': '\n=== Authentication Status ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ No authentication method configured.\n', + 'Run one of the following commands to get started:\n': + 'Run one of the following commands to get started:\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n', + 'Or simply run:': 'Or simply run:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Interactive authentication setup\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Authentication Method: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Global - Alibaba Cloud', + ' Region: {{region}}': ' Region: {{region}}', + ' Current Model: {{model}}': ' Current Model: {{model}}', + ' Config Version: {{version}}': ' Config Version: {{version}}', + ' Status: API key configured\n': ' Status: API key configured\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', + ' Issue: API key not found in environment or settings\n': + ' Issue: API key not found in environment or settings\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Run `qwen auth coding-plan` to re-configure.\n', + '✓ Authentication Method: {{type}}': '✓ Authentication Method: {{type}}', + ' Status: Configured\n': ' Status: Configured\n', + 'Failed to check authentication status: {{error}}': + 'Failed to check authentication status: {{error}}', + 'Select an option:': 'Select an option:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Raw mode not available. Please run in an interactive terminal.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n', + verbose: 'verbose', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).', + 'Press Ctrl+O to show full tool output': + 'Press Ctrl+O to show full tool output', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', + + "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", +}; diff --git a/apps/airiscode-cli/src/i18n/locales/ja.js b/apps/airiscode-cli/src/i18n/locales/ja.js new file mode 100644 index 000000000..136ccc843 --- /dev/null +++ b/apps/airiscode-cli/src/i18n/locales/ja.js @@ -0,0 +1,1452 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Japanese translations for Qwen Code CLI + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + 'Basics:': '基本操作:', + 'Add context': 'コンテキストを追加', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + '{{symbol}} を使用してコンテキスト用のファイルを指定します(例: {{example}}) また、特定のファイルやフォルダを対象にできます', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'シェルモード', + 'YOLO mode': 'YOLOモード', + 'plan mode': 'プランモード', + 'auto-accept edits': '編集を自動承認', + 'Accepting edits': '編集を承認中', + '(shift + tab to cycle)': '(Shift + Tab で切り替え)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + '{{symbol}} でシェルコマンドを実行(例: {{example1}})、または自然言語で入力(例: {{example2}})', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'サーバーを起動', + 'Commands:': 'コマンド:', + 'shell command': 'シェルコマンド', + 'Model Context Protocol command (from external servers)': + 'Model Context Protocol コマンド(外部サーバーから)', + 'Keyboard Shortcuts:': 'キーボードショートカット:', + 'Jump through words in the input': '入力欄の単語間を移動', + 'Close dialogs, cancel requests, or quit application': + 'ダイアログを閉じる、リクエストをキャンセル、またはアプリを終了', + 'New line': '改行', + 'New line (Alt+Enter works for certain linux distros)': + '改行(一部のLinuxディストリビューションではAlt+Enterが有効)', + 'Clear the screen': '画面をクリア', + 'Open input in external editor': '外部エディタで入力を開く', + 'Send message': 'メッセージを送信', + 'Initializing...': '初期化中...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'MCPサーバーに接続中... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': + 'メッセージを入力、@パス/ファイルでファイルを添付(D&D対応)', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "'i' でINSERTモード、'Esc' でNORMALモード", + 'Cancel operation / Clear input (double press)': + '操作をキャンセル / 入力をクリア(2回押し)', + 'Cycle approval modes': '承認モードを切り替え', + 'Cycle through your prompt history': 'プロンプト履歴を順に表示', + 'For a full list of shortcuts, see {{docPath}}': + 'ショートカットの完全なリストは {{docPath}} を参照', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'Qwen Code のヘルプ', + 'show version info': 'バージョン情報を表示', + 'submit a bug report': 'バグレポートを送信', + 'About Qwen Code': 'Qwen Code について', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'CLI Version': 'CLIバージョン', + 'Git Commit': 'Gitコミット', + Model: 'モデル', + 'Fast Model': '高速モデル', + Sandbox: 'サンドボックス', + 'OS Platform': 'OSプラットフォーム', + 'OS Arch': 'OSアーキテクチャ', + 'OS Release': 'OSリリース', + 'Node.js Version': 'Node.js バージョン', + 'NPM Version': 'NPM バージョン', + 'Session ID': 'セッションID', + 'Auth Method': '認証方式', + 'Base URL': 'ベースURL', + 'Memory Usage': 'メモリ使用量', + 'IDE Client': 'IDEクライアント', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成', + 'List available Qwen Code tools. Usage: /tools [desc]': + '利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]', + 'List available skills.': '利用可能なスキルを一覧表示する。', + 'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:', + 'No tools available': '利用可能なツールはありません', + 'View or change the approval mode for tool usage': + 'ツール使用の承認モードを表示または変更', + 'View or change the language setting': '言語設定を表示または変更', + 'change the theme': 'テーマを変更', + 'Select Theme': 'テーマを選択', + Preview: 'プレビュー', + '(Use Enter to select, Tab to configure scope)': + '(Enter で選択、Tab でスコープを設定)', + '(Use Enter to apply scope, Tab to select theme)': + '(Enter でスコープを適用、Tab でテーマを選択)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'NO_COLOR 環境変数のためテーマ設定は利用できません', + 'Theme "{{themeName}}" not found.': 'テーマ "{{themeName}}" が見つかりません', + 'Theme "{{themeName}}" not found in selected scope.': + '選択したスコープにテーマ "{{themeName}}" が見つかりません', + 'Clear conversation history and free up context': + '会話履歴をクリアしてコンテキストを解放', + 'Compresses the context by replacing it with a summary.': + 'コンテキストを要約に置き換えて圧縮', + 'open full Qwen Code documentation in your browser': + 'ブラウザで Qwen Code のドキュメントを開く', + 'Configuration not available.': '設定が利用できません', + 'change the auth method': '認証方式を変更', + 'Configure authentication information for login': + 'ログイン用の認証情報を設定', + 'Copy the last result or code snippet to clipboard': + '最後の結果またはコードスニペットをクリップボードにコピー', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + '専門タスクを委任するサブエージェントを管理', + 'Manage existing subagents (view, edit, delete).': + '既存のサブエージェントを管理(表示、編集、削除)', + 'Create a new subagent with guided setup.': + 'ガイド付きセットアップで新しいサブエージェントを作成', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: 'エージェント', + 'Choose Action': 'アクションを選択', + 'Edit {{name}}': '{{name}} を編集', + 'Edit Tools: {{name}}': 'ツールを編集: {{name}}', + 'Edit Color: {{name}}': '色を編集: {{name}}', + 'Delete {{name}}': '{{name}} を削除', + 'Unknown Step': '不明なステップ', + 'Esc to close': 'Esc で閉じる', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter で選択、↑↓ で移動、Esc で閉じる', + 'Esc to go back': 'Esc で戻る', + 'Enter to confirm, Esc to cancel': 'Enter で確定、Esc でキャンセル', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter で選択、↑↓ で移動、Esc で戻る', + 'Enter to submit, Esc to go back': 'Enter で送信、Esc で戻る', + 'Invalid step: {{step}}': '無効なステップ: {{step}}', + 'No subagents found.': 'サブエージェントが見つかりません', + "Use '/agents create' to create your first subagent.": + "'/agents create' で最初のサブエージェントを作成してください", + '(built-in)': '(組み込み)', + '(overridden by project level agent)': + '(プロジェクトレベルのエージェントで上書き)', + 'Project Level ({{path}})': 'プロジェクトレベル ({{path}})', + 'User Level ({{path}})': 'ユーザーレベル ({{path}})', + 'Built-in Agents': '組み込みエージェント', + 'Using: {{count}} agents': '使用中: {{count}} エージェント', + 'View Agent': 'エージェントを表示', + 'Edit Agent': 'エージェントを編集', + 'Delete Agent': 'エージェントを削除', + Back: '戻る', + 'No agent selected': 'エージェントが選択されていません', + 'File Path: ': 'ファイルパス: ', + 'Tools: ': 'ツール: ', + 'Color: ': '色: ', + 'Description:': '説明:', + 'System Prompt:': 'システムプロンプト:', + 'Open in editor': 'エディタで開く', + 'Edit tools': 'ツールを編集', + 'Edit color': '色を編集', + '❌ Error:': '❌ エラー:', + 'Are you sure you want to delete agent "{{name}}"?': + 'エージェント "{{name}}" を削除してもよろしいですか?', + 'Project Level (.qwen/agents/)': 'プロジェクトレベル (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'ユーザーレベル (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': + '✅ サブエージェントの作成に成功しました!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'サブエージェント "{{name}}" を {{level}} に保存しました', + 'Name: ': '名前: ', + 'Location: ': '場所: ', + '❌ Error saving subagent:': '❌ サブエージェント保存エラー:', + 'Warnings:': '警告:', + 'Step {{n}}: Choose Location': 'ステップ {{n}}: 場所を選択', + 'Step {{n}}: Choose Generation Method': 'ステップ {{n}}: 作成方法を選択', + 'Generate with Qwen Code (Recommended)': 'Qwen Code で生成(推奨)', + 'Manual Creation': '手動作成', + 'Generating subagent configuration...': 'サブエージェント設定を生成中...', + 'Failed to generate subagent: {{error}}': + 'サブエージェントの生成に失敗: {{error}}', + 'Step {{n}}: Describe Your Subagent': + 'ステップ {{n}}: サブエージェントを説明', + 'Step {{n}}: Enter Subagent Name': 'ステップ {{n}}: サブエージェント名を入力', + 'Step {{n}}: Enter System Prompt': 'ステップ {{n}}: システムプロンプトを入力', + 'Step {{n}}: Enter Description': 'ステップ {{n}}: 説明を入力', + 'Step {{n}}: Select Tools': 'ステップ {{n}}: ツールを選択', + 'All Tools (Default)': '全ツール(デフォルト)', + 'All Tools': '全ツール', + 'Read-only Tools': '読み取り専用ツール', + 'Read & Edit Tools': '読み取り&編集ツール', + 'Read & Edit & Execution Tools': '読み取り&編集&実行ツール', + 'Selected tools:': '選択されたツール:', + 'Step {{n}}: Choose Background Color': 'ステップ {{n}}: 背景色を選択', + 'Step {{n}}: Confirm and Save': 'ステップ {{n}}: 確認して保存', + 'Esc to cancel': 'Esc でキャンセル', + cancel: 'キャンセル', + 'go back': '戻る', + '↑↓ to navigate, ': '↑↓ で移動、', + 'Name cannot be empty.': '名前は空にできません', + 'System prompt cannot be empty.': 'システムプロンプトは空にできません', + 'Description cannot be empty.': '説明は空にできません', + 'Failed to launch editor: {{error}}': 'エディタの起動に失敗: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'サブエージェントの保存と編集に失敗: {{error}}', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + '"{{name}}" は {{level}} に既に存在します - 既存のサブエージェントを上書きします', + 'Name "{{name}}" exists at user level - project level will take precedence': + '"{{name}}" はユーザーレベルに存在します - プロジェクトレベルが優先されます', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + '"{{name}}" はプロジェクトレベルに存在します - 既存のサブエージェントが優先されます', + 'Description is over {{length}} characters': + '説明が {{length}} 文字を超えています', + 'System prompt is over {{length}} characters': + 'システムプロンプトが {{length}} 文字を超えています', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'このサブエージェントの役割と使用タイミングを説明してください(詳細に記述するほど良い結果が得られます)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + '例: ベストプラクティスに基づいてコードをレビューするエキスパートレビュアー...', + 'All tools selected, including MCP tools': + 'MCPツールを含むすべてのツールを選択', + 'Read-only tools:': '読み取り専用ツール:', + 'Edit tools:': '編集ツール:', + 'Execution tools:': '実行ツール:', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter で保存、e で保存して編集、Esc で戻る', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter で続行、{{navigation}}Esc で{{action}}', + 'Enter a clear, unique name for this subagent.': + 'このサブエージェントの明確で一意な名前を入力してください', + 'e.g., Code Reviewer': '例: コードレビュアー', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'このサブエージェントの動作を定義するシステムプロンプトを記述してください (詳細に書くほど良い結果が得られます)', + 'e.g., You are an expert code reviewer...': + '例: あなたはエキスパートコードレビュアーです...', + 'Describe when and how this subagent should be used.': + 'このサブエージェントをいつどのように使用するかを説明してください', + 'e.g., Reviews code for best practices and potential bugs.': + '例: ベストプラクティスと潜在的なバグについてコードをレビューします。', + // Commands - General (continued) + '(Use Enter to select{{tabText}})': '(Enter で選択{{tabText}})', + ', Tab to change focus': '、Tab でフォーカス変更', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + '変更を確認するには Qwen Code を再起動する必要があります。 r を押して終了し、変更を適用してください', + 'The command "/{{command}}" is not supported in non-interactive mode.': + 'コマンド "/{{command}}" は非対話モードではサポートされていません', + 'View and edit Qwen Code settings': 'Qwen Code の設定を表示・編集', + Settings: '設定', + 'Vim Mode': 'Vim モード', + 'Disable Auto Update': '自動更新を無効化', + Language: '言語', + 'Output Format': '出力形式', + 'Hide Tips': 'ヒントを非表示', + 'Hide Banner': 'バナーを非表示', + 'Show Memory Usage': 'メモリ使用量を表示', + 'Show Line Numbers': '行番号を表示', + Text: 'テキスト', + JSON: 'JSON', + Plan: 'プラン', + Default: 'デフォルト', + 'Auto Edit': '自動編集', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'Vim モードのオン/オフを切り替え', + 'exit the cli': 'CLIを終了', + Timeout: 'タイムアウト', + 'Max Retries': '最大リトライ回数', + 'Auto Accept': '自動承認', + 'Folder Trust': 'フォルダの信頼', + 'Enable Prompt Completion': 'プロンプト補完を有効化', + 'Debug Keystroke Logging': 'キーストロークのデバッグログ', + 'Hide Window Title': 'ウィンドウタイトルを非表示', + 'Show Status in Title': 'タイトルにステータスを表示', + 'Hide Context Summary': 'コンテキスト要約を非表示', + 'Hide CWD': '作業ディレクトリを非表示', + 'Hide Sandbox Status': 'サンドボックス状態を非表示', + 'Hide Model Info': 'モデル情報を非表示', + 'Hide Footer': 'フッターを非表示', + 'Show Citations': '引用を表示', + 'Custom Witty Phrases': 'カスタムウィットフレーズ', + 'Enable Welcome Back': 'ウェルカムバック機能を有効化', + 'Disable Loading Phrases': 'ローディングフレーズを無効化', + 'Screen Reader Mode': 'スクリーンリーダーモード', + 'IDE Mode': 'IDEモード', + 'Max Session Turns': '最大セッションターン数', + 'Skip Next Speaker Check': '次の発言者チェックをスキップ', + 'Skip Loop Detection': 'ループ検出をスキップ', + 'Skip Startup Context': '起動時コンテキストをスキップ', + 'Enable OpenAI Logging': 'OpenAI ログを有効化', + 'OpenAI Logging Directory': 'OpenAI ログディレクトリ', + 'Disable Cache Control': 'キャッシュ制御を無効化', + 'Memory Discovery Max Dirs': 'メモリ検出の最大ディレクトリ数', + 'Load Memory From Include Directories': + 'インクルードディレクトリからメモリを読み込み', + 'Respect .gitignore': '.gitignore を優先', + 'Respect .qwenignore': '.qwenignore を優先', + 'Enable Recursive File Search': '再帰的ファイル検索を有効化', + 'Disable Fuzzy Search': 'ファジー検索を無効化', + 'Enable Interactive Shell': '対話型シェルを有効化', + 'Show Color': '色を表示', + 'Use Ripgrep': 'Ripgrep を使用', + 'Use Builtin Ripgrep': '組み込み Ripgrep を使用', + 'Enable Tool Output Truncation': 'ツール出力の切り詰めを有効化', + 'Tool Output Truncation Threshold': 'ツール出力切り詰めのしきい値', + 'Tool Output Truncation Lines': 'ツール出力の切り詰め行数', + 'Vision Model Preview': 'ビジョンモデルプレビュー', + 'Tool Schema Compliance': 'ツールスキーマ準拠', + 'Auto (detect from system)': '自動(システムから検出)', + 'check session stats. Usage: /stats [model|tools]': + 'セッション統計を確認。使い方: /stats [model|tools]', + 'Show model-specific usage statistics.': 'モデル別の使用統計を表示', + 'Show tool-specific usage statistics.': 'ツール別の使用統計を表示', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'MCP管理ダイアログを開く、またはOAuth対応サーバーで認証', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + '設定済みのMCPサーバーとツールを一覧表示、またはOAuth対応サーバーで認証', + 'Manage workspace directories': 'ワークスペースディレクトリを管理', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'ワークスペースにディレクトリを追加。複数パスはカンマで区切ってください', + 'Show all directories in the workspace': + 'ワークスペース内のすべてのディレクトリを表示', + 'set external editor preference': '外部エディタの設定', + 'Manage extensions': '拡張機能を管理', + 'Manage installed extensions': 'インストール済みの拡張機能を管理する', + 'List active extensions': '有効な拡張機能を一覧表示', + 'Update extensions. Usage: update |--all': + '拡張機能を更新。使い方: update <拡張機能名>|--all', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + '{{originSource}} から拡張機能をインストールしています。一部の機能は Qwen Code で完全に動作しない可能性があります。', + 'manage IDE integration': 'IDE連携を管理', + 'check status of IDE integration': 'IDE連携の状態を確認', + 'install required IDE companion for {{ideName}}': + '{{ideName}} 用の必要なIDEコンパニオンをインストール', + 'enable IDE integration': 'IDE連携を有効化', + 'disable IDE integration': 'IDE連携を無効化', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + '現在の環境ではIDE連携はサポートされていません。この機能を使用するには、VS Code または VS Code 派生エディタで Qwen Code を実行してください', + 'Set up GitHub Actions': 'GitHub Actions を設定', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + '複数行入力用のターミナルキーバインドを設定(VS Code、Cursor、Windsurf、Trae)', + 'Please restart your terminal for the changes to take effect.': + '変更を有効にするにはターミナルを再起動してください', + 'Failed to configure terminal: {{error}}': + 'ターミナルの設定に失敗: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Windows で {{terminalName}} の設定パスを特定できません: APPDATA 環境変数が設定されていません', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} の keybindings.json は存在しますが、有効なJSON配列ではありません。ファイルを手動で修正するか、削除して自動設定を許可してください', + 'File: {{file}}': 'ファイル: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} の keybindings.json の解析に失敗しました。ファイルに無効なJSONが含まれています。手動で修正するか、削除して自動設定を許可してください', + 'Error: {{error}}': 'エラー: {{error}}', + 'Shift+Enter binding already exists': 'Shift+Enter バインドは既に存在します', + 'Ctrl+Enter binding already exists': 'Ctrl+Enter バインドは既に存在します', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + '既存のキーバインドが検出されました。競合を避けるため変更をしません', + 'Please check and modify manually if needed: {{file}}': + '必要に応じて手動で確認・変更してください: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + '{{terminalName}} に Shift+Enter と Ctrl+Enter のキーバインドを追加しました', + 'Modified: {{file}}': '変更済み: {{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}} のキーバインドは既に設定されています', + 'Failed to configure {{terminalName}}.': + '{{terminalName}} の設定に失敗しました', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'ターミナルは複数行入力(Shift+Enter と Ctrl+Enter)に最適化されています', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Qwen Code のフックを管理する', + 'List all configured hooks': '設定済みのフックをすべて表示する', + 'Enable a disabled hook': '無効なフックを有効にする', + 'Disable an active hook': '有効なフックを無効にする', + // Hooks - Dialog + Hooks: 'フック', + 'Loading hooks...': 'フックを読み込んでいます...', + 'Error loading hooks:': 'フックの読み込みエラー:', + 'Press Escape to close': 'Escape キーで閉じる', + 'Press Escape, Ctrl+C, or Ctrl+D to cancel': + 'Escape、Ctrl+C、Ctrl+D でキャンセル', + 'Press Space, Enter, or Escape to dismiss': 'Space、Enter、Escape で閉じる', + 'No hook selected': 'フックが選択されていません', + // Hooks - List Step + 'No hook events found.': 'フックイベントが見つかりません。', + '{{count}} hook configured': '{{count}} 件のフックが設定されています', + '{{count}} hooks configured': '{{count}} 件のフックが設定されています', + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.': + 'このメニューは読み取り専用です。フックを追加または変更するには、settings.json を直接編集するか、Qwen Code に尋ねてください。', + 'Enter to select · Esc to cancel': 'Enter で選択 · Esc でキャンセル', + // Hooks - Detail Step + 'Exit codes:': '終了コード:', + 'Configured hooks:': '設定済みのフック:', + 'No hooks configured for this event.': + 'このイベントにはフックが設定されていません。', + 'To add hooks, edit settings.json directly or ask Qwen.': + 'フックを追加するには、settings.json を直接編集するか、Qwen に尋ねてください。', + 'Enter to select · Esc to go back': 'Enter で選択 · Esc で戻る', + // Hooks - Config Detail Step + 'Hook details': 'フック詳細', + 'Event:': 'イベント:', + 'Extension:': '拡張機能:', + 'Desc:': '説明:', + 'No hook config selected': 'フック設定が選択されていません', + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.': + 'このフックを変更または削除するには、settings.json を直接編集するか、Qwen に尋ねてください。', + // Hooks - Disabled Step + 'Hook Configuration - Disabled': 'フック設定 - 無効', + 'All hooks are currently disabled. You have {{count}} that are not running.': + 'すべてのフックは現在無効です。{{count}} が実行されていません。', + '{{count}} configured hook': '{{count}} 個の設定されたフック', + '{{count}} configured hooks': '{{count}} 個の設定されたフック', + 'When hooks are disabled:': 'フックが無効な場合:', + 'No hook commands will execute': 'フックコマンドは実行されません', + 'StatusLine will not be displayed': 'StatusLine は表示されません', + 'Tool operations will proceed without hook validation': + 'ツール操作はフック検証なしで続行されます', + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.': + 'フックを再有効化するには、settings.json から "disableAllHooks" を削除するか、Qwen Code に尋ねてください。', + // Hooks - Source + Project: 'プロジェクト', + User: 'ユーザー', + System: 'システム', + Extension: '拡張機能', + 'Local Settings': 'ローカル設定', + 'User Settings': 'ユーザー設定', + 'System Settings': 'システム設定', + Extensions: '拡張機能', + // Hooks - Status + '✓ Enabled': '✓ 有効', + '✗ Disabled': '✗ 無効', + // Hooks - Event Descriptions (short) + 'Before tool execution': 'ツール実行前', + 'After tool execution': 'ツール実行後', + 'After tool execution fails': 'ツール実行失敗時', + 'When notifications are sent': '通知送信時', + 'When the user submits a prompt': 'ユーザーがプロンプトを送信した時', + 'When a new session is started': '新しいセッションが開始された時', + 'Right before Qwen Code concludes its response': + 'Qwen Code が応答を終了する直前', + 'When a subagent (Agent tool call) is started': + 'サブエージェント(Agent ツール呼び出し)が開始された時', + 'Right before a subagent concludes its response': + 'サブエージェントが応答を終了する直前', + 'Before conversation compaction': '会話圧縮前', + 'When a session is ending': 'セッション終了時', + 'When a permission dialog is displayed': '権限ダイアログ表示時', + // Hooks - Event Descriptions (detailed) + 'Input to command is JSON of tool call arguments.': + 'コマンドへの入力はツール呼び出し引数の JSON です。', + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).': + 'コマンドへの入力は "inputs"(ツール呼び出し引数)と "response"(ツール呼び出し応答)フィールドを持つ JSON です。', + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.': + 'コマンドへの入力は tool_name、tool_input、tool_use_id、error、error_type、is_interrupt、is_timeout を持つ JSON です。', + 'Input to command is JSON with notification message and type.': + 'コマンドへの入力は通知メッセージとタイプを持つ JSON です。', + 'Input to command is JSON with original user prompt text.': + 'コマンドへの入力は元のユーザープロンプトテキストを持つ JSON です。', + 'Input to command is JSON with session start source.': + 'コマンドへの入力はセッション開始ソースを持つ JSON です。', + 'Input to command is JSON with session end reason.': + 'コマンドへの入力はセッション終了理由を持つ JSON です。', + 'Input to command is JSON with agent_id and agent_type.': + 'コマンドへの入力は agent_id と agent_type を持つ JSON です。', + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.': + 'コマンドへの入力は agent_id、agent_type、agent_transcript_path を持つ JSON です。', + 'Input to command is JSON with compaction details.': + 'コマンドへの入力は圧縮詳細を持つ JSON です。', + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.': + 'コマンドへの入力は tool_name、tool_input、tool_use_id を持つ JSON です。許可または拒否の決定を含む hookSpecificOutput を持つ JSON を出力します。', + // Hooks - Exit Code Descriptions + 'stdout/stderr not shown': 'stdout/stderr は表示されません', + 'show stderr to model and continue conversation': + 'stderr をモデルに表示し、会話を続ける', + 'show stderr to user only': 'stderr をユーザーのみに表示', + 'stdout shown in transcript mode (ctrl+o)': + 'stdout はトランスクリプトモードで表示 (ctrl+o)', + 'show stderr to model immediately': 'stderr をモデルに即座に表示', + 'show stderr to user only but continue with tool call': + 'stderr をユーザーのみに表示し、ツール呼び出しを続ける', + 'block processing, erase original prompt, and show stderr to user only': + '処理をブロックし、元のプロンプトを消去し、stderr をユーザーのみに表示', + 'stdout shown to Qwen': 'stdout をモデルに表示', + 'show stderr to user only (blocking errors ignored)': + 'stderr をユーザーのみに表示(ブロッキングエラーは無視)', + 'command completes successfully': 'コマンドが正常に完了', + 'stdout shown to subagent': 'stdout をサブエージェントに表示', + 'show stderr to subagent and continue having it run': + 'stderr をサブエージェントに表示し、実行を続ける', + 'stdout appended as custom compact instructions': + 'stdout をカスタム圧縮指示として追加', + 'block compaction': '圧縮をブロック', + 'show stderr to user only but continue with compaction': + 'stderr をユーザーのみに表示し、圧縮を続ける', + 'use hook decision if provided': '提供されている場合はフックの決定を使用', + // Hooks - Messages + 'Config not loaded.': '設定が読み込まれていません。', + 'Hooks are not enabled. Enable hooks in settings to use this feature.': + 'フックが有効になっていません。この機能を使用するには設定でフックを有効にしてください。', + 'No hooks configured. Add hooks in your settings.json file.': + 'フックが設定されていません。settings.json ファイルにフックを追加してください。', + 'Configured Hooks ({{count}} total)': '設定済みのフック(合計 {{count}} 件)', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + '現在のセッションのメッセージ履歴をファイルにエクスポートする', + 'Export session to HTML format': 'セッションを HTML 形式でエクスポートする', + 'Export session to JSON format': 'セッションを JSON 形式でエクスポートする', + 'Export session to JSONL format (one message per line)': + 'セッションを JSONL 形式でエクスポートする(1 行に 1 メッセージ)', + 'Export session to markdown format': + 'セッションを Markdown 形式でエクスポートする', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'チャット履歴からパーソナライズされたプログラミングインサイトを生成する', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': '前のセッションを再開する', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'ツール呼び出しを復元します。これにより、会話とファイルの履歴はそのツール呼び出しが提案された時点の状態に戻ります', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'ターミナルの種類を検出できませんでした。サポートされているターミナル: VS Code、Cursor、Windsurf、Trae', + 'Terminal "{{terminal}}" is not supported yet.': + 'ターミナル "{{terminal}}" はまだサポートされていません', + // Commands - Language + 'Invalid language. Available: {{options}}': + '無効な言語です。使用可能: {{options}}', + 'Language subcommands do not accept additional arguments.': + '言語サブコマンドは追加の引数を受け付けません', + 'Current UI language: {{lang}}': '現在のUI言語: {{lang}}', + 'Current LLM output language: {{lang}}': '現在のLLM出力言語: {{lang}}', + 'LLM output language not set': 'LLM出力言語が設定されていません', + 'Set UI language': 'UI言語を設定', + 'Set LLM output language': 'LLM出力言語を設定', + 'Usage: /language ui [{{options}}]': '使い方: /language ui [{{options}}]', + 'Usage: /language output ': '使い方: /language output <言語>', + 'Example: /language output 中文': '例: /language output 中文', + 'Example: /language output English': '例: /language output English', + 'Example: /language output 日本語': '例: /language output 日本語', + 'Example: /language output Português': '例: /language output Português', + 'UI language changed to {{lang}}': 'UI言語を {{lang}} に変更しました', + 'LLM output language rule file generated at {{path}}': + 'LLM出力言語ルールファイルを {{path}} に生成しました', + 'Please restart the application for the changes to take effect.': + '変更を有効にするにはアプリケーションを再起動してください', + 'Failed to generate LLM output language rule file: {{error}}': + 'LLM出力言語ルールファイルの生成に失敗: {{error}}', + 'Invalid command. Available subcommands:': + '無効なコマンドです。使用可能なサブコマンド:', + 'Available subcommands:': '使用可能なサブコマンド:', + 'To request additional UI language packs, please open an issue on GitHub.': + '追加のUI言語パックをリクエストするには、GitHub で Issue を作成してください', + 'Available options:': '使用可能なオプション:', + 'Set UI language to {{name}}': 'UI言語を {{name}} に設定', + // Approval Mode + 'Approval Mode': '承認モード', + 'Current approval mode: {{mode}}': '現在の承認モード: {{mode}}', + 'Available approval modes:': '利用可能な承認モード:', + 'Approval mode changed to: {{mode}}': '承認モードを変更しました: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + '承認モードを {{mode}} に変更しました({{scope}} 設定{{location}}に保存)', + 'Usage: /approval-mode [--session|--user|--project]': + '使い方: /approval-mode <モード> [--session|--user|--project]', + 'Scope subcommands do not accept additional arguments.': + 'スコープサブコマンドは追加の引数を受け付けません', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'プランモード - 分析のみ、ファイルの変更やコマンドの実行はしません', + 'Default mode - Require approval for file edits or shell commands': + 'デフォルトモード - ファイル編集やシェルコマンドには承認が必要', + 'Auto-edit mode - Automatically approve file edits': + '自動編集モード - ファイル編集を自動承認', + 'YOLO mode - Automatically approve all tools': + 'YOLOモード - すべてのツールを自動承認', + '{{mode}} mode': '{{mode}}モード', + 'Settings service is not available; unable to persist the approval mode.': + '設定サービスが利用できません。承認モードを保存できません', + 'Failed to save approval mode: {{error}}': + '承認モードの保存に失敗: {{error}}', + 'Failed to change approval mode: {{error}}': + '承認モードの変更に失敗: {{error}}', + 'Apply to current session only (temporary)': + '現在のセッションのみに適用(一時的)', + 'Persist for this project/workspace': 'このプロジェクト/ワークスペースに保存', + 'Persist for this user on this machine': 'このマシンのこのユーザーに保存', + 'Analyze only, do not modify files or execute commands': + '分析のみ、ファイルの変更やコマンドの実行はしません', + 'Require approval for file edits or shell commands': + 'ファイル編集やシェルコマンドには承認が必要', + 'Automatically approve file edits': 'ファイル編集を自動承認', + 'Automatically approve all tools': 'すべてのツールを自動承認', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'ワークスペースの承認モードが存在し、優先されます。ユーザーレベルの変更は効果がありません', + '(Use Enter to select, Tab to change focus)': + '(Enter で選択、Tab でフォーカス変更)', + 'Apply To': '適用先', + 'Workspace Settings': 'ワークスペース設定', + // Memory + 'Commands for interacting with memory.': 'メモリ操作のコマンド', + 'Show the current memory contents.': '現在のメモリ内容を表示', + 'Show project-level memory contents.': 'プロジェクトレベルのメモリ内容を表示', + 'Show global memory contents.': 'グローバルメモリ内容を表示', + 'Add content to project-level memory.': + 'プロジェクトレベルのメモリにコンテンツを追加', + 'Add content to global memory.': 'グローバルメモリにコンテンツを追加', + 'Refresh the memory from the source.': 'ソースからメモリを更新', + 'Usage: /memory add --project ': + '使い方: /memory add --project <記憶するテキスト>', + 'Usage: /memory add --global ': + '使い方: /memory add --global <記憶するテキスト>', + 'Attempting to save to project memory: "{{text}}"': + 'プロジェクトメモリへの保存を試行中: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'グローバルメモリへの保存を試行中: "{{text}}"', + 'Current memory content from {{count}} file(s):': + '{{count}} 個のファイルからの現在のメモリ内容:', + 'Memory is currently empty.': 'メモリは現在空です', + 'Project memory file not found or is currently empty.': + 'プロジェクトメモリファイルが見つからないか、現在空です', + 'Global memory file not found or is currently empty.': + 'グローバルメモリファイルが見つからないか、現在空です', + 'Global memory is currently empty.': 'グローバルメモリは現在空です', + 'Global memory content:\n\n---\n{{content}}\n---': + 'グローバルメモリ内容:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + '{{path}} からのプロジェクトメモリ内容:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': 'プロジェクトメモリは現在空です', + 'Refreshing memory from source files...': + 'ソースファイルからメモリを更新中...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'メモリにコンテンツを追加。グローバルメモリには --global、プロジェクトメモリには --project を使用', + 'Usage: /memory add [--global|--project] ': + '使い方: /memory add [--global|--project] <記憶するテキスト>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'メモリ {{scope}} への保存を試行中: "{{fact}}"', + // MCP + 'Authenticate with an OAuth-enabled MCP server': + 'OAuth対応のMCPサーバーで認証', + 'List configured MCP servers and tools': + '設定済みのMCPサーバーとツールを一覧表示', + 'No MCP servers configured.': 'MCPサーバーが設定されていません', + 'Restarts MCP servers.': 'MCPサーバーを再起動します', + 'Could not retrieve tool registry.': 'ツールレジストリを取得できませんでした', + 'No MCP servers configured with OAuth authentication.': + 'OAuth認証が設定されたMCPサーバーはありません', + 'MCP servers with OAuth authentication:': 'OAuth認証のMCPサーバー:', + 'Use /mcp auth to authenticate.': + '認証するには /mcp auth <サーバー名> を使用', + "MCP server '{{name}}' not found.": "MCPサーバー '{{name}}' が見つかりません", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "'{{name}}' の認証とツール更新に成功しました", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "MCPサーバー '{{name}}' での認証に失敗: {{error}}", + "Re-discovering tools from '{{name}}'...": + "'{{name}}' からツールを再検出中...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "'{{name}}' から {{count}} 個のツールを検出しました。", + 'Authentication complete. Returning to server details...': + '認証完了。サーバー詳細に戻ります...', + 'Authentication successful.': '認証成功。', + 'If the browser does not open, copy and paste this URL into your browser:': + 'ブラウザが開かない場合は、このURLをコピーしてブラウザに貼り付けてください:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ URL全体をコピーしてください——複数行にまたがる場合があります。', + 'Configured MCP servers:': '設定済みMCPサーバー:', + Ready: '準備完了', + Disconnected: '切断', + '{{count}} tool': '{{count}} ツール', + '{{count}} tools': '{{count}} ツール', + 'Restarting MCP servers...': 'MCPサーバーを再起動中...', + // Chat + 'Manage conversation history.': '会話履歴を管理します', + 'List saved conversation checkpoints': + '保存された会話チェックポイントを一覧表示', + 'No saved conversation checkpoints found.': + '保存された会話チェックポイントが見つかりません', + 'List of saved conversations:': '保存された会話の一覧:', + 'Note: Newest last, oldest first': + '注: 最新のものが下にあり、過去のものが上にあります', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + '現在の会話をチェックポイントとして保存。使い方: /chat save <タグ>', + 'Missing tag. Usage: /chat save ': + 'タグが不足しています。使い方: /chat save <タグ>', + 'Delete a conversation checkpoint. Usage: /chat delete ': + '会話チェックポイントを削除。使い方: /chat delete <タグ>', + 'Missing tag. Usage: /chat delete ': + 'タグが不足しています。使い方: /chat delete <タグ>', + "Conversation checkpoint '{{tag}}' has been deleted.": + "会話チェックポイント '{{tag}}' を削除しました", + "Error: No checkpoint found with tag '{{tag}}'.": + "エラー: タグ '{{tag}}' のチェックポイントが見つかりません", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'チェックポイントから会話を再開。使い方: /chat resume <タグ>', + 'Missing tag. Usage: /chat resume ': + 'タグが不足しています。使い方: /chat resume <タグ>', + 'No saved checkpoint found with tag: {{tag}}.': + 'タグ {{tag}} のチェックポイントが見つかりません', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'タグ {{tag}} のチェックポイントは既に存在します。上書きしますか?', + 'No chat client available to save conversation.': + '会話を保存するためのチャットクライアントがありません', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'タグ {{tag}} で会話チェックポイントを保存しました', + 'No conversation found to save.': '保存する会話が見つかりません', + 'No chat client available to share conversation.': + '会話を共有するためのチャットクライアントがありません', + 'Invalid file format. Only .md and .json are supported.': + '無効なファイル形式です。.md と .json のみサポートされています', + 'Error sharing conversation: {{error}}': '会話の共有中にエラー: {{error}}', + 'Conversation shared to {{filePath}}': '会話を {{filePath}} に共有しました', + 'No conversation found to share.': '共有する会話が見つかりません', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + '現在の会話をmarkdownまたはjsonファイルに共有。使い方: /chat share <ファイル>', + // Summary + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'プロジェクトサマリーを生成し、.qwen/PROJECT_SUMMARY.md に保存', + 'No chat client available to generate summary.': + 'サマリーを生成するためのチャットクライアントがありません', + 'Already generating summary, wait for previous request to complete': + 'サマリー生成中です。前のリクエストの完了をお待ちください', + 'No conversation found to summarize.': '要約する会話が見つかりません', + 'Failed to generate project context summary: {{error}}': + 'プロジェクトコンテキストサマリーの生成に失敗: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'プロジェクトサマリーを {{filePathForDisplay}} に保存しました', + 'Saving project summary...': 'プロジェクトサマリーを保存中...', + 'Generating project summary...': 'プロジェクトサマリーを生成中...', + 'Failed to generate summary - no text content received from LLM response': + 'サマリーの生成に失敗 - LLMレスポンスからテキストコンテンツを受信できませんでした', + // Model + 'Switch the model for this session': 'このセッションのモデルを切り替え', + 'Set fast model for background tasks': + 'バックグラウンドタスク用の高速モデルを設定', + 'Content generator configuration not available.': + 'コンテンツジェネレーター設定が利用できません', + 'Authentication type not available.': '認証タイプが利用できません', + 'No models available for the current authentication type ({{authType}}).': + '現在の認証タイプ({{authType}})で利用可能なモデルはありません', + // Clear + 'Starting a new session, resetting chat, and clearing terminal.': + '新しいセッションを開始し、チャットをリセットし、ターミナルをクリアしています', + 'Starting a new session and clearing.': + '新しいセッションを開始してクリアしています', + // Compress + 'Already compressing, wait for previous request to complete': + '圧縮中です。前のリクエストの完了をお待ちください', + 'Failed to compress chat history.': 'チャット履歴の圧縮に失敗しました', + 'Failed to compress chat history: {{error}}': + 'チャット履歴の圧縮に失敗: {{error}}', + 'Compressing chat history': 'チャット履歴を圧縮中', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'チャット履歴を {{originalTokens}} トークンから {{newTokens}} トークンに圧縮しました', + 'Compression was not beneficial for this history size.': + 'この履歴サイズには圧縮の効果がありませんでした', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'チャット履歴の圧縮でサイズが減少しませんでした。圧縮プロンプトに問題がある可能性があります', + 'Could not compress chat history due to a token counting error.': + 'トークンカウントエラーのため、チャット履歴を圧縮できませんでした', + 'Chat history is already compressed.': 'チャット履歴は既に圧縮されています', + // Directory + 'Configuration is not available.': '設定が利用できません', + 'Please provide at least one path to add.': + '追加するパスを少なくとも1つ指定してください', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + '制限的なサンドボックスプロファイルでは /directory add コマンドはサポートされていません。代わりにセッション開始時に --include-directories を使用してください', + "Error adding '{{path}}': {{error}}": + "'{{path}}' の追加中にエラー: {{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + '以下のディレクトリから QWEN.md ファイルを追加しました(存在する場合):\n- {{directories}}', + 'Error refreshing memory: {{error}}': 'メモリの更新中にエラー: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'ディレクトリを正常に追加しました:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + '現在のワークスペースディレクトリ:\n{{directories}}', + // Docs + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'ドキュメントを表示するには、ブラウザで以下のURLを開いてください:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + ' ブラウザでドキュメントを開きました: {{url}}', + // Dialogs - Tool Confirmation + 'Do you want to proceed?': '続行しますか?', + 'Yes, allow once': 'はい(今回のみ許可)', + 'Allow always': '常に許可する', + Yes: 'はい', + No: 'いいえ', + 'No (esc)': 'いいえ (Esc)', + 'Yes, allow always for this session': 'はい、このセッションで常に許可', + + // MCP Management - Core translations + 'Manage MCP servers': 'MCPサーバーを管理', + 'Server Detail': 'サーバー詳細', + 'Disable Server': 'サーバーを無効化', + Tools: 'ツール', + 'Tool Detail': 'ツール詳細', + 'MCP Management': 'MCP管理', + 'Loading...': '読み込み中...', + 'Unknown step': '不明なステップ', + 'Esc to back': 'Esc 戻る', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ ナビゲート · Enter 選択 · Esc 閉じる', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ ナビゲート · Enter 選択 · Esc 戻る', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ ナビゲート · Enter 確認 · Esc 戻る', + 'User Settings (global)': 'ユーザー設定(グローバル)', + 'Workspace Settings (project-specific)': + 'ワークスペース設定(プロジェクト固有)', + 'Disable server:': 'サーバーを無効化:', + 'Select where to add the server to the exclude list:': + 'サーバーを除外リストに追加する場所を選択してください:', + 'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル', + Disable: '無効化', + Enable: '有効化', + Authenticate: '認証', + 'Re-authenticate': '再認証', + 'Clear Authentication': '認証をクリア', + disabled: '無効', + 'Server:': 'サーバー:', + Reconnect: '再接続', + 'View tools': 'ツールを表示', + 'Status:': 'ステータス:', + 'Source:': 'ソース:', + 'Command:': 'コマンド:', + 'Working Directory:': '作業ディレクトリ:', + 'Capabilities:': '機能:', + 'No server selected': 'サーバーが選択されていません', + '(disabled)': '(無効)', + 'Error:': 'エラー:', + tool: 'ツール', + tools: 'ツール', + connected: '接続済み', + connecting: '接続中', + disconnected: '切断済み', + error: 'エラー', + + // MCP Server List + 'User MCPs': 'ユーザーMCP', + 'Project MCPs': 'プロジェクトMCP', + 'Extension MCPs': '拡張機能MCP', + server: 'サーバー', + servers: 'サーバー', + 'Add MCP servers to your settings to get started.': + '設定にMCPサーバーを追加して開始してください。', + 'Run qwen --debug to see error logs': + 'qwen --debug を実行してエラーログを確認してください', + + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth 認証', + 'Press Enter to start authentication, Esc to go back': + 'Enter で認証開始、Esc で戻る', + 'Authenticating... Please complete the login in your browser.': + '認証中... ブラウザでログインを完了してください。', + 'Press Enter or Esc to go back': 'Enter または Esc で戻る', + + // MCP Tool List + 'No tools available for this server.': + 'このサーバーには使用可能なツールがありません。', + destructive: '破壊的', + 'read-only': '読み取り専用', + 'open-world': 'オープンワールド', + idempotent: '冪等', + 'Tools for {{name}}': '{{name}} のツール', + 'Tools for {{serverName}}': '{{serverName}} のツール', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: '必須', + Type: '型', + Enum: '列挙', + Parameters: 'パラメータ', + 'No tool selected': 'ツールが選択されていません', + Annotations: '注釈', + Title: 'タイトル', + 'Read Only': '読み取り専用', + Destructive: '破壊的', + Idempotent: '冪等', + 'Open World': 'オープンワールド', + Server: 'サーバー', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 個の無効なツール', + invalid: '無効', + 'invalid: {{reason}}': '無効: {{reason}}', + 'missing name': '名前なし', + 'missing description': '説明なし', + '(unnamed)': '(名前なし)', + 'Warning: This tool cannot be called by the LLM': + '警告: このツールはLLMによって呼び出すことができません', + Reason: '理由', + 'Tools must have both name and description to be used by the LLM.': + 'ツールはLLMによって使用されるには名前と説明の両方が必要です。', + 'Modify in progress:': '変更中:', + 'Save and close external editor to continue': + '続行するには外部エディタを保存して閉じてください', + 'Apply this change?': 'この変更を適用しますか?', + 'Yes, allow always': 'はい、常に許可', + 'Modify with external editor': '外部エディタで編集', + 'No, suggest changes (esc)': 'いいえ、変更を提案 (Esc)', + "Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?", + 'Yes, allow always ...': 'はい、常に許可...', + 'Always allow in this project': 'このプロジェクトで常に許可', + 'Always allow {{action}} in this project': + 'このプロジェクトで{{action}}を常に許可', + 'Always allow for this user': 'このユーザーに常に許可', + 'Always allow {{action}} for this user': 'このユーザーに{{action}}を常に許可', + 'Yes, restore previous mode ({{mode}})': + 'はい、以前のモードに戻す ({{mode}})', + 'Yes, and auto-accept edits': 'はい、編集を自動承認', + 'Yes, and manually approve edits': 'はい、編集を手動承認', + 'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)', + 'URLs to fetch:': '取得するURL:', + 'MCP Server: {{server}}': 'MCPサーバー: {{server}}', + 'Tool: {{tool}}': 'ツール: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'サーバー "{{server}}" からの MCPツール "{{tool}}" の実行を許可しますか?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'はい、サーバー "{{server}}" からのツール "{{tool}}" を常に許可', + 'Yes, always allow all tools from server "{{server}}"': + 'はい、サーバー "{{server}}" からのすべてのツールを常に許可', + // Dialogs - Shell Confirmation + 'Shell Command Execution': 'シェルコマンド実行', + 'A custom command wants to run the following shell commands:': + 'カスタムコマンドが以下のシェルコマンドを実行しようとしています:', + // Dialogs - Pro Quota + 'Pro quota limit reached for {{model}}.': + '{{model}} のProクォータ上限に達しました', + 'Change auth (executes the /auth command)': + '認証を変更(/auth コマンドを実行)', + 'Continue with {{model}}': '{{model}} で続行', + // Dialogs - Welcome Back + 'Current Plan:': '現在のプラン:', + 'Progress: {{done}}/{{total}} tasks completed': + '進捗: {{done}}/{{total}} タスク完了', + ', {{inProgress}} in progress': '、{{inProgress}} 進行中', + 'Pending Tasks:': '保留中のタスク:', + 'What would you like to do?': '何をしますか?', + 'Choose how to proceed with your session:': + 'セッションの続行方法を選択してください:', + 'Start new chat session': '新しいチャットセッションを開始', + 'Continue previous conversation': '前回の会話を続行', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 おかえりなさい!(最終更新: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 全体目標:', + // Dialogs - Auth + 'Get started': '始める', + 'Select Authentication Method': '認証方法を選択', + 'OpenAI API key is required to use OpenAI authentication.': + 'OpenAI認証を使用するには OpenAI APIキーが必要です', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + '続行するには認証方法を選択してください。Ctrl+C をもう一度押すと終了します', + 'Terms of Services and Privacy Notice': '利用規約とプライバシー通知', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + '有料 \u00B7 5時間最大6,000リクエスト \u00B7 すべての Alibaba Cloud Coding Plan モデル', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': '自分のAPIキーを使用', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'ログインに失敗しました。メッセージ: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + '認証は {{enforcedType}} に強制されていますが、現在 {{currentType}} を使用しています', + 'Please visit this URL to authorize:': + '認証するには以下のURLにアクセスしてください:', + 'Or scan the QR code below:': 'または以下のQRコードをスキャン:', + 'Waiting for authorization': '認証を待っています', + 'Time remaining:': '残り時間:', + '(Press ESC or CTRL+C to cancel)': '(ESC または CTRL+C でキャンセル)', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuthトークンが期限切れです({{seconds}}秒以上)。認証方法を再度選択してください', + 'Press any key to return to authentication type selection.': + '認証タイプ選択に戻るには任意のキーを押してください', + 'Authentication timed out. Please try again.': + '認証がタイムアウトしました。再度お試しください', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + '認証を待っています... (ESC または CTRL+C でキャンセル)', + 'Failed to authenticate. Message: {{message}}': + '認証に失敗しました。メッセージ: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + '{{authType}} 認証情報で正常に認証されました', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + '無効な QWEN_DEFAULT_AUTH_TYPE 値: "{{value}}"。有効な値: {{validValues}}', + 'OpenAI Configuration Required': 'OpenAI設定が必要です', + 'Please enter your OpenAI configuration. You can get an API key from': + 'OpenAI設定を入力してください。APIキーは以下から取得できます', + 'API Key:': 'APIキー:', + 'Invalid credentials: {{errorMessage}}': '無効な認証情報: {{errorMessage}}', + 'Failed to validate credentials': '認証情報の検証に失敗しました', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Enter で続行、Tab/↑↓ で移動、Esc でキャンセル', + // Dialogs - Model + 'Select Model': 'モデルを選択', + '(Press Esc to close)': '(Esc で閉じる)', + Modality: 'モダリティ', + 'Context Window': 'コンテキストウィンドウ', + text: 'テキスト', + 'text-only': 'テキストのみ', + image: '画像', + pdf: 'PDF', + audio: '音声', + video: '動画', + 'not set': '未設定', + none: 'なし', + unknown: '不明', + 'Qwen 3.6 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.6 Plus — 効率的なハイブリッドモデル、業界トップクラスのコーディング性能', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)', + // Dialogs - Permissions + 'Manage folder trust settings': 'フォルダ信頼設定を管理', + 'Manage permission rules': '権限ルールを管理', + Allow: '許可', + Ask: '確認', + Deny: '拒否', + Workspace: 'ワークスペース', + "Qwen Code won't ask before using allowed tools.": + 'Qwen Code は許可されたツールを使用する前に確認しません。', + 'Qwen Code will ask before using these tools.': + 'Qwen Code はこれらのツールを使用する前に確認します。', + 'Qwen Code is not allowed to use denied tools.': + 'Qwen Code は拒否されたツールを使用できません。', + 'Manage trusted directories for this workspace.': + 'このワークスペースの信頼済みディレクトリを管理します。', + 'Any use of the {{tool}} tool': '{{tool}} ツールのすべての使用', + "{{tool}} commands matching '{{pattern}}'": + "'{{pattern}}' に一致する {{tool}} コマンド", + 'From user settings': 'ユーザー設定から', + 'From project settings': 'プロジェクト設定から', + 'From session': 'セッションから', + 'Project settings (local)': 'プロジェクト設定(ローカル)', + 'Saved in .qwen/settings.local.json': '.qwen/settings.local.json に保存', + 'Project settings': 'プロジェクト設定', + 'Checked in at .qwen/settings.json': '.qwen/settings.json にチェックイン', + 'User settings': 'ユーザー設定', + 'Saved in at ~/.qwen/settings.json': '~/.qwen/settings.json に保存', + 'Add a new rule…': '新しいルールを追加…', + 'Add {{type}} permission rule': '{{type}}権限ルールを追加', + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.': + '権限ルールはツール名で、オプションで括弧内に指定子を付けます。', + 'e.g.,': '例:', + or: 'または', + 'Enter permission rule…': '権限ルールを入力…', + 'Enter to submit · Esc to cancel': 'Enter で送信 · Esc でキャンセル', + 'Where should this rule be saved?': 'このルールをどこに保存しますか?', + 'Enter to confirm · Esc to cancel': 'Enter で確認 · Esc でキャンセル', + 'Delete {{type}} rule?': '{{type}}ルールを削除しますか?', + 'Are you sure you want to delete this permission rule?': + 'この権限ルールを削除してもよろしいですか?', + 'Permissions:': '権限:', + '(←/→ or tab to cycle)': '(←/→ または Tab で切替)', + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel': + '↑↓ でナビゲート · Enter で選択 · 入力で検索 · Esc でキャンセル', + 'Search…': '検索…', + 'Use /trust to manage folder trust settings for this workspace.': + '/trust を使用してこのワークスペースのフォルダ信頼設定を管理します。', + // Workspace directory management + 'Add directory…': 'ディレクトリを追加…', + 'Add directory to workspace': 'ワークスペースにディレクトリを追加', + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.': + 'Qwen Code はワークスペース内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。', + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.': + 'Qwen Code はこのディレクトリ内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。', + 'Enter the path to the directory:': 'ディレクトリのパスを入力してください:', + 'Enter directory path…': 'ディレクトリパスを入力…', + 'Tab to complete · Enter to add · Esc to cancel': + 'Tab で補完 · Enter で追加 · Esc でキャンセル', + 'Remove directory?': 'ディレクトリを削除しますか?', + 'Are you sure you want to remove this directory from the workspace?': + 'このディレクトリをワークスペースから削除してもよろしいですか?', + ' (Original working directory)': ' (元の作業ディレクトリ)', + ' (from settings)': ' (設定より)', + 'Directory does not exist.': 'ディレクトリが存在しません。', + 'Path is not a directory.': 'パスはディレクトリではありません。', + 'This directory is already in the workspace.': + 'このディレクトリはすでにワークスペースに含まれています。', + 'Already covered by existing directory: {{dir}}': + '既存のディレクトリによって既にカバーされています: {{dir}}', + // Status Bar + 'Using:': '使用中:', + '{{count}} open file': '{{count}} 個のファイルを開いています', + '{{count}} open files': '{{count}} 個のファイルを開いています', + '(ctrl+g to view)': '(Ctrl+G で表示)', + '{{count}} {{name}} file': '{{count}} {{name}} ファイル', + '{{count}} {{name}} files': '{{count}} {{name}} ファイル', + '{{count}} MCP server': '{{count}} MCPサーバー', + '{{count}} MCP servers': '{{count}} MCPサーバー', + '{{count}} Blocked': '{{count}} ブロック', + '(ctrl+t to view)': '(Ctrl+T で表示)', + '(ctrl+t to toggle)': '(Ctrl+T で切り替え)', + 'Press Ctrl+C again to exit.': 'Ctrl+C をもう一度押すと終了します', + 'Press Ctrl+D again to exit.': 'Ctrl+D をもう一度押すと終了します', + 'Press Esc again to clear.': 'Esc をもう一度押すとクリアします', + // MCP Status + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCPサーバーを起動中({{count}} 初期化中)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + '注: 初回起動には時間がかかる場合があります。ツールの利用可能状況は自動的に更新されます', + 'Starting... (first startup may take longer)': + '起動中...(初回起動には時間がかかる場合があります)', + '{{count}} prompt': '{{count}} プロンプト', + '{{count}} prompts': '{{count}} プロンプト', + '(from {{extensionName}})': '({{extensionName}} から)', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth 期限切れ', + 'OAuth not authenticated': 'OAuth 未認証', + 'tools and prompts will appear when ready': + 'ツールとプロンプトは準備完了後に表示されます', + '{{count}} tools cached': '{{count}} ツール(キャッシュ済み)', + 'Tools:': 'ツール:', + 'Parameters:': 'パラメータ:', + 'Prompts:': 'プロンプト:', + Blocked: 'ブロック', + '💡 Tips:': '💡 ヒント:', + Use: '使用', + 'to show server and tool descriptions': 'サーバーとツールの説明を表示', + 'to show tool parameter schemas': 'ツールパラメータスキーマを表示', + 'to hide descriptions': '説明を非表示', + 'to authenticate with OAuth-enabled servers': 'OAuth対応サーバーで認証', + Press: '押す', + 'to toggle tool descriptions on/off': 'ツール説明の表示/非表示を切り替え', + "Starting OAuth authentication for MCP server '{{name}}'...": + "MCPサーバー '{{name}}' のOAuth認証を開始中...", + // Startup Tips + 'Tips:': 'ヒント:', + 'Use /compress when the conversation gets long to summarize history and free up context.': + '会話が長くなったら /compress で履歴を要約し、コンテキストを解放できます。', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + '/clear または /new で新しいアイデアを始められます。前のセッションは履歴に残ります。', + 'Use /bug to submit issues to the maintainers when something goes off.': + '問題が発生したら /bug でメンテナーに報告できます。', + 'Switch auth type quickly with /auth.': + '/auth で認証タイプをすばやく切り替えられます。', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Qwen Code から ! を使って任意のシェルコマンドを実行できます(例: !ls)。', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + '/ を入力してコマンドポップアップを開きます。Tab でスラッシュコマンドと保存済みプロンプトを補完できます。', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'qwen --continue または qwen --resume で前の会話を再開できます。', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + 'Shift+Tab または /approval-mode で権限モードをすばやく切り替えられます。', + 'You can switch permission mode quickly with Tab or /approval-mode.': + 'Tab または /approval-mode で権限モードをすばやく切り替えられます。', + 'Try /insight to generate personalized insights from your chat history.': + '/insight でチャット履歴からパーソナライズされたインサイトを生成できます。', + 'Tips for getting started:': '始めるためのヒント:', + '1. Ask questions, edit files, or run commands.': + '1. 質問したり、ファイルを編集したり、コマンドを実行したりできます', + '2. Be specific for the best results.': + '2. 具体的に指示すると最良の結果が得られます', + 'files to customize your interactions with Qwen Code.': + 'Qwen Code との対話をカスタマイズするためのファイル', + 'for more information.': '詳細情報を確認できます', + // Exit Screen / Stats + 'Agent powering down. Goodbye!': 'エージェントを終了します。さようなら!', + 'To continue this session, run': 'このセッションを続行するには、次を実行:', + 'Interaction Summary': 'インタラクション概要', + 'Session ID:': 'セッションID:', + 'Tool Calls:': 'ツール呼び出し:', + 'Success Rate:': '成功率:', + 'User Agreement:': 'ユーザー同意:', + reviewed: 'レビュー済み', + 'Code Changes:': 'コード変更:', + Performance: 'パフォーマンス', + 'Wall Time:': '経過時間:', + 'Agent Active:': 'エージェント稼働時間:', + 'API Time:': 'API時間:', + 'Tool Time:': 'ツール時間:', + 'Session Stats': 'セッション統計', + 'Model Usage': 'モデル使用量', + Reqs: 'リクエスト', + 'Input Tokens': '入力トークン', + 'Output Tokens': '出力トークン', + 'Savings Highlight:': '節約ハイライト:', + 'of input tokens were served from the cache, reducing costs.': + '入力トークンがキャッシュから提供され、コストを削減しました', + 'Tip: For a full token breakdown, run `/stats model`.': + 'ヒント: トークンの詳細な内訳は `/stats model` を実行してください', + 'Model Stats For Nerds': 'マニア向けモデル統計', + 'Tool Stats For Nerds': 'マニア向けツール統計', + Metric: 'メトリック', + API: 'API', + Requests: 'リクエスト', + Errors: 'エラー', + 'Avg Latency': '平均レイテンシ', + Tokens: 'トークン', + Total: '合計', + Prompt: 'プロンプト', + Cached: 'キャッシュ', + Thoughts: '思考', + Tool: 'ツール', + Output: '出力', + 'No API calls have been made in this session.': + 'このセッションではAPI呼び出しが行われていません', + 'Tool Name': 'ツール名', + Calls: '呼び出し', + 'Success Rate': '成功率', + 'Avg Duration': '平均時間', + 'User Decision Summary': 'ユーザー決定サマリー', + 'Total Reviewed Suggestions:': '総レビュー提案数:', + ' » Accepted:': ' » 承認:', + ' » Rejected:': ' » 却下:', + ' » Modified:': ' » 変更:', + ' Overall Agreement Rate:': ' 全体承認率:', + 'No tool calls have been made in this session.': + 'このセッションではツール呼び出しが行われていません', + 'Session start time is unavailable, cannot calculate stats.': + 'セッション開始時刻が利用できないため、統計を計算できません', + // Loading + 'Waiting for user confirmation...': 'ユーザーの確認を待っています...', + '(esc to cancel, {{time}})': '(Esc でキャンセル、{{time}})', + // Witty Loading Phrases + WITTY_LOADING_PHRASES: [ + '運任せで検索中...', + '中の人がタイピング中...', + 'ロジックを最適化中...', + '電子の数を確認中...', + '宇宙のバグをチェック中...', + '大量の0と1をコンパイル中...', + 'HDDと思い出をデフラグ中...', + 'ビットをこっそり入れ替え中...', + 'ニューロンの接続を再構築中...', + 'どこかに行ったセミコロンを捜索中...', + 'フラックスキャパシタを調整中...', + 'フォースと交感中...', + 'アルゴリズムをチューニング中...', + '白いウサギを追跡中...', + 'カセットフーフー中...', + 'ローディングメッセージを考え中...', + 'ほぼ完了...多分...', + '最新のミームについて調査中...', + 'この表示を改善するアイデアを思索中...', + 'この問題を考え中...', + 'それはバグでなく誰も知らない新機能だよ', + 'ダイヤルアップ接続音が終わるのを待機中...', + 'コードに油を追加中...', + + // かなり意訳が入ってるもの + 'イヤホンをほどき中...', + 'カフェインをコードに変換中...', + '天動説を地動説に書き換え中...', + 'プールで時計の完成を待機中...', + '笑撃的な回答を用意中...', + '適切なミームを記述中...', + 'Aボタンを押して次へ...', + 'コードにリックロールを仕込み中...', + 'プログラマーが貧乏なのはキャッシュを使いすぎるから...', + 'プログラマーがダークモードなのはバグを見たくないから...', + 'コードが壊れた?叩けば治るさ', + 'USBの差し込みに挑戦中...', + ], + + // ============================================================================ + // Custom API Key Configuration + // ============================================================================ + 'You can configure your API key and models in settings.json': + 'settings.json で API キーとモデルを設定できます', + 'Refer to the documentation for setup instructions': + 'セットアップ手順はドキュメントを参照してください', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'API key cannot be empty.': 'APIキーは空にできません。', + 'You can get your Coding Plan API key here': + 'Coding Plan APIキーはこちらで取得できます', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan の API キーが見つかりません。Coding Plan で再認証してください。', + 'Failed to update Coding Plan configuration: {{message}}': + 'Coding Plan の設定更新に失敗しました: {{message}}', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of ModelStudio Coding Plan and you're all set!": + 'ModelStudio Coding PlanのAPIキーを貼り付けるだけで準備完了です!', + Custom: 'カスタム', + 'More instructions about configuring `modelProviders` manually.': + '`modelProviders`を手動で設定する方法の詳細はこちら。', + 'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください:', + '(Press Escape to go back)': '(Escapeキーで戻る)', + '(Press Enter to submit, Escape to cancel)': + '(Enterで送信、Escapeでキャンセル)', + 'More instructions please check:': '詳細な手順はこちらをご確認ください:', + 'Select Region for Coding Plan': 'Coding Planのリージョンを選択', + 'Choose based on where your account is registered': + 'アカウントの登録先に応じて選択してください', + 'Enter Coding Plan API Key': 'Coding Plan APIキーを入力', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '{{region}} での認証に成功しました。API キーとモデル設定が settings.json に保存されました(バックアップ済み)。', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'コンテキスト使用量', + 'No API response yet. Send a message to see actual usage.': + 'API応答はありません。メッセージを送信して実際の使用量を確認してください。', + 'Estimated pre-conversation overhead': '推定事前会話オーバーヘッド', + 'Context window': 'コンテキストウィンドウ', + tokens: 'トークン', + Used: '使用済み', + Free: '空き', + 'Autocompact buffer': '自動圧縮バッファ', + 'Usage by category': 'カテゴリ別の使用量', + 'System prompt': 'システムプロンプト', + 'Built-in tools': '組み込みツール', + 'MCP tools': 'MCPツール', + 'Memory files': 'メモリファイル', + Skills: 'スキル', + Messages: 'メッセージ', + 'Show context window usage breakdown.': + 'コンテキストウィンドウの使用状況を表示します。', + 'Run /context detail for per-item breakdown.': + '/context detail を実行すると項目ごとの内訳を表示します。', + active: '有効', + 'body loaded': '本文読み込み済み', + memory: 'メモリ', + '{{region}} configuration updated successfully.': + '{{region}} の設定が正常に更新されました。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。', + 'Tip: Use /model to switch between available Coding Plan models.': + 'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': '以下の質問に答えてください:', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + '非対話モードではユーザーに質問できません。このツールを使用するには対話モードで実行してください。', + 'User declined to answer the questions.': + 'ユーザーは質問への回答を拒否しました。', + 'User has provided the following answers:': + 'ユーザーは以下の回答を提供しました:', + 'Failed to process user answers:': 'ユーザー回答の処理に失敗しました:', + 'Type something...': '何か入力...', + Submit: '送信', + 'Submit answers': '回答を送信', + Cancel: 'キャンセル', + 'Your answers:': 'あなたの回答:', + '(not answered)': '(未回答)', + 'Ready to submit your answers?': '回答を送信しますか?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: ナビゲート | ←/→: タブ切り替え | Enter: 選択', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: ナビゲート | ←/→: タブ切り替え | Space/Enter: 切り替え | Esc: キャンセル', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Authenticate using Alibaba Cloud Coding Plan': + 'Alibaba Cloud Coding Plan で認証する', + 'Region for Coding Plan (china/global)': + 'Coding Plan のリージョン (china/global)', + 'API key for Coding Plan': 'Coding Plan の API キー', + 'Show current authentication status': '現在の認証ステータスを表示', + 'Authentication completed successfully.': '認証が正常に完了しました。', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Alibaba Cloud Coding Plan 認証を処理しています...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Alibaba Cloud Coding Plan での認証に成功しました。', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Coding Plan での認証に失敗しました: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'グローバル', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Coding Plan のリージョンを選択:', + 'Enter your Coding Plan API key: ': + 'Coding Plan の API キーを入力してください: ', + 'Select authentication method:': '認証方法を選択:', + '\n=== Authentication Status ===\n': '\n=== 認証ステータス ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ 認証方法が設定されていません。\n', + 'Run one of the following commands to get started:\n': + '以下のコマンドのいずれかを実行して開始してください:\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Alibaba Cloud Coding Plan で認証\n', + 'Or simply run:': 'または以下を実行:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - インタラクティブ認証セットアップ\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ 認証方法: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'グローバル - Alibaba Cloud', + ' Region: {{region}}': ' リージョン: {{region}}', + ' Current Model: {{model}}': ' 現在のモデル: {{model}}', + ' Config Version: {{version}}': ' 設定バージョン: {{version}}', + ' Status: API key configured\n': ' ステータス: APIキー設定済み\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ 認証方法: Alibaba Cloud Coding Plan(不完全)', + ' Issue: API key not found in environment or settings\n': + ' 問題: 環境変数または設定にAPIキーが見つかりません\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' `qwen auth coding-plan` を実行して再設定してください。\n', + '✓ Authentication Method: {{type}}': '✓ 認証方法: {{type}}', + ' Status: Configured\n': ' ステータス: 設定済み\n', + 'Failed to check authentication status: {{error}}': + '認証ステータスの確認に失敗しました: {{error}}', + 'Select an option:': 'オプションを選択:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Rawモードが利用できません。インタラクティブターミナルで実行してください。', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', + verbose: '詳細', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + '詳細モードで完全なツール出力と思考を表示します(Ctrl+O で切り替え)。', + 'Press Ctrl+O to show full tool output': 'Ctrl+O で完全なツール出力を表示', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', + + "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", +}; diff --git a/apps/airiscode-cli/src/i18n/locales/pt.js b/apps/airiscode-cli/src/i18n/locales/pt.js new file mode 100644 index 000000000..3f72b072b --- /dev/null +++ b/apps/airiscode-cli/src/i18n/locales/pt.js @@ -0,0 +1,1949 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Portuguese translations for Qwen Code CLI (pt-BR) + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + 'Basics:': 'Noções básicas:', + 'Add context': 'Adicionar contexto', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Use {{symbol}} para especificar arquivos para o contexto (ex: {{example}}) para atingir arquivos ou pastas específicos.', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Modo shell', + 'YOLO mode': 'Modo YOLO', + 'plan mode': 'modo planejamento', + 'auto-accept edits': 'aceitar edições automaticamente', + 'Accepting edits': 'Aceitando edições', + '(shift + tab to cycle)': '(shift + tab para alternar)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Execute comandos shell via {{symbol}} (ex: {{example1}}) ou use linguagem natural (ex: {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'iniciar servidor', + 'Commands:': 'Comandos:', + 'shell command': 'comando shell', + 'Model Context Protocol command (from external servers)': + 'Comando Model Context Protocol (de servidores externos)', + 'Keyboard Shortcuts:': 'Atalhos de teclado:', + 'Toggle this help display': 'Alternar exibição desta ajuda', + 'Toggle shell mode': 'Alternar modo shell', + 'Open command menu': 'Abrir menu de comandos', + 'Add file context': 'Adicionar contexto de arquivo', + 'Accept suggestion / Autocomplete': 'Aceitar sugestão / Autocompletar', + 'Reverse search history': 'Pesquisa reversa no histórico', + 'Press ? again to close': 'Pressione ? novamente para fechar', + // Keyboard shortcuts panel descriptions + 'for shell mode': 'para modo shell', + 'for commands': 'para comandos', + 'for file paths': 'para caminhos de arquivo', + 'to clear input': 'para limpar entrada', + 'to cycle approvals': 'para alternar aprovações', + 'to quit': 'para sair', + 'for newline': 'para nova linha', + 'to clear screen': 'para limpar a tela', + 'to search history': 'para pesquisar no histórico', + 'to paste images': 'para colar imagens', + 'for external editor': 'para editor externo', + 'Jump through words in the input': 'Pular palavras na entrada', + 'Close dialogs, cancel requests, or quit application': + 'Fechar diálogos, cancelar solicitações ou sair do aplicativo', + 'New line': 'Nova linha', + 'New line (Alt+Enter works for certain linux distros)': + 'Nova linha (Alt+Enter funciona em certas distros linux)', + 'Clear the screen': 'Limpar a tela', + 'Open input in external editor': 'Abrir entrada no editor externo', + 'Send message': 'Enviar mensagem', + 'Initializing...': 'Inicializando...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Conectando aos servidores MCP... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': + 'Digite sua mensagem ou @caminho/do/arquivo', + '? for shortcuts': '? para atalhos', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Pressione 'i' para modo INSERÇÃO e 'Esc' para modo NORMAL.", + 'Cancel operation / Clear input (double press)': + 'Cancelar operação / Limpar entrada (pressionar duas vezes)', + 'Cycle approval modes': 'Alternar modos de aprovação', + 'Cycle through your prompt history': 'Alternar histórico de prompts', + 'For a full list of shortcuts, see {{docPath}}': + 'Para uma lista completa de atalhos, consulte {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'para ajuda sobre o Qwen Code', + 'show version info': 'mostrar informações de versão', + 'submit a bug report': 'enviar um relatório de erro', + 'About Qwen Code': 'Sobre o Qwen Code', + Status: 'Status', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'Qwen Code': 'Qwen Code', + Runtime: 'Runtime', + OS: 'SO', + Auth: 'Autenticação', + 'CLI Version': 'Versão da CLI', + 'Git Commit': 'Commit do Git', + Model: 'Modelo', + 'Fast Model': 'Modelo Rápido', + Sandbox: 'Sandbox', + 'OS Platform': 'Plataforma do SO', + 'OS Arch': 'Arquitetura do SO', + 'OS Release': 'Versão do SO', + 'Node.js Version': 'Versão do Node.js', + 'NPM Version': 'Versão do NPM', + 'Session ID': 'ID da Sessão', + 'Auth Method': 'Método de Autenticação', + 'Base URL': 'URL Base', + Proxy: 'Proxy', + 'Memory Usage': 'Uso de Memória', + 'IDE Client': 'Cliente IDE', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Analisa o projeto e cria um arquivo QWEN.md personalizado.', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]', + 'List available skills.': 'Listar habilidades disponíveis.', + 'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:', + 'No tools available': 'Nenhuma ferramenta disponível', + 'View or change the approval mode for tool usage': + 'Ver ou alterar o modo de aprovação para uso de ferramentas', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + 'Modo de aprovação inválido "{{arg}}". Modos válidos: {{modes}}', + 'Approval mode set to "{{mode}}"': + 'Modo de aprovação definido como "{{mode}}"', + 'View or change the language setting': + 'Ver ou alterar a configuração de idioma', + 'change the theme': 'alterar o tema', + 'Select Theme': 'Selecionar Tema', + Preview: 'Visualizar', + '(Use Enter to select, Tab to configure scope)': + '(Use Enter para selecionar, Tab para configurar o escopo)', + '(Use Enter to apply scope, Tab to go back)': + '(Use Enter para aplicar o escopo, Tab para voltar)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Configuração de tema indisponível devido à variável de ambiente NO_COLOR.', + 'Theme "{{themeName}}" not found.': 'Tema "{{themeName}}" não encontrado.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Tema "{{themeName}}" não encontrado no escopo selecionado.', + 'Clear conversation history and free up context': + 'Limpar histórico de conversa e liberar contexto', + 'Compresses the context by replacing it with a summary.': + 'Comprime o contexto substituindo-o por um resumo.', + 'open full Qwen Code documentation in your browser': + 'abrir documentação completa do Qwen Code no seu navegador', + 'Configuration not available.': 'Configuração não disponível.', + 'change the auth method': 'alterar o método de autenticação', + 'Configure authentication information for login': + 'Configurar informações de autenticação para login', + 'Copy the last result or code snippet to clipboard': + 'Copiar o último resultado ou trecho de código para a área de transferência', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Gerenciar subagentes para delegação de tarefas especializadas.', + 'Manage existing subagents (view, edit, delete).': + 'Gerenciar subagentes existentes (ver, editar, excluir).', + 'Create a new subagent with guided setup.': + 'Criar um novo subagente com configuração guiada.', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: 'Agentes', + 'Choose Action': 'Escolher Ação', + 'Edit {{name}}': 'Editar {{name}}', + 'Edit Tools: {{name}}': 'Editar Ferramentas: {{name}}', + 'Edit Color: {{name}}': 'Editar Cor: {{name}}', + 'Delete {{name}}': 'Excluir {{name}}', + 'Unknown Step': 'Etapa Desconhecida', + 'Esc to close': 'Esc para fechar', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter para selecionar, ↑↓ para navegar, Esc para fechar', + 'Esc to go back': 'Esc para voltar', + 'Enter to confirm, Esc to cancel': 'Enter para confirmar, Esc para cancelar', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter para selecionar, ↑↓ para navegar, Esc para voltar', + 'Enter to submit, Esc to go back': 'Enter para enviar, Esc para voltar', + 'Invalid step: {{step}}': 'Etapa inválida: {{step}}', + 'No subagents found.': 'Nenhum subagente encontrado.', + "Use '/agents create' to create your first subagent.": + "Use '/agents create' para criar seu primeiro subagente.", + '(built-in)': '(integrado)', + '(overridden by project level agent)': + '(substituído por agente de nível de projeto)', + 'Project Level ({{path}})': 'Nível de Projeto ({{path}})', + 'User Level ({{path}})': 'Nível de Usuário ({{path}})', + 'Built-in Agents': 'Agentes Integrados', + 'Extension Agents': 'Agentes de Extensão', + 'Using: {{count}} agents': 'Usando: {{count}} agentes', + 'View Agent': 'Ver Agente', + 'Edit Agent': 'Editar Agente', + 'Delete Agent': 'Excluir Agente', + Back: 'Voltar', + 'No agent selected': 'Nenhum agente selecionado', + 'File Path: ': 'Caminho do Arquivo: ', + 'Tools: ': 'Ferramentas: ', + 'Color: ': 'Cor: ', + 'Description:': 'Descrição:', + 'System Prompt:': 'Prompt do Sistema:', + 'Open in editor': 'Abrir no editor', + 'Edit tools': 'Editar ferramentas', + 'Edit color': 'Editar cor', + '❌ Error:': '❌ Erro:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Tem certeza que deseja excluir o agente "{{name}}"?', + + // ============================================================================ + // Agents - Creation Wizard + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Nível de Projeto (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'Nível de Usuário (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Subagente criado com sucesso!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'O subagente "{{name}}" foi salvo no nível {{level}}.', + 'Name: ': 'Nome: ', + 'Location: ': 'Localização: ', + '❌ Error saving subagent:': '❌ Erro ao salvar subagente:', + 'Warnings:': 'Avisos:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'O nome "{{name}}" já existe no nível {{level}} - o subagente existente será substituído', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'O nome "{{name}}" existe no nível de usuário - o nível de projeto terá precedência', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'O nome "{{name}}" existe no nível de projeto - o subagente existente terá precedência', + 'Description is over {{length}} characters': + 'A descrição tem mais de {{length}} caracteres', + 'System prompt is over {{length}} characters': + 'O prompt do sistema tem mais de {{length}} caracteres', + + // ============================================================================ + // Agents - Creation Wizard Steps + // ============================================================================ + 'Step {{n}}: Choose Location': 'Etapa {{n}}: Escolher Localização', + 'Step {{n}}: Choose Generation Method': + 'Etapa {{n}}: Escolher Método de Geração', + 'Generate with Qwen Code (Recommended)': 'Gerar com Qwen Code (Recomendado)', + 'Manual Creation': 'Criação Manual', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Descreva o que este subagente deve fazer e quando deve ser usado. (Seja abrangente para melhores resultados)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'ex: Revisor de código especialista que revisa código com base em melhores práticas...', + 'Generating subagent configuration...': + 'Gerando configuração do subagente...', + 'Failed to generate subagent: {{error}}': + 'Falha ao gerar subagente: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Etapa {{n}}: Descreva Seu Subagente', + 'Step {{n}}: Enter Subagent Name': 'Etapa {{n}}: Digite o Nome do Subagente', + 'Step {{n}}: Enter System Prompt': 'Etapa {{n}}: Digite o Prompt do Sistema', + 'Step {{n}}: Enter Description': 'Etapa {{n}}: Digite a Descrição', + + // ============================================================================ + // Agents - Tool Selection + // ============================================================================ + 'Step {{n}}: Select Tools': 'Etapa {{n}}: Selecionar Ferramentas', + 'All Tools (Default)': 'Todas as Ferramentas (Padrão)', + 'All Tools': 'Todas as Ferramentas', + 'Read-only Tools': 'Ferramentas de Somente Leitura', + 'Read & Edit Tools': 'Ferramentas de Leitura e Edição', + 'Read & Edit & Execution Tools': 'Ferramentas de Leitura, Edição e Execução', + 'All tools selected, including MCP tools': + 'Todas as ferramentas selecionadas, incluindo ferramentas MCP', + 'Selected tools:': 'Ferramentas selecionadas:', + 'Read-only tools:': 'Ferramentas de somente leitura:', + 'Edit tools:': 'Ferramentas de edição:', + 'Execution tools:': 'Ferramentas de execução:', + 'Step {{n}}: Choose Background Color': 'Etapa {{n}}: Escolher Cor de Fundo', + 'Step {{n}}: Confirm and Save': 'Etapa {{n}}: Confirmar e Salvar', + + // ============================================================================ + // Agents - Navigation & Instructions + // ============================================================================ + 'Esc to cancel': 'Esc para cancelar', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Pressione Enter para salvar, e para salvar e editar, Esc para voltar', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Pressione Enter para continuar, {{navigation}}Esc para {{action}}', + cancel: 'cancelar', + 'go back': 'voltar', + '↑↓ to navigate, ': '↑↓ para navegar, ', + 'Enter a clear, unique name for this subagent.': + 'Digite um nome claro e único para este subagente.', + 'e.g., Code Reviewer': 'ex: Revisor de Código', + 'Name cannot be empty.': 'O nome não pode estar vazio.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'Escreva o prompt do sistema que define o comportamento deste subagente. Seja abrangente para melhores resultados.', + 'e.g., You are an expert code reviewer...': + 'ex: Você é um revisor de código especialista...', + 'System prompt cannot be empty.': 'O prompt do sistema não pode estar vazio.', + 'Describe when and how this subagent should be used.': + 'Descreva quando e como este subagente deve ser usado.', + 'e.g., Reviews code for best practices and potential bugs.': + 'ex: Revisa o código em busca de melhores práticas e erros potenciais.', + 'Description cannot be empty.': 'A descrição não pode estar vazia.', + 'Failed to launch editor: {{error}}': 'Falha ao iniciar editor: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Falha ao salvar e editar subagente: {{error}}', + + // ============================================================================ + // Commands - General (continued) + // ============================================================================ + 'View and edit Qwen Code settings': 'Ver e editar configurações do Qwen Code', + Settings: 'Configurações', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'Para ver as alterações, o Qwen Code deve ser reiniciado. Pressione r para sair e aplicar as alterações agora.', + 'The command "/{{command}}" is not supported in non-interactive mode.': + 'O comando "/{{command}}" não é suportado no modo não interativo.', + + // ============================================================================ + // Settings Labels + // ============================================================================ + 'Vim Mode': 'Modo Vim', + 'Disable Auto Update': 'Desativar Atualização Automática', + 'Attribution: commit': 'Atribuição: commit', + 'Terminal Bell Notification': 'Notificação Sonora do Terminal', + 'Enable Usage Statistics': 'Ativar Estatísticas de Uso', + Theme: 'Tema', + 'Preferred Editor': 'Editor Preferido', + 'Auto-connect to IDE': 'Conexão Automática com IDE', + 'Enable Prompt Completion': 'Ativar Autocompletar de Prompts', + 'Debug Keystroke Logging': 'Log de Depuração de Teclas', + 'Language: UI': 'Idioma: Interface', + 'Language: Model': 'Idioma: Modelo', + 'Output Format': 'Formato de Saída', + 'Hide Window Title': 'Ocultar Título da Janela', + 'Show Status in Title': 'Mostrar Status no Título', + 'Hide Tips': 'Ocultar Dicas', + 'Show Line Numbers in Code': 'Mostrar Números de Linhas no Código', + 'Show Citations': 'Mostrar Citações', + 'Custom Witty Phrases': 'Frases de Efeito Personalizadas', + 'Show Welcome Back Dialog': 'Mostrar Diálogo de Bem-vindo de Volta', + 'Enable User Feedback': 'Ativar Feedback do Usuário', + 'How is Qwen doing this session? (optional)': + 'Como o Qwen está se saindo nesta sessão? (opcional)', + Bad: 'Ruim', + Fine: 'Bom', + Good: 'Ótimo', + Dismiss: 'Ignorar', + 'Not Sure Yet': 'Não tenho certeza ainda', + 'Any other key': 'Qualquer outra tecla', + 'Disable Loading Phrases': 'Desativar Frases de Carregamento', + 'Screen Reader Mode': 'Modo de Leitor de Tela', + 'IDE Mode': 'Modo IDE', + 'Max Session Turns': 'Máximo de Turnos da Sessão', + 'Skip Next Speaker Check': 'Pular Verificação do Próximo Falante', + 'Skip Loop Detection': 'Pular Detecção de Loop', + 'Skip Startup Context': 'Pular Contexto de Inicialização', + 'Enable OpenAI Logging': 'Ativar Log do OpenAI', + 'OpenAI Logging Directory': 'Diretório de Log do OpenAI', + Timeout: 'Tempo Limite', + 'Max Retries': 'Máximo de Tentativas', + 'Disable Cache Control': 'Desativar Controle de Cache', + 'Memory Discovery Max Dirs': 'Descoberta de Memória Máx. Diretorios', + 'Load Memory From Include Directories': + 'Carregar Memória de Diretórios Incluídos', + 'Respect .gitignore': 'Respeitar .gitignore', + 'Respect .qwenignore': 'Respeitar .qwenignore', + 'Enable Recursive File Search': 'Ativar Pesquisa Recursiva de Arquivos', + 'Disable Fuzzy Search': 'Desativar Pesquisa Difusa', + 'Interactive Shell (PTY)': 'Shell Interativo (PTY)', + 'Show Color': 'Mostrar Cores', + 'Auto Accept': 'Aceitar Automaticamente', + 'Use Ripgrep': 'Usar Ripgrep', + 'Use Builtin Ripgrep': 'Usar Ripgrep Integrado', + 'Enable Tool Output Truncation': 'Ativar Truncamento de Saída de Ferramenta', + 'Tool Output Truncation Threshold': + 'Limite de Truncamento de Saída de Ferramenta', + 'Tool Output Truncation Lines': + 'Linhas de Truncamento de Saída de Ferramenta', + 'Folder Trust': 'Confiança de Pasta', + 'Vision Model Preview': 'Visualização de Modelo de Visão', + 'Tool Schema Compliance': 'Conformidade de Esquema de Ferramenta', + + // Settings enum options + 'Auto (detect from system)': 'Automático (detectar do sistema)', + Text: 'Texto', + JSON: 'JSON', + Plan: 'Planejamento', + Default: 'Padrão', + 'Auto Edit': 'Edição Automática', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'alternar modo vim ligado/desligado', + 'check session stats. Usage: /stats [model|tools]': + 'verificar estatísticas da sessão. Uso: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Mostrar estatísticas de uso específicas do modelo.', + 'Show tool-specific usage statistics.': + 'Mostrar estatísticas de uso específicas da ferramenta.', + 'exit the cli': 'sair da cli', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Abrir diálogo de gerenciamento MCP ou autenticar com servidor habilitado para OAuth', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth', + 'Manage workspace directories': 'Gerenciar diretórios do workspace', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Adicionar diretórios ao workspace. Use vírgula para separar vários caminhos', + 'Show all directories in the workspace': + 'Mostrar todos os diretórios no workspace', + 'set external editor preference': 'definir preferência de editor externo', + 'Select Editor': 'Selecionar Editor', + 'Editor Preference': 'Preferência de Editor', + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.': + 'Estes editores são suportados atualmente. Note que alguns editores não podem ser usados no modo sandbox.', + 'Your preferred editor is:': 'Seu editor preferido é:', + 'Manage extensions': 'Gerenciar extensões', + 'Manage installed extensions': 'Gerenciar extensões instaladas', + 'List active extensions': 'Listar extensões ativas', + 'Update extensions. Usage: update |--all': + 'Atualizar extensões. Uso: update |--all', + 'Disable an extension': 'Desativar uma extensão', + 'Enable an extension': 'Ativar uma extensão', + 'Install an extension from a git repo or local path': + 'Instalar uma extensão de um repositório git ou caminho local', + 'Uninstall an extension': 'Desinstalar uma extensão', + 'No extensions installed.': 'Nenhuma extensão instalada.', + 'Usage: /extensions update |--all': + 'Uso: /extensions update |--all', + 'Extension "{{name}}" not found.': 'Extensão "{{name}}" não encontrada.', + 'No extensions to update.': 'Nenhuma extensão para atualizar.', + 'Usage: /extensions install ': 'Uso: /extensions install ', + 'Installing extension from "{{source}}"...': + 'Instalando extensão de "{{source}}"...', + 'Extension "{{name}}" installed successfully.': + 'Extensão "{{name}}" instalada com sucesso.', + 'Failed to install extension from "{{source}}": {{error}}': + 'Falha ao instalar extensão de "{{source}}": {{error}}', + 'Usage: /extensions uninstall ': + 'Uso: /extensions uninstall ', + 'Uninstalling extension "{{name}}"...': + 'Desinstalando extensão "{{name}}"...', + 'Extension "{{name}}" uninstalled successfully.': + 'Extensão "{{name}}" desinstalada com sucesso.', + 'Failed to uninstall extension "{{name}}": {{error}}': + 'Falha ao desinstalar extensão "{{name}}": {{error}}', + 'Usage: /extensions {{command}} [--scope=]': + 'Uso: /extensions {{command}} [--scope=]', + 'Unsupported scope "{{scope}}", deve ser um de "user" ou "workspace"': + 'Escopo não suportado "{{scope}}", deve ser um de "user" ou "workspace"', + 'Extension "{{name}}" disabled for scope "{{scope}}"': + 'Extensão "{{name}}" desativada para o escopo "{{scope}}"', + 'Extension "{{name}}" enabled for scope "{{scope}}"': + 'Extensão "{{name}}" ativada para o escopo "{{scope}}"', + 'Do you want to continue? [Y/n]: ': 'Você deseja continuar? [Y/n]: ', + 'Do you want to continue?': 'Você deseja continuar?', + 'Installing extension "{{name}}".': 'Instalando extensão "{{name}}".', + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**': + '**As extensões podem introduzir comportamentos inesperados. Certifique-se de ter investigado a fonte da extensão e confie no autor.**', + 'This extension will run the following MCP servers:': + 'Esta extensão executará os seguintes servidores MCP:', + local: 'local', + remote: 'remoto', + 'This extension will add the following commands: {{commands}}.': + 'Esta extensão adicionará os seguintes comandos: {{commands}}.', + 'This extension will append info to your QWEN.md context using {{fileName}}': + 'Esta extensão anexará informações ao seu contexto QWEN.md usando {{fileName}}', + 'This extension will exclude the following core tools: {{tools}}': + 'Esta extensão excluirá as seguintes ferramentas principais: {{tools}}', + 'This extension will install the following skills:': + 'Esta extensão instalará as seguintes habilidades:', + 'This extension will install the following subagents:': + 'Esta extensão instalará os seguintes subagentes:', + 'Installation cancelled for "{{name}}".': + 'Instalação cancelada para "{{name}}".', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Você está instalando uma extensão de {{originSource}}. Alguns recursos podem não funcionar perfeitamente com o Qwen Code.', + '--ref and --auto-update are not applicable for marketplace extensions.': + '--ref e --auto-update não são aplicáveis para extensões de marketplace.', + 'Extension "{{name}}" installed successfully and enabled.': + 'Extensão "{{name}}" instalada com sucesso e ativada.', + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).': + 'Instala uma extensão de uma URL de repositório git, caminho local ou marketplace do claude (marketplace-url:plugin-name).', + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': + 'A URL do github, caminho local ou fonte do marketplace (marketplace-url:plugin-name) da extensão para instalar.', + 'The git ref to install from.': 'A referência git para instalar.', + 'Enable auto-update for this extension.': + 'Ativar atualização automática para esta extensão.', + 'Enable pre-release versions for this extension.': + 'Ativar versões de pré-lançamento para esta extensão.', + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': + 'Reconhecer os riscos de segurança de instalar uma extensão e pular o prompt de confirmação.', + 'The source argument must be provided.': + 'O argumento fonte deve ser fornecido.', + 'Extension "{{name}}" successfully uninstalled.': + 'Extensão "{{name}}" desinstalada com sucesso.', + 'Uninstalls an extension.': 'Desinstala uma extensão.', + 'The name or source path of the extension to uninstall.': + 'O nome ou caminho da fonte da extensão para desinstalar.', + 'Please include the name of the extension to uninstall as a positional argument.': + 'Inclua o nome da extensão para desinstalar como um argumento posicional.', + 'Enables an extension.': 'Ativa uma extensão.', + 'The name of the extension to enable.': 'O nome da extensão para ativar.', + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.': + 'O escopo para ativar a extensão. Se não definido, será ativada em todos os escopos.', + 'Extension "{{name}}" successfully enabled for scope "{{scope}}".': + 'Extensão "{{name}}" ativada com sucesso para o escopo "{{scope}}".', + 'Extension "{{name}}" successfully enabled in all scopes.': + 'Extensão "{{name}}" ativada com sucesso em todos os escopos.', + 'Invalid scope: {{scope}}. Please use one of {{scopes}}.': + 'Escopo inválido: {{scope}}. Use um de {{scopes}}.', + 'Disables an extension.': 'Desativa uma extensão.', + 'The name of the extension to disable.': 'O nome da extensão para desativar.', + 'The scope to disable the extenison in.': + 'O escopo para desativar a extensão.', + 'Extension "{{name}}" successfully disabled for scope "{{scope}}".': + 'Extensão "{{name}}" desativada com sucesso para o escopo "{{scope}}".', + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.': + 'Extensão "{{name}}" atualizada com sucesso: {{oldVersion}} → {{newVersion}}.', + 'Unable to install extension "{{name}}" due to missing install metadata': + 'Não foi possível instalar a extensão "{{name}}" devido à falta de metadados de instalação', + 'Extension "{{name}}" is already up to date.': + 'A extensão "{{name}}" já está atualizada.', + 'Updates all extensions or a named extension to the latest version.': + 'Atualiza todas as extensões ou uma extensão nomeada para a última versão.', + 'Update all extensions.': 'Atualizar todas as extensões.', + 'Either an extension name or --all must be provided': + 'Um nome de extensão ou --all deve ser fornecido', + 'Lists installed extensions.': 'Lista as extensões instaladas.', + 'Link extension failed to install.': 'Falha ao instalar link da extensão.', + 'Extension "{{name}}" linked successfully and enabled.': + 'Extensão "{{name}}" vinculada com sucesso e ativada.', + 'Links an extension from a local path. Updates made to the local path will always be reflected.': + 'Vincula uma extensão de um caminho local. Atualizações feitas no caminho local sempre serão refletidas.', + 'The name of the extension to link.': 'O nome da extensão para vincular.', + 'Set a specific setting for an extension.': + 'Define uma configuração específica para uma extensão.', + 'Name of the extension to configure.': 'Nome da extensão para configurar.', + 'The setting to configure (name or env var).': + 'A configuração para configurar (nome ou var env).', + 'The scope to set the setting in.': 'O escopo para definir a configuração.', + 'List all settings for an extension.': + 'Listar todas as configurações de uma extensão.', + 'Name of the extension.': 'Nome da extensão.', + 'Extension "{{name}}" has no settings to configure.': + 'A extensão "{{name}}" não tem configurações para configurar.', + 'Settings for "{{name}}":': 'Configurações para "{{name}}":', + '(workspace)': '(workspace)', + '(user)': '(usuário)', + '[not set]': '[não definido]', + '[value stored in keychain]': '[valor armazenado no chaveiro]', + 'Value:': 'Valor:', + 'Manage extension settings.': 'Gerenciar configurações de extensão.', + 'You need to specify a command (set or list).': + 'Você precisa especificar um comando (set ou list).', + + // ============================================================================ + // Plugin Choice / Marketplace + // ============================================================================ + 'No plugins available in this marketplace.': + 'Nenhum plugin disponível neste marketplace.', + 'Select a plugin to install from marketplace "{{name}}":': + 'Selecione um plugin para instalar do marketplace "{{name}}":', + 'Plugin selection cancelled.': 'Seleção de plugin cancelada.', + 'Select a plugin from "{{name}}"': 'Selecione um plugin de "{{name}}"', + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': + 'Use ↑↓ ou j/k para navegar, Enter para selecionar, Escape para cancelar', + '{{count}} more above': '{{count}} mais acima', + '{{count}} more below': '{{count}} mais abaixo', + 'manage IDE integration': 'gerenciar integração com IDE', + 'check status of IDE integration': 'verificar status da integração com IDE', + 'install required IDE companion for {{ideName}}': + 'instalar companion IDE necessário para {{ideName}}', + 'enable IDE integration': 'ativar integração com IDE', + 'disable IDE integration': 'desativar integração com IDE', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'A integração com IDE não é suportada no seu ambiente atual. Para usar este recurso, execute o Qwen Code em um destes IDEs suportados: VS Code ou forks do VS Code.', + 'Set up GitHub Actions': 'Configurar GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Configurar atalhos de terminal para entrada multilinhas (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Reinicie seu terminal para que as alterações tenham efeito.', + 'Failed to configure terminal: {{error}}': + 'Falha ao configurar terminal: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Não foi possível determinar o caminho de configuração de {{terminalName}} no Windows: variável de ambiente APPDATA não está definida.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json existe mas não é um array JSON válido. Corrija o arquivo manualmente ou exclua-o para permitir a configuração automática.', + 'File: {{file}}': 'Arquivo: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Falha ao analisar {{terminalName}} keybindings.json. O arquivo contém JSON inválido. Corrija o arquivo manualmente ou exclua-o para permitir a configuração automática.', + 'Error: {{error}}': 'Erro: {{error}}', + 'Shift+Enter binding already exists': 'Atalho Shift+Enter já existe', + 'Ctrl+Enter binding already exists': 'Atalho Ctrl+Enter já existe', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Atalhos existentes detectados. Não serão modificados para evitar conflitos.', + 'Please check and modify manually if needed: {{file}}': + 'Verifique e modifique manualmente se necessário: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Adicionados atalhos Shift+Enter e Ctrl+Enter para {{terminalName}}.', + 'Modified: {{file}}': 'Modificado: {{file}}', + '{{terminalName}} keybindings already configured.': + 'Atalhos de {{terminalName}} já configurados.', + 'Failed to configure {{terminalName}}.': + 'Falha ao configurar {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Seu terminal já está configurado para uma experiência ideal com entrada multilinhas (Shift+Enter e Ctrl+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Gerenciar hooks do Qwen Code', + 'List all configured hooks': 'Listar todos os hooks configurados', + 'Enable a disabled hook': 'Ativar um hook desativado', + 'Disable an active hook': 'Desativar um hook ativo', + // Hooks - Dialog + Hooks: 'Hooks', + 'Loading hooks...': 'Carregando hooks...', + 'Error loading hooks:': 'Erro ao carregar hooks:', + 'Press Escape to close': 'Pressione Escape para fechar', + 'Press Escape, Ctrl+C, or Ctrl+D to cancel': + 'Pressione Escape, Ctrl+C ou Ctrl+D para cancelar', + 'Press Space, Enter, or Escape to dismiss': + 'Pressione Espaço, Enter ou Escape para dispensar', + 'No hook selected': 'Nenhum hook selecionado', + // Hooks - List Step + 'No hook events found.': 'Nenhum evento de hook encontrado.', + '{{count}} hook configured': '{{count}} hook configurado', + '{{count}} hooks configured': '{{count}} hooks configurados', + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.': + 'Este menu é somente leitura. Para adicionar ou modificar hooks, edite settings.json diretamente ou pergunte ao Qwen Code.', + 'Enter to select · Esc to cancel': + 'Enter para selecionar · Esc para cancelar', + // Hooks - Detail Step + 'Exit codes:': 'Códigos de saída:', + 'Configured hooks:': 'Hooks configurados:', + 'No hooks configured for this event.': + 'Nenhum hook configurado para este evento.', + 'To add hooks, edit settings.json directly or ask Qwen.': + 'Para adicionar hooks, edite settings.json diretamente ou pergunte ao Qwen.', + 'Enter to select · Esc to go back': 'Enter para selecionar · Esc para voltar', + // Hooks - Config Detail Step + 'Hook details': 'Detalhes do Hook', + 'Event:': 'Evento:', + 'Extension:': 'Extensão:', + 'Desc:': 'Descrição:', + 'No hook config selected': 'Nenhuma configuração de hook selecionada', + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.': + 'Para modificar ou remover este hook, edite settings.json diretamente ou pergunte ao Qwen.', + // Hooks - Disabled Step + 'Hook Configuration - Disabled': 'Configuração de Hook - Desativado', + 'All hooks are currently disabled. You have {{count}} that are not running.': + 'Todos os hooks estão desativados. Você tem {{count}} que não estão em execução.', + '{{count}} configured hook': '{{count}} hook configurado', + '{{count}} configured hooks': '{{count}} hooks configurados', + 'When hooks are disabled:': 'Quando os hooks estão desativados:', + 'No hook commands will execute': 'Nenhum comando de hook será executado', + 'StatusLine will not be displayed': 'StatusLine não será exibido', + 'Tool operations will proceed without hook validation': + 'As operações de ferramentas prosseguirão sem validação de hook', + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.': + 'Para reativar os hooks, remova "disableAllHooks" do settings.json ou pergunte ao Qwen Code.', + // Hooks - Source + Project: 'Projeto', + User: 'Usuário', + System: 'Sistema', + Extension: 'Extensão', + 'Local Settings': 'Configurações Locais', + 'User Settings': 'Configurações do Usuário', + 'System Settings': 'Configurações do Sistema', + Extensions: 'Extensões', + // Hooks - Status + '✓ Enabled': '✓ Ativado', + '✗ Disabled': '✗ Desativado', + // Hooks - Event Descriptions (short) + 'Before tool execution': 'Antes da execução da ferramenta', + 'After tool execution': 'Após a execução da ferramenta', + 'After tool execution fails': 'Após a falha da execução da ferramenta', + 'When notifications are sent': 'Quando notificações são enviadas', + 'When the user submits a prompt': 'Quando o usuário envia um prompt', + 'When a new session is started': 'Quando uma nova sessão é iniciada', + 'Right before Qwen Code concludes its response': + 'Logo antes do Qwen Code concluir sua resposta', + 'When a subagent (Agent tool call) is started': + 'Quando um subagente (chamada de ferramenta Agent) é iniciado', + 'Right before a subagent concludes its response': + 'Logo antes de um subagente concluir sua resposta', + 'Before conversation compaction': 'Antes da compactação da conversa', + 'When a session is ending': 'Quando uma sessão está terminando', + 'When a permission dialog is displayed': + 'Quando um diálogo de permissão é exibido', + // Hooks - Event Descriptions (detailed) + 'Input to command is JSON of tool call arguments.': + 'A entrada para o comando é JSON dos argumentos da chamada da ferramenta.', + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).': + 'A entrada para o comando é JSON com campos "inputs" (argumentos da chamada da ferramenta) e "response" (resposta da chamada da ferramenta).', + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.': + 'A entrada para o comando é JSON com tool_name, tool_input, tool_use_id, error, error_type, is_interrupt e is_timeout.', + 'Input to command is JSON with notification message and type.': + 'A entrada para o comando é JSON com mensagem e tipo de notificação.', + 'Input to command is JSON with original user prompt text.': + 'A entrada para o comando é JSON com o texto original do prompt do usuário.', + 'Input to command is JSON with session start source.': + 'A entrada para o comando é JSON com a fonte de início da sessão.', + 'Input to command is JSON with session end reason.': + 'A entrada para o comando é JSON com o motivo do fim da sessão.', + 'Input to command is JSON with agent_id and agent_type.': + 'A entrada para o comando é JSON com agent_id e agent_type.', + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.': + 'A entrada para o comando é JSON com agent_id, agent_type e agent_transcript_path.', + 'Input to command is JSON with compaction details.': + 'A entrada para o comando é JSON com detalhes da compactação.', + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.': + 'A entrada para o comando é JSON com tool_name, tool_input e tool_use_id. Saída é JSON com hookSpecificOutput contendo decisão de permitir ou negar.', + // Hooks - Exit Code Descriptions + 'stdout/stderr not shown': 'stdout/stderr não exibido', + 'show stderr to model and continue conversation': + 'mostrar stderr ao modelo e continuar conversa', + 'show stderr to user only': 'mostrar stderr apenas ao usuário', + 'stdout shown in transcript mode (ctrl+o)': + 'stdout exibido no modo transcrição (ctrl+o)', + 'show stderr to model immediately': 'mostrar stderr ao modelo imediatamente', + 'show stderr to user only but continue with tool call': + 'mostrar stderr apenas ao usuário mas continuar com chamada de ferramenta', + 'block processing, erase original prompt, and show stderr to user only': + 'bloquear processamento, apagar prompt original e mostrar stderr apenas ao usuário', + 'stdout shown to Qwen': 'stdout mostrado ao Qwen', + 'show stderr to user only (blocking errors ignored)': + 'mostrar stderr apenas ao usuário (erros de bloqueio ignorados)', + 'command completes successfully': 'comando concluído com sucesso', + 'stdout shown to subagent': 'stdout mostrado ao subagente', + 'show stderr to subagent and continue having it run': + 'mostrar stderr ao subagente e continuar executando', + 'stdout appended as custom compact instructions': + 'stdout anexado como instruções de compactação personalizadas', + 'block compaction': 'bloquear compactação', + 'show stderr to user only but continue with compaction': + 'mostrar stderr apenas ao usuário mas continuar com compactação', + 'use hook decision if provided': 'usar decisão do hook se fornecida', + // Hooks - Messages + 'Config not loaded.': 'Configuração não carregada.', + 'Hooks are not enabled. Enable hooks in settings to use this feature.': + 'Hooks não estão ativados. Ative hooks nas configurações para usar este recurso.', + 'No hooks configured. Add hooks in your settings.json file.': + 'Nenhum hook configurado. Adicione hooks no seu arquivo settings.json.', + 'Configured Hooks ({{count}} total)': 'Hooks Configurados ({{count}} total)', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Exportar o histórico de mensagens da sessão atual para um arquivo', + 'Export session to HTML format': 'Exportar a sessão para o formato HTML', + 'Export session to JSON format': 'Exportar a sessão para o formato JSON', + 'Export session to JSONL format (one message per line)': + 'Exportar a sessão para o formato JSONL (uma mensagem por linha)', + 'Export session to markdown format': + 'Exportar a sessão para o formato Markdown', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'Gerar insights personalizados de programação a partir do seu histórico de chat', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Retomar uma sessão anterior', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Restaurar uma chamada de ferramenta. Isso redefinirá o histórico da conversa e dos arquivos para o estado em que a chamada da ferramenta foi sugerida', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Não foi possível detectar o tipo de terminal. Terminais suportados: VS Code, Cursor, Windsurf e Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'O terminal "{{terminal}}" ainda não é suportado.', + + // ============================================================================ + // Commands - Language + // ============================================================================ + 'Invalid language. Available: {{options}}': + 'Idioma inválido. Disponíveis: {{options}}', + 'Language subcommands do not accept additional arguments.': + 'Subcomandos de idioma não aceitam argumentos adicionais.', + 'Current UI language: {{lang}}': 'Idioma atual da interface: {{lang}}', + 'Current LLM output language: {{lang}}': + 'Idioma atual da saída do LLM: {{lang}}', + 'LLM output language not set': 'Idioma de saída do LLM não definido', + 'Set UI language': 'Definir idioma da interface', + 'Set LLM output language': 'Definir idioma de saída do LLM', + 'Usage: /language ui [{{options}}]': 'Uso: /language ui [{{options}}]', + 'Usage: /language output ': 'Uso: /language output ', + 'Example: /language output 中文': 'Exemplo: /language output Português', + 'Example: /language output English': 'Exemplo: /language output Inglês', + 'Example: /language output 日本語': 'Exemplo: /language output Japonês', + 'Example: /language output Português': 'Exemplo: /language output Português', + 'UI language changed to {{lang}}': + 'Idioma da interface alterado para {{lang}}', + 'LLM output language set to {{lang}}': + 'Idioma de saída do LLM definido para {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'Arquivo de regra de idioma de saída do LLM gerado em {{path}}', + 'Please restart the application for the changes to take effect.': + 'Reinicie o aplicativo para que as alterações tenham efeito.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Falha ao gerar arquivo de regra de idioma de saída do LLM: {{error}}', + 'Invalid command. Available subcommands:': + 'Comando inválido. Subcomandos disponíveis:', + 'Available subcommands:': 'Subcomandos disponíveis:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'Para solicitar pacotes de idiomas de interface adicionais, abra um problema no GitHub.', + 'Available options:': 'Opções disponíveis:', + 'Set UI language to {{name}}': 'Definir idioma da interface para {{name}}', + + // ============================================================================ + // Commands - Approval Mode + // ============================================================================ + 'Tool Approval Mode': 'Modo de Aprovação de Ferramenta', + 'Current approval mode: {{mode}}': 'Modo de aprovação atual: {{mode}}', + 'Available approval modes:': 'Modos de aprovação disponíveis:', + 'Approval mode changed to: {{mode}}': + 'Modo de aprovação alterado para: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Modo de aprovação alterado para: {{mode}} (salvo nas configurações de {{scope}}{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Uso: /approval-mode [--session|--user|--project]', + + 'Scope subcommands do not accept additional arguments.': + 'Subcomandos de escopo não aceitam argumentos adicionais.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Modo planejamento - Apenas analisa, não modifica arquivos nem executa comandos', + 'Default mode - Require approval for file edits or shell commands': + 'Modo padrão - Exige aprovação para edições de arquivos ou comandos shell', + 'Auto-edit mode - Automatically approve file edits': + 'Modo auto-edição - Aprova automaticamente edições de arquivos', + 'YOLO mode - Automatically approve all tools': + 'Modo YOLO - Aprova automaticamente todas as ferramentas', + '{{mode}} mode': 'Modo {{mode}}', + 'Settings service is not available; unable to persist the approval mode.': + 'Serviço de configurações não disponível; não foi possível persistir o modo de aprovação.', + 'Failed to save approval mode: {{error}}': + 'Falha ao salvar modo de aprovação: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Falha ao alterar modo de aprovação: {{error}}', + 'Apply to current session only (temporary)': + 'Aplicar apenas à sessão atual (temporário)', + 'Persist for this project/workspace': 'Persistir para este projeto/workspace', + 'Persist for this user on this machine': + 'Persistir para este usuário nesta máquina', + 'Analyze only, do not modify files or execute commands': + 'Apenas analisar, não modificar arquivos nem executar comandos', + 'Require approval for file edits or shell commands': + 'Exigir aprovação para edições de arquivos ou comandos shell', + 'Automatically approve file edits': + 'Aprovar automaticamente edições de arquivos', + 'Automatically approve all tools': + 'Aprovar automaticamente todas as ferramentas', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'O modo de aprovação do workspace existe e tem prioridade. A alteração no nível do usuário não terá efeito.', + 'Apply To': 'Aplicar A', + 'Workspace Settings': 'Configurações do Workspace', + + // ============================================================================ + // Commands - Memory + // ============================================================================ + 'Commands for interacting with memory.': + 'Comandos para interagir com a memória.', + 'Show the current memory contents.': + 'Mostrar os conteúdos atuais da memória.', + 'Show project-level memory contents.': + 'Mostrar conteúdos da memória de nível de projeto.', + 'Show global memory contents.': 'Mostrar conteúdos da memória global.', + 'Add content to project-level memory.': + 'Adicionar conteúdo à memória de nível de projeto.', + 'Add content to global memory.': 'Adicionar conteúdo à memória global.', + 'Refresh the memory from the source.': 'Atualizar a memória da fonte.', + 'Usage: /memory add --project ': + 'Uso: /memory add --project ', + 'Usage: /memory add --global ': + 'Uso: /memory add --global ', + 'Attempting to save to project memory: "{{text}}"': + 'Tentando salvar na memória do projeto: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Tentando salvar na memória global: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Conteúdo da memória atual de {{count}} arquivo(s):', + 'Memory is currently empty.': 'A memória está vazia no momento.', + 'Project memory file not found or is currently empty.': + 'Arquivo de memória do projeto não encontrado ou está vazio.', + 'Global memory file not found or is currently empty.': + 'Arquivo de memória global não encontrado ou está vazio.', + 'Global memory is currently empty.': + 'A memória global está vazia no momento.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Conteúdo da memória global:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Conteúdo da memória do projeto de {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': + 'A memória do projeto está vazia no momento.', + 'Refreshing memory from source files...': + 'Atualizando memória dos arquivos fonte...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Adicionar conteúdo à memória. Use --global para memória global ou --project para memória do projeto.', + 'Usage: /memory add [--global|--project] ': + 'Uso: /memory add [--global|--project] ', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Tentando salvar na memória {{scope}}: "{{fact}}"', + + // ============================================================================ + // Commands - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Autenticar com um servidor MCP habilitado para OAuth', + 'List configured MCP servers and tools': + 'Listar servidores e ferramentas MCP configurados', + 'Restarts MCP servers.': 'Reinicia os servidores MCP.', + 'Could not retrieve tool registry.': + 'Não foi possível recuperar o registro de ferramentas.', + 'No MCP servers configured with OAuth authentication.': + 'Nenhum servidor MCP configurado com autenticação OAuth.', + 'MCP servers with OAuth authentication:': + 'Servidores MCP com autenticação OAuth:', + 'Use /mcp auth to authenticate.': + 'Use /mcp auth para autenticar.', + "MCP server '{{name}}' not found.": "Servidor MCP '{{name}}' não encontrado.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Autenticado com sucesso e ferramentas atualizadas para '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Falha ao autenticar com o servidor MCP '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "Redescobrindo ferramentas de '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "{{count}} ferramenta(s) descoberta(s) de '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Autenticação concluída. Retornando aos detalhes do servidor...', + 'Authentication successful.': 'Autenticação bem-sucedida.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Se o navegador não abrir, copie e cole esta URL no seu navegador:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Certifique-se de copiar a URL COMPLETA – ela pode ocupar várias linhas.', + + // ============================================================================ + // Commands - Chat + // ============================================================================ + 'Manage conversation history.': 'Gerenciar histórico de conversas.', + 'List saved conversation checkpoints': + 'Listar checkpoints de conversa salvos', + 'No saved conversation checkpoints found.': + 'Nenhum checkpoint de conversa salvo encontrado.', + 'List of saved conversations:': 'Lista de conversas salvas:', + 'Note: Newest last, oldest first': + 'Nota: Mais novos por último, mais antigos primeiro', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Salvar a conversa atual como um checkpoint. Uso: /chat save ', + 'Missing tag. Usage: /chat save ': 'Tag ausente. Uso: /chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Excluir um checkpoint de conversa. Uso: /chat delete ', + 'Missing tag. Usage: /chat delete ': + 'Tag ausente. Uso: /chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "O checkpoint de conversa '{{tag}}' foi excluído.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Erro: Nenhum checkpoint encontrado com a tag '{{tag}}'.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Retomar uma conversa de um checkpoint. Uso: /chat resume ', + 'Missing tag. Usage: /chat resume ': + 'Tag ausente. Uso: /chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + 'Nenhum checkpoint salvo encontrado com a tag: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'Um checkpoint com a tag {{tag}} já existe. Você deseja substituí-lo?', + 'No chat client available to save conversation.': + 'Nenhum cliente de chat disponível para salvar a conversa.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Checkpoint de conversa salvo com a tag: {{tag}}.', + 'No conversation found to save.': 'Nenhuma conversa encontrada para salvar.', + 'No chat client available to share conversation.': + 'Nenhum cliente de chat disponível para compartilhar a conversa.', + 'Invalid file format. Only .md and .json are supported.': + 'Formato de arquivo inválido. Apenas .md e .json são suportados.', + 'Error sharing conversation: {{error}}': + 'Erro ao compartilhar conversa: {{error}}', + 'Conversation shared to {{filePath}}': + 'Conversa compartilhada em {{filePath}}', + 'No conversation found to share.': + 'Nenhuma conversa encontrada para compartilhar.', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + 'Compartilhar a conversa atual para um arquivo markdown ou json. Uso: /chat share ', + + // ============================================================================ + // Commands - Summary + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Gerar um resumo do projeto e salvá-lo em .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'Nenhum cliente de chat disponível para gerar o resumo.', + 'Already generating summary, wait for previous request to complete': + 'Já gerando resumo, aguarde a conclusão da solicitação anterior', + 'No conversation found to summarize.': + 'Nenhuma conversa encontrada para resumir.', + 'Failed to generate project context summary: {{error}}': + 'Falha ao gerar resumo do contexto do projeto: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'Resumo do projeto salvo em {{filePathForDisplay}}.', + 'Saving project summary...': 'Salvando resumo do projeto...', + 'Generating project summary...': 'Gerando resumo do projeto...', + 'Failed to generate summary - no text content received from LLM response': + 'Falha ao gerar resumo - nenhum conteúdo de texto recebido da resposta do LLM', + + // ============================================================================ + // Commands - Model + // ============================================================================ + 'Switch the model for this session': 'Trocar o modelo para esta sessão', + 'Set fast model for background tasks': + 'Definir modelo rápido para tarefas em segundo plano', + 'Content generator configuration not available.': + 'Configuração do gerador de conteúdo não disponível.', + 'Authentication type not available.': 'Tipo de autenticação não disponível.', + 'No models available for the current authentication type ({{authType}}).': + 'Nenhum modelo disponível para o tipo de autenticação atual ({{authType}}).', + + // ============================================================================ + // Commands - Clear + // ============================================================================ + 'Starting a new session, resetting chat, and clearing terminal.': + 'Iniciando uma nova sessão, resetando o chat e limpando o terminal.', + 'Starting a new session and clearing.': + 'Iniciando uma nova sessão e limpando.', + + // ============================================================================ + // Commands - Compress + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Já comprimindo, aguarde a conclusão da solicitação anterior', + 'Failed to compress chat history.': 'Falha ao comprimir histórico do chat.', + 'Failed to compress chat history: {{error}}': + 'Falha ao comprimir histórico do chat: {{error}}', + 'Compressing chat history': 'Comprimindo histórico do chat', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'Histórico do chat comprimido de {{originalTokens}} para {{newTokens}} tokens.', + 'Compression was not beneficial for this history size.': + 'A compressão não foi benéfica para este tamanho de histórico.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'A compressão do histórico do chat não reduziu o tamanho. Isso pode indicar problemas com o prompt de compressão.', + 'Could not compress chat history due to a token counting error.': + 'Não foi possível comprimir o histórico do chat devido a um erro de contagem de tokens.', + 'Chat history is already compressed.': + 'O histórico do chat já está comprimido.', + + // ============================================================================ + // Commands - Directory + // ============================================================================ + 'Configuration is not available.': 'A configuração não está disponível.', + 'Please provide at least one path to add.': + 'Forneça pelo menos um caminho para adicionar.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'O comando /directory add não é suportado em perfis de sandbox restritivos. Use --include-directories ao iniciar a sessão.', + "Error adding '{{path}}': {{error}}": + "Erro ao adicionar '{{path}}': {{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + 'Arquivos QWEN.md adicionados com sucesso dos seguintes diretórios, se houverem:\n- {{directories}}', + 'Error refreshing memory: {{error}}': 'Erro ao atualizar memória: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Diretórios adicionados com sucesso:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Diretórios atuais do workspace:\n{{directories}}', + + // ============================================================================ + // Commands - Docs + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Abra a seguinte URL no seu navegador para ver a documentação:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Abrindo documentação no seu navegador: {{url}}', + + // ============================================================================ + // Dialogs - Tool Confirmation + // ============================================================================ + 'Do you want to proceed?': 'Você deseja prosseguir?', + 'Yes, allow once': 'Sim, permitir uma vez', + 'Allow always': 'Permitir sempre', + Yes: 'Sim', + No: 'Não', + 'No (esc)': 'Não (esc)', + 'Yes, allow always for this session': 'Sim, permitir sempre para esta sessão', + + // MCP Management - Core translations + 'Manage MCP servers': 'Gerenciar servidores MCP', + 'Server Detail': 'Detalhes do servidor', + 'Disable Server': 'Desativar servidor', + Tools: 'Ferramentas', + 'Tool Detail': 'Detalhes da ferramenta', + 'MCP Management': 'Gerenciamento MCP', + 'Loading...': 'Carregando...', + 'Unknown step': 'Etapa desconhecida', + 'Esc to back': 'Esc para voltar', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ navegar · Enter selecionar · Esc fechar', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ navegar · Enter selecionar · Esc voltar', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ navegar · Enter confirmar · Esc voltar', + 'User Settings (global)': 'Configurações do usuário (global)', + 'Workspace Settings (project-specific)': + 'Configurações do workspace (específico do projeto)', + 'Disable server:': 'Desativar servidor:', + 'Select where to add the server to the exclude list:': + 'Selecione onde adicionar o servidor à lista de exclusão:', + 'Press Enter to confirm, Esc to cancel': + 'Enter para confirmar, Esc para cancelar', + Disable: 'Desativar', + Enable: 'Ativar', + Authenticate: 'Autenticar', + 'Re-authenticate': 'Reautenticar', + 'Clear Authentication': 'Limpar autenticação', + disabled: 'desativado', + 'Server:': 'Servidor:', + Reconnect: 'Reconectar', + 'View tools': 'Ver ferramentas', + 'Status:': 'Status:', + 'Source:': 'Fonte:', + 'Command:': 'Comando:', + 'Working Directory:': 'Diretório de trabalho:', + 'Capabilities:': 'Capacidades:', + 'No server selected': 'Nenhum servidor selecionado', + '(disabled)': '(desativado)', + 'Error:': 'Erro:', + tool: 'ferramenta', + tools: 'ferramentas', + connected: 'conectado', + connecting: 'conectando', + disconnected: 'desconectado', + error: 'erro', + + // MCP Server List + 'User MCPs': 'MCPs do usuário', + 'Project MCPs': 'MCPs do projeto', + 'Extension MCPs': 'MCPs de extensão', + server: 'servidor', + servers: 'servidores', + 'Add MCP servers to your settings to get started.': + 'Adicione servidores MCP às suas configurações para começar.', + 'Run qwen --debug to see error logs': + 'Execute qwen --debug para ver os logs de erro', + + // MCP OAuth Authentication + 'OAuth Authentication': 'Autenticação OAuth', + 'Press Enter to start authentication, Esc to go back': + 'Pressione Enter para iniciar a autenticação, Esc para voltar', + 'Authenticating... Please complete the login in your browser.': + 'Autenticando... Por favor, conclua o login no seu navegador.', + 'Press Enter or Esc to go back': 'Pressione Enter ou Esc para voltar', + + // MCP Tool List + 'No tools available for this server.': + 'Nenhuma ferramenta disponível para este servidor.', + destructive: 'destrutivo', + 'read-only': 'somente leitura', + 'open-world': 'mundo aberto', + idempotent: 'idempotente', + 'Tools for {{name}}': 'Ferramentas para {{name}}', + 'Tools for {{serverName}}': 'Ferramentas para {{serverName}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'obrigatório', + Type: 'Tipo', + Enum: 'Enumeração', + Parameters: 'Parâmetros', + 'No tool selected': 'Nenhuma ferramenta selecionada', + Annotations: 'Anotações', + Title: 'Título', + 'Read Only': 'Somente leitura', + Destructive: 'Destrutivo', + Idempotent: 'Idempotente', + 'Open World': 'Mundo aberto', + Server: 'Servidor', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} ferramentas inválidas', + invalid: 'inválido', + 'invalid: {{reason}}': 'inválido: {{reason}}', + 'missing name': 'nome ausente', + 'missing description': 'descrição ausente', + '(unnamed)': '(sem nome)', + 'Warning: This tool cannot be called by the LLM': + 'Aviso: Esta ferramenta não pode ser chamada pelo LLM', + Reason: 'Motivo', + 'Tools must have both name and description to be used by the LLM.': + 'As ferramentas devem ter tanto nome quanto descrição para serem usadas pelo LLM.', + 'Modify in progress:': 'Modificação em progresso:', + 'Save and close external editor to continue': + 'Salve e feche o editor externo para continuar', + 'Apply this change?': 'Aplicar esta alteração?', + 'Yes, allow always': 'Sim, permitir sempre', + 'Modify with external editor': 'Modificar com editor externo', + 'No, suggest changes (esc)': 'Não, sugerir alterações (esc)', + "Allow execution of: '{{command}}'?": + "Permitir a execução de: '{{command}}'?", + 'Yes, allow always ...': 'Sim, permitir sempre ...', + 'Always allow in this project': 'Sempre permitir neste projeto', + 'Always allow {{action}} in this project': + 'Sempre permitir {{action}} neste projeto', + 'Always allow for this user': 'Sempre permitir para este usuário', + 'Always allow {{action}} for this user': + 'Sempre permitir {{action}} para este usuário', + 'Yes, restore previous mode ({{mode}})': + 'Sim, restaurar modo anterior ({{mode}})', + 'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente', + 'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente', + 'No, keep planning (esc)': 'Não, continuar planejando (esc)', + 'URLs to fetch:': 'URLs para buscar:', + 'MCP Server: {{server}}': 'Servidor MCP: {{server}}', + 'Tool: {{tool}}': 'Ferramenta: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Permitir a execução da ferramenta MCP "{{tool}}" do servidor "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Sim, sempre permitir a ferramenta "{{tool}}" do servidor "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'Sim, sempre permitir todas as ferramentas do servidor "{{server}}"', + + // ============================================================================ + // Dialogs - Shell Confirmation + // ============================================================================ + 'Shell Command Execution': 'Execução de Comando Shell', + 'A custom command wants to run the following shell commands:': + 'Um comando personalizado deseja executar os seguintes comandos shell:', + + // ============================================================================ + // Dialogs - Pro Quota + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Limite de cota Pro atingido para {{model}}.', + 'Change auth (executes the /auth command)': + 'Alterar autenticação (executa o comando /auth)', + 'Continue with {{model}}': 'Continuar com {{model}}', + + // ============================================================================ + // Dialogs - Welcome Back + // ============================================================================ + 'Current Plan:': 'Plano Atual:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Progresso: {{done}}/{{total}} tarefas concluídas', + ', {{inProgress}} in progress': ', {{inProgress}} em progresso', + 'Pending Tasks:': 'Tarefas Pendentes:', + 'What would you like to do?': 'O que você gostaria de fazer?', + 'Choose how to proceed with your session:': + 'Escolha como proceder com sua sessão:', + 'Start new chat session': 'Iniciar nova sessão de chat', + 'Continue previous conversation': 'Continuar conversa anterior', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 Bem-vindo de volta! (Última atualização: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Objetivo Geral:', + + // ============================================================================ + // Dialogs - Auth + // ============================================================================ + 'Get started': 'Começar', + 'Select Authentication Method': 'Selecionar Método de Autenticação', + 'OpenAI API key is required to use OpenAI authentication.': + 'A chave da API do OpenAI é necessária para usar a autenticação do OpenAI.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'Você deve selecionar um método de autenticação para prosseguir. Pressione Ctrl+C novamente para sair.', + 'Terms of Services and Privacy Notice': + 'Termos de Serviço e Aviso de Privacidade', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Pago \u00B7 Até 6.000 solicitações/5 hrs \u00B7 Todos os modelos Alibaba Cloud Coding Plan', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Traga sua própria chave API', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Falha ao fazer login. Mensagem: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'A autenticação é forçada para {{enforcedType}}, mas você está usando {{currentType}} no momento.', + 'Please visit this URL to authorize:': 'Visite esta URL para autorizar:', + 'Or scan the QR code below:': 'Ou escaneie o código QR abaixo:', + 'Waiting for authorization': 'Aguardando autorização', + 'Time remaining:': 'Tempo restante:', + '(Press ESC or CTRL+C to cancel)': '(Pressione ESC ou CTRL+C para cancelar)', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'Token OAuth expirado (mais de {{seconds}} segundos). Selecione o método de autenticação novamente.', + 'Press any key to return to authentication type selection.': + 'Pressione qualquer tecla para retornar à seleção do tipo de autenticação.', + 'Authentication timed out. Please try again.': + 'A autenticação expirou. Tente novamente.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Aguardando autenticação... (Pressione ESC ou CTRL+C para cancelar)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + 'Chave de API ausente para autenticação compatível com OpenAI. Defina settings.security.auth.apiKey ou a variável de ambiente {{envKeyHint}}.', + '{{envKeyHint}} environment variable not found.': + 'Variável de ambiente {{envKeyHint}} não encontrada.', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + 'Variável de ambiente {{envKeyHint}} não encontrada. Defina-a no seu arquivo .env ou variáveis de ambiente.', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + 'Variável de ambiente {{envKeyHint}} não encontrada (ou defina settings.security.auth.apiKey). Defina-a no seu arquivo .env ou variáveis de ambiente.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'Chave de API ausente para autenticação compatível com OpenAI. Defina a variável de ambiente {{envKeyHint}}.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Provedor Anthropic sem a baseUrl necessária em modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'Variável de ambiente ANTHROPIC_BASE_URL não encontrada.', + 'Invalid auth method selected.': + 'Método de autenticação inválido selecionado.', + 'Failed to authenticate. Message: {{message}}': + 'Falha ao autenticar. Mensagem: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Autenticado com sucesso com credenciais {{authType}}.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Valor QWEN_DEFAULT_AUTH_TYPE inválido: "{{value}}". Valores válidos são: {{validValues}}', + 'OpenAI Configuration Required': 'Configuração do OpenAI Necessária', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Insira sua configuração do OpenAI. Você pode obter uma chave de API de', + 'API Key:': 'Chave da API:', + 'Invalid credentials: {{errorMessage}}': + 'Credenciais inválidas: {{errorMessage}}', + 'Failed to validate credentials': 'Falha ao validar credenciais', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Pressione Enter para continuar, Tab/↑↓ para navegar, Esc para cancelar', + + // ============================================================================ + // Dialogs - Model + // ============================================================================ + 'Select Model': 'Selecionar Modelo', + '(Press Esc to close)': '(Pressione Esc para fechar)', + 'Current (effective) configuration': 'Configuração atual (efetiva)', + AuthType: 'AuthType', + 'API Key': 'Chave da API', + unset: 'não definido', + '(default)': '(padrão)', + '(set)': '(definido)', + '(not set)': '(não definido)', + Modality: 'Modalidade', + 'Context Window': 'Janela de Contexto', + text: 'texto', + 'text-only': 'somente texto', + image: 'imagem', + pdf: 'PDF', + audio: 'áudio', + video: 'vídeo', + 'not set': 'não definido', + none: 'nenhum', + unknown: 'desconhecido', + "Failed to switch model to '{{modelId}}'.\n\n{{error}}": + "Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}", + 'Qwen 3.6 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.6 Plus — modelo híbrido eficiente com desempenho líder em programação', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'O modelo Qwen Vision mais recente do Alibaba Cloud ModelStudio (versão: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Dialogs - Permissions + // ============================================================================ + 'Manage folder trust settings': + 'Gerenciar configurações de confiança de pasta', + 'Manage permission rules': 'Gerenciar regras de permissão', + Allow: 'Permitir', + Ask: 'Perguntar', + Deny: 'Negar', + Workspace: 'Área de trabalho', + "Qwen Code won't ask before using allowed tools.": + 'O Qwen Code não perguntará antes de usar ferramentas permitidas.', + 'Qwen Code will ask before using these tools.': + 'O Qwen Code perguntará antes de usar essas ferramentas.', + 'Qwen Code is not allowed to use denied tools.': + 'O Qwen Code não tem permissão para usar ferramentas negadas.', + 'Manage trusted directories for this workspace.': + 'Gerenciar diretórios confiáveis para esta área de trabalho.', + 'Any use of the {{tool}} tool': 'Qualquer uso da ferramenta {{tool}}', + "{{tool}} commands matching '{{pattern}}'": + "Comandos {{tool}} correspondentes a '{{pattern}}'", + 'From user settings': 'Das configurações do usuário', + 'From project settings': 'Das configurações do projeto', + 'From session': 'Da sessão', + 'Project settings (local)': 'Configurações do projeto (local)', + 'Saved in .qwen/settings.local.json': 'Salvo em .qwen/settings.local.json', + 'Project settings': 'Configurações do projeto', + 'Checked in at .qwen/settings.json': 'Registrado em .qwen/settings.json', + 'User settings': 'Configurações do usuário', + 'Saved in at ~/.qwen/settings.json': 'Salvo em ~/.qwen/settings.json', + 'Add a new rule…': 'Adicionar nova regra…', + 'Add {{type}} permission rule': 'Adicionar regra de permissão {{type}}', + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.': + 'Regras de permissão são um nome de ferramenta, opcionalmente seguido por um especificador entre parênteses.', + 'e.g.,': 'ex.', + or: 'ou', + 'Enter permission rule…': 'Insira a regra de permissão…', + 'Enter to submit · Esc to cancel': 'Enter para enviar · Esc para cancelar', + 'Where should this rule be saved?': 'Onde esta regra deve ser salva?', + 'Enter to confirm · Esc to cancel': + 'Enter para confirmar · Esc para cancelar', + 'Delete {{type}} rule?': 'Excluir regra {{type}}?', + 'Are you sure you want to delete this permission rule?': + 'Tem certeza de que deseja excluir esta regra de permissão?', + 'Permissions:': 'Permissões:', + '(←/→ or tab to cycle)': '(←/→ ou Tab para alternar)', + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel': + '↑↓ para navegar · Enter para selecionar · Digite para pesquisar · Esc para cancelar', + 'Search…': 'Pesquisar…', + 'Use /trust to manage folder trust settings for this workspace.': + 'Use /trust para gerenciar as configurações de confiança de pasta desta área de trabalho.', + // Workspace directory management + 'Add directory…': 'Adicionar diretório…', + 'Add directory to workspace': 'Adicionar diretório à área de trabalho', + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.': + 'O Qwen Code pode ler arquivos na área de trabalho e fazer edições quando a aceitação automática está ativada.', + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.': + 'O Qwen Code poderá ler arquivos neste diretório e fazer edições quando a aceitação automática está ativada.', + 'Enter the path to the directory:': 'Insira o caminho do diretório:', + 'Enter directory path…': 'Insira o caminho do diretório…', + 'Tab to complete · Enter to add · Esc to cancel': + 'Tab para completar · Enter para adicionar · Esc para cancelar', + 'Remove directory?': 'Remover diretório?', + 'Are you sure you want to remove this directory from the workspace?': + 'Tem certeza de que deseja remover este diretório da área de trabalho?', + ' (Original working directory)': ' (Diretório de trabalho original)', + ' (from settings)': ' (das configurações)', + 'Directory does not exist.': 'O diretório não existe.', + 'Path is not a directory.': 'O caminho não é um diretório.', + 'This directory is already in the workspace.': + 'Este diretório já está na área de trabalho.', + 'Already covered by existing directory: {{dir}}': + 'Já coberto pelo diretório existente: {{dir}}', + + // ============================================================================ + // Status Bar + // ============================================================================ + 'Using:': 'Usando:', + '{{count}} open file': '{{count}} arquivo aberto', + '{{count}} open files': '{{count}} arquivos abertos', + '(ctrl+g to view)': '(ctrl+g para ver)', + '{{count}} {{name}} file': '{{count}} arquivo {{name}}', + '{{count}} {{name}} files': '{{count}} arquivos {{name}}', + '{{count}} MCP server': '{{count}} servidor MCP', + '{{count}} MCP servers': '{{count}} servidores MCP', + '{{count}} Blocked': '{{count}} Bloqueados', + '(ctrl+t to view)': '(ctrl+t para ver)', + '(ctrl+t to toggle)': '(ctrl+t para alternar)', + 'Press Ctrl+C again to exit.': 'Pressione Ctrl+C novamente para sair.', + 'Press Ctrl+D again to exit.': 'Pressione Ctrl+D novamente para sair.', + 'Press Esc again to clear.': 'Pressione Esc novamente para limpar.', + + // ============================================================================ + // MCP Status + // ============================================================================ + 'No MCP servers configured.': 'Nenhum servidor MCP configurado.', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ Servidores MCP estão iniciando ({{count}} inicializando)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Nota: A primeira inicialização pode demorar mais. A disponibilidade da ferramenta será atualizada automaticamente.', + 'Configured MCP servers:': 'Servidores MCP configurados:', + Ready: 'Pronto', + 'Starting... (first startup may take longer)': + 'Iniciando... (a primeira inicialização pode demorar mais)', + Disconnected: 'Desconectado', + '{{count}} tool': '{{count}} ferramenta', + '{{count}} tools': '{{count}} ferramentas', + '{{count}} prompt': '{{count}} prompt', + '{{count}} prompts': '{{count}} prompts', + '(from {{extensionName}})': '(de {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth expirado', + 'OAuth not authenticated': 'OAuth não autenticado', + 'tools and prompts will appear when ready': + 'ferramentas e prompts aparecerão quando estiverem prontos', + '{{count}} tools cached': '{{count}} ferramentas em cache', + 'Tools:': 'Ferramentas:', + 'Parameters:': 'Parâmetros:', + 'Prompts:': 'Prompts:', + Blocked: 'Bloqueado', + '💡 Tips:': '💡 Dicas:', + Use: 'Use', + 'to show server and tool descriptions': + 'para mostrar descrições de servidores e ferramentas', + 'to show tool parameter schemas': + 'para mostrar esquemas de parâmetros de ferramentas', + 'to hide descriptions': 'para ocultar descrições', + 'to authenticate with OAuth-enabled servers': + 'para autenticar com servidores habilitados para OAuth', + Press: 'Pressione', + 'to toggle tool descriptions on/off': + 'para alternar descrições de ferramentas ligadas/desligadas', + "Starting OAuth authentication for MCP server '{{name}}'...": + "Iniciando autenticação OAuth para servidor MCP '{{name}}'...", + 'Restarting MCP servers...': 'Reiniciando servidores MCP...', + + // ============================================================================ + // Startup Tips + // ============================================================================ + 'Tips:': 'Dicas:', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Use /compress quando a conversa ficar longa para resumir o histórico e liberar contexto.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'Comece uma nova ideia com /clear ou /new; a sessão anterior permanece disponível no histórico.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Use /bug para enviar problemas aos mantenedores quando algo der errado.', + 'Switch auth type quickly with /auth.': + 'Troque o tipo de autenticação rapidamente com /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Você pode executar quaisquer comandos shell do Qwen Code usando ! (ex: !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'Digite / para abrir o popup de comandos; Tab autocompleta comandos de barra e prompts salvos.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'Você pode retomar uma conversa anterior executando qwen --continue ou qwen --resume.', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + 'Você pode alternar o modo de permissão rapidamente com Shift+Tab ou /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Experimente /insight para gerar insights personalizados do seu histórico de conversas.', + + // ============================================================================ + // Exit Screen / Stats + // ============================================================================ + 'Agent powering down. Goodbye!': 'Agente desligando. Adeus!', + 'To continue this session, run': 'Para continuar esta sessão, execute', + 'Interaction Summary': 'Resumo da Interação', + 'Session ID:': 'ID da Sessão:', + 'Tool Calls:': 'Chamadas de Ferramenta:', + 'Success Rate:': 'Taxa de Sucesso:', + 'User Agreement:': 'Acordo do Usuário:', + reviewed: 'revisado', + 'Code Changes:': 'Alterações de Código:', + Performance: 'Desempenho', + 'Wall Time:': 'Tempo Total:', + 'Agent Active:': 'Agente Ativo:', + 'API Time:': 'Tempo de API:', + 'Tool Time:': 'Tempo de Ferramenta:', + 'Session Stats': 'Estatísticas da Sessão', + 'Model Usage': 'Uso do Modelo', + Reqs: 'Reqs', + 'Input Tokens': 'Tokens de Entrada', + 'Output Tokens': 'Tokens de Saída', + 'Savings Highlight:': 'Destaque de Economia:', + 'of input tokens were served from the cache, reducing costs.': + 'de tokens de entrada foram servidos do cache, reduzindo custos.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Dica: Para um detalhamento completo de tokens, execute `/stats model`.', + 'Model Stats For Nerds': 'Estatísticas de Modelo Para Nerds', + 'Tool Stats For Nerds': 'Estatísticas de Ferramenta Para Nerds', + Metric: 'Métrica', + API: 'API', + Requests: 'Solicitações', + Errors: 'Erros', + 'Avg Latency': 'Latência Média', + Tokens: 'Tokens', + Total: 'Total', + Prompt: 'Prompt', + Cached: 'Cacheado', + Thoughts: 'Pensamentos', + Tool: 'Ferramenta', + Output: 'Saída', + 'No API calls have been made in this session.': + 'Nenhuma chamada de API foi feita nesta sessão.', + 'Tool Name': 'Nome da Ferramenta', + Calls: 'Chamadas', + 'Success Rate': 'Taxa de Sucesso', + 'Avg Duration': 'Duração Média', + 'User Decision Summary': 'Resumo de Decisão do Usuário', + 'Total Reviewed Suggestions:': 'Total de Sugestões Revisadas:', + ' » Accepted:': ' » Aceitas:', + ' » Rejected:': ' » Rejeitadas:', + ' » Modified:': ' » Modificadas:', + ' Overall Agreement Rate:': ' Taxa Geral de Acordo:', + 'No tool calls have been made in this session.': + 'Nenhuma chamada de ferramenta foi feita nesta sessão.', + 'Session start time is unavailable, cannot calculate stats.': + 'Hora de início da sessão indisponível, não é possível calcular estatísticas.', + + // ============================================================================ + // Command Format Migration + // ============================================================================ + 'Command Format Migration': 'Migração de Formato de Comando', + 'Found {{count}} TOML command file:': + 'Encontrado {{count}} arquivo de comando TOML:', + 'Found {{count}} TOML command files:': + 'Encontrados {{count}} arquivos de comando TOML:', + '... and {{count}} more': '... e mais {{count}}', + 'The TOML format is deprecated. Would you like to migrate them to Markdown format?': + 'O formato TOML está obsoleto. Você gostaria de migrá-los para o formato Markdown?', + '(Backups will be created and original files will be preserved)': + '(Backups serão criados e arquivos originais serão preservados)', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': 'Aguardando confirmação do usuário...', + '(esc to cancel, {{time}})': '(esc para cancelar, {{time}})', + + WITTY_LOADING_PHRASES: [ + 'Estou com sorte', + 'Enviando maravilhas...', + 'Pintando os serifos de volta...', + 'Navegando pelo mofo limoso...', + 'Consultando os espíritos digitais...', + 'Reticulando splines...', + 'Aquecendo os hamsters da IA...', + 'Perguntando à concha mágica...', + 'Gerando réplica espirituosa...', + 'Polindo os algoritmos...', + 'Não apresse a perfeição (ou meu código)...', + 'Preparando bytes frescos...', + 'Contando elétrons...', + 'Engajando processadores cognitivos...', + 'Verificando erros de sintaxe no universo...', + 'Um momento, otimizando o humor...', + 'Embaralhando piadas...', + 'Desembaraçando redes neurais...', + 'Compilando brilhantismo...', + 'Carregando humor.exe...', + 'Invocando a nuvem da sabedoria...', + 'Preparando uma resposta espirituosa...', + 'Só um segundo, estou depurando a realidade...', + 'Confundindo as opções...', + 'Sintonizando as frequências cósmicas...', + 'Criando uma resposta digna da sua paciência...', + 'Compilando os 1s e 0s...', + 'Resolvendo dependências... e crises existenciais...', + 'Desfragmentando memórias... tanto RAM quanto pessoais...', + 'Reiniciando o módulo de humor...', + 'Fazendo cache do essencial (principalmente memes de gatos)...', + 'Otimizando para velocidade absurda', + 'Trocando bits... não conte para os bytes...', + 'Coletando lixo... volto já...', + 'Montando a internet...', + 'Convertendo café em código...', + 'Atualizando a sintaxe da realidade...', + 'Reconectando as sinapses...', + 'Procurando um ponto e vírgula perdido...', + 'Lubrificando as engrenagens da máquina...', + 'Pré-aquecendo os servidores...', + 'Calibrando o capacitor de fluxo...', + 'Engajando o motor de improbabilidade...', + 'Canalizando a Força...', + 'Alinhando as estrelas para uma resposta ideal...', + 'Assim dizemos todos...', + 'Carregando a próxima grande ideia...', + 'Só um momento, estou na zona...', + 'Preparando para deslumbrá-lo com brilhantismo...', + 'Só um tique, estou polindo minha inteligência...', + 'Segure firme, estou criando uma obra-prima...', + 'Só um instante, estou depurando o universo...', + 'Só um momento, estou alinhando os pixels...', + 'Só um segundo, estou otimizando o humor...', + 'Só um momento, estou ajustando os algoritmos...', + 'Velocidade de dobra engajada...', + 'Minerando mais cristais de Dilithium...', + 'Não entre em pânico...', + 'Seguindo o coelho branco...', + 'A verdade está lá fora... em algum lugar...', + 'Soprando o cartucho...', + 'Carregando... Faça um barrel roll!', + 'Aguardando o respawn...', + 'Terminando a Kessel Run em menos de 12 parsecs...', + 'O bolo não é uma mentira, só ainda está carregando...', + 'Mexendo na tela de criação de personagem...', + 'Só um momento, estou encontrando o meme certo...', + "Pressionando 'A' para continuar...", + 'Pastoreando gatos digitais...', + 'Polindo os pixels...', + 'Encontrando um trocadilho adequado para a tela de carregamento...', + 'Distraindo você com esta frase espirituosa...', + 'Quase lá... provavelmente...', + 'Nossos hamsters estão trabalhando o mais rápido que podem...', + 'Dando um tapinha na cabeça do Cloudy...', + 'Acariciando o gato...', + 'Dando um Rickroll no meu chefe...', + 'Never gonna give you up, never gonna let you down...', + 'Tocando o baixo...', + 'Provando as amoras...', + 'Estou indo longe, estou indo pela velocidade...', + 'Isso é vida real? Ou é apenas fantasia?...', + 'Tenho um bom pressentimento sobre isso...', + 'Cutucando o urso...', + 'Fazendo pesquisa sobre os últimos memes...', + 'Descobrindo como tornar isso mais espirituoso...', + 'Hmmm... deixe-me pensar...', + 'O que você chama de um peixe sem olhos? Um pxe...', + 'Por que o computador foi à terapia? Porque tinha muitos bytes...', + 'Por que programadores não gostam da natureza? Porque tem muitos bugs...', + 'Por que programadores preferem o modo escuro? Porque a luz atrai bugs...', + 'Por que o desenvolvedor faliu? Porque usou todo o seu cache...', + 'O que você pode fazer com um lápis quebrado? Nada, ele não tem ponta...', + 'Aplicando manutenção percussiva...', + 'Procurando a orientação correta do USB...', + 'Garantindo que a fumaça mágica permaneça dentro dos fios...', + 'Tentando sair do Vim...', + 'Girando a roda do hamster...', + 'Isso não é um bug, é um recurso não documentado...', + 'Engajar.', + 'Eu voltarei... com uma resposta.', + 'Meu outro processo é uma TARDIS...', + 'Comungando com o espírito da máquina...', + 'Deixando os pensamentos marinarem...', + 'Lembrei agora onde coloquei minhas chaves...', + 'Ponderando a orbe...', + 'Eu vi coisas que vocês não acreditariam... como um usuário que lê mensagens de carregamento.', + 'Iniciando olhar pensativo...', + 'Qual é o lanche favorito de um computador? Microchips.', + 'Por que desenvolvedores Java usam óculos? Porque eles não C#.', + 'Carregando o laser... pew pew!', + 'Dividindo por zero... só brincando!', + 'Procurando por um supervisor adulto... digo, processando.', + 'Fazendo bip boop.', + 'Buffering... porque até as IAs precisam de um momento.', + 'Entrelaçando partículas quânticas para uma resposta mais rápida...', + 'Polindo o cromo... nos algoritmos.', + 'Você não está entretido? (Trabalhando nisso!)', + 'Invocando os gremlins do código... para ajudar, é claro.', + 'Só esperando o som da conexão discada terminar...', + 'Recalibrando o humorômetro.', + 'Minha outra tela de carregamento é ainda mais engraçada.', + 'Tenho quase certeza que tem um gato andando no teclado em algum lugar...', + 'Aumentando... Aumentando... Ainda carregando.', + 'Não é um bug, é um recurso... desta tela de carregamento.', + 'Você já tentou desligar e ligar de novo? (A tela de carregamento, não eu.)', + 'Construindo pilares adicionais...', + ], + + // ============================================================================ + // Extension Settings Input + // ============================================================================ + 'Enter value...': 'Digite o valor...', + 'Enter sensitive value...': 'Digite o valor sensível...', + 'Press Enter to submit, Escape to cancel': + 'Pressione Enter para enviar, Escape para cancelar', + + // ============================================================================ + // Command Migration Tool + // ============================================================================ + 'Markdown file already exists: {{filename}}': + 'Arquivo Markdown já existe: {{filename}}', + 'TOML Command Format Deprecation Notice': + 'Aviso de Obsolescência do Formato de Comando TOML', + 'Found {{count}} command file(s) in TOML format:': + 'Encontrado(s) {{count}} arquivo(s) de comando no formato TOML:', + 'The TOML format for commands is being deprecated in favor of Markdown format.': + 'O formato TOML para comandos está sendo descontinuado em favor do formato Markdown.', + 'Markdown format is more readable and easier to edit.': + 'O formato Markdown é mais legível e fácil de editar.', + 'You can migrate these files automatically using:': + 'Você pode migrar esses arquivos automaticamente usando:', + 'Or manually convert each file:': 'Ou converter manualmente cada arquivo:', + 'TOML: prompt = "..." / description = "..."': + 'TOML: prompt = "..." / description = "..."', + 'Markdown: YAML frontmatter + content': + 'Markdown: YAML frontmatter + conteúdo', + 'The migration tool will:': 'A ferramenta de migração irá:', + 'Convert TOML files to Markdown': 'Converter arquivos TOML para Markdown', + 'Create backups of original files': 'Criar backups dos arquivos originais', + 'Preserve all command functionality': + 'Preservar toda a funcionalidade do comando', + 'TOML format will continue to work for now, but migration is recommended.': + 'O formato TOML continuará a funcionar por enquanto, mas a migração é recomendada.', + + // ============================================================================ + // Extensions - Explore Command + // ============================================================================ + 'Open extensions page in your browser': + 'Abrir página de extensões no seu navegador', + 'Unknown extensions source: {{source}}.': + 'Fonte de extensões desconhecida: {{source}}.', + 'Would open extensions page in your browser: {{url}} (skipped in test environment)': + 'Abriria a página de extensões no seu navegador: {{url}} (pulado no ambiente de teste)', + 'View available extensions at {{url}}': + 'Ver extensões disponíveis em {{url}}', + 'Opening extensions page in your browser: {{url}}': + 'Abrindo página de extensões no seu navegador: {{url}}', + 'Failed to open browser. Check out the extensions gallery at {{url}}': + 'Falha ao abrir o navegador. Confira a galeria de extensões em {{url}}', + + // ============================================================================ + // Custom API Key Configuration + // ============================================================================ + 'You can configure your API key and models in settings.json': + 'Você pode configurar sua chave de API e modelos em settings.json', + 'Refer to the documentation for setup instructions': + 'Consulte a documentação para instruções de configuração', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'API key cannot be empty.': 'A chave de API não pode estar vazia.', + 'You can get your Coding Plan API key here': + 'Você pode obter sua chave de API do Coding Plan aqui', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'Novas configurações de modelo estão disponíveis para o Alibaba Cloud Coding Plan. Atualizar agora?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Chave de API do Coding Plan não encontrada. Por favor, re-autentique com o Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Falha ao atualizar a configuração do Coding Plan: {{message}}', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of ModelStudio Coding Plan and you're all set!": + 'Cole sua chave de API do ModelStudio Coding Plan e pronto!', + Custom: 'Personalizado', + 'More instructions about configuring `modelProviders` manually.': + 'Mais instruções sobre como configurar `modelProviders` manualmente.', + 'Select API-KEY configuration mode:': + 'Selecione o modo de configuração da API-KEY:', + '(Press Escape to go back)': '(Pressione Escape para voltar)', + '(Press Enter to submit, Escape to cancel)': + '(Pressione Enter para enviar, Escape para cancelar)', + 'More instructions please check:': 'Mais instruções, consulte:', + 'Select Region for Coding Plan': 'Selecionar região do Coding Plan', + 'Choose based on where your account is registered': + 'Escolha com base em onde sua conta está registrada', + 'Enter Coding Plan API Key': 'Inserir chave de API do Coding Plan', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Uso do Contexto', + 'No API response yet. Send a message to see actual usage.': + 'Ainda não há resposta da API. Envie uma mensagem para ver o uso real.', + 'Estimated pre-conversation overhead': 'Sobrecarga estimada pré-conversa', + 'Context window': 'Janela de Contexto', + tokens: 'tokens', + Used: 'Usado', + Free: 'Livre', + 'Autocompact buffer': 'Buffer de autocompactação', + 'Usage by category': 'Uso por categoria', + 'System prompt': 'Prompt do sistema', + 'Built-in tools': 'Ferramentas integradas', + 'MCP tools': 'Ferramentas MCP', + 'Memory files': 'Arquivos de memória', + Skills: 'Habilidades', + Messages: 'Mensagens', + 'Show context window usage breakdown.': + 'Exibe a divisão de uso da janela de contexto.', + 'Run /context detail for per-item breakdown.': + 'Execute /context detail para detalhamento por item.', + active: 'ativo', + 'body loaded': 'conteúdo carregado', + memory: 'memória', + '{{region}} configuration updated successfully.': + 'Configuração do {{region}} atualizada com sucesso.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Dica: Use /model para alternar entre os modelos disponíveis do Coding Plan.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Por favor, responda à(s) seguinte(s) pergunta(s):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Não é possível fazer perguntas ao usuário no modo não interativo. Por favor, execute no modo interativo para usar esta ferramenta.', + 'User declined to answer the questions.': + 'O usuário recusou responder às perguntas.', + 'User has provided the following answers:': + 'O usuário forneceu as seguintes respostas:', + 'Failed to process user answers:': + 'Falha ao processar as respostas do usuário:', + 'Type something...': 'Digite algo...', + Submit: 'Enviar', + 'Submit answers': 'Enviar respostas', + Cancel: 'Cancelar', + 'Your answers:': 'Suas respostas:', + '(not answered)': '(não respondido)', + 'Ready to submit your answers?': 'Pronto para enviar suas respostas?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navegar | ←/→: Alternar abas | Enter: Selecionar', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navegar | ←/→: Alternar abas | Space/Enter: Alternar | Esc: Cancelar', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Authenticate using Alibaba Cloud Coding Plan': + 'Autenticar usando Alibaba Cloud Coding Plan', + 'Region for Coding Plan (china/global)': + 'Região para Coding Plan (china/global)', + 'API key for Coding Plan': 'Chave de API para Coding Plan', + 'Show current authentication status': 'Mostrar status atual de autenticação', + 'Authentication completed successfully.': + 'Autenticação concluída com sucesso.', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Processando autenticação Alibaba Cloud Coding Plan...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Autenticado com sucesso via Alibaba Cloud Coding Plan.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Falha ao autenticar com Coding Plan: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Global', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Selecione a região para Coding Plan:', + 'Enter your Coding Plan API key: ': + 'Insira sua chave de API do Coding Plan: ', + 'Select authentication method:': 'Selecione o método de autenticação:', + '\n=== Authentication Status ===\n': '\n=== Status de Autenticação ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ Nenhum método de autenticação configurado.\n', + 'Run one of the following commands to get started:\n': + 'Execute um dos seguintes comandos para começar:\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Autenticar com Alibaba Cloud Coding Plan\n', + 'Or simply run:': 'Ou simplesmente execute:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Configuração interativa de autenticação\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Método de autenticação: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Global - Alibaba Cloud', + ' Region: {{region}}': ' Região: {{region}}', + ' Current Model: {{model}}': ' Modelo atual: {{model}}', + ' Config Version: {{version}}': ' Versão da configuração: {{version}}', + ' Status: API key configured\n': ' Status: Chave de API configurada\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Método de autenticação: Alibaba Cloud Coding Plan (Incompleto)', + ' Issue: API key not found in environment or settings\n': + ' Problema: Chave de API não encontrada no ambiente ou configurações\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Execute `qwen auth coding-plan` para reconfigurar.\n', + '✓ Authentication Method: {{type}}': '✓ Método de autenticação: {{type}}', + ' Status: Configured\n': ' Status: Configurado\n', + 'Failed to check authentication status: {{error}}': + 'Falha ao verificar status de autenticação: {{error}}', + 'Select an option:': 'Selecione uma opção:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Modo raw não disponível. Execute em um terminal interativo.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n', + verbose: 'detalhado', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Mostrar saída completa da ferramenta e raciocínio no modo detalhado (alternar com Ctrl+O).', + 'Press Ctrl+O to show full tool output': + 'Pressione Ctrl+O para exibir a saída completa da ferramenta', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', + + "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", +}; diff --git a/apps/airiscode-cli/src/i18n/locales/ru.js b/apps/airiscode-cli/src/i18n/locales/ru.js new file mode 100644 index 000000000..9fec3ec25 --- /dev/null +++ b/apps/airiscode-cli/src/i18n/locales/ru.js @@ -0,0 +1,1957 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Русский перевод для Qwen Code CLI +// Ключ служит одновременно ключом перевода и текстом по умолчанию + +export default { + // ============================================================================ + // Справка / Компоненты интерфейса + // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ управление вложениями', + '← → select, Delete to remove, ↓ to exit': + '← → выбрать, Delete удалить, ↓ выйти', + 'Attachments: ': 'Вложения: ', + + 'Basics:': 'Основы:', + 'Add context': 'Добавить контекст', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Используйте {{symbol}} для добавления файлов в контекст (например, {{example}}) для выбора конкретных файлов или папок).', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Режим терминала', + 'YOLO mode': 'Режим YOLO', + 'plan mode': 'Режим планирования', + 'auto-accept edits': 'Режим принятия правок', + 'Accepting edits': 'Принятие правок', + '(shift + tab to cycle)': '(shift + tab для переключения)', + '(tab to cycle)': '(Tab для переключения)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Выполняйте команды терминала через {{symbol}} (например, {{example1}}) или используйте естественный язык (например, {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'Команды:', + 'shell command': 'команда терминала', + 'Model Context Protocol command (from external servers)': + 'Команда Model Context Protocol (из внешних серверов)', + 'Keyboard Shortcuts:': 'Горячие клавиши:', + 'Toggle this help display': 'Показать/скрыть эту справку', + 'Toggle shell mode': 'Переключить режим оболочки', + 'Open command menu': 'Открыть меню команд', + 'Add file context': 'Добавить файл в контекст', + 'Accept suggestion / Autocomplete': 'Принять подсказку / Автодополнение', + 'Reverse search history': 'Обратный поиск по истории', + 'Press ? again to close': 'Нажмите ? ещё раз, чтобы закрыть', + 'Jump through words in the input': 'Переход по словам во вводе', + 'Close dialogs, cancel requests, or quit application': + 'Закрыть диалоги, отменить запросы или выйти из приложения', + 'New line': 'Новая строка', + 'New line (Alt+Enter works for certain linux distros)': + 'Новая строка (Alt+Enter работает только в некоторых дистрибутивах Linux)', + 'Clear the screen': 'Очистить экран', + 'Open input in external editor': 'Открыть ввод во внешнем редакторе', + 'Send message': 'Отправить сообщение', + 'Initializing...': 'Инициализация...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Подключение к MCP-серверам... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу', + '? for shortcuts': '? — горячие клавиши', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.", + 'Cancel operation / Clear input (double press)': + 'Отменить операцию / Очистить ввод (двойное нажатие)', + 'Cycle approval modes': 'Переключение режимов подтверждения', + 'Cycle through your prompt history': 'Пролистать историю запросов', + 'For a full list of shortcuts, see {{docPath}}': + 'Полный список горячих клавиш см. в {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'Справка по Qwen Code', + 'show version info': 'Просмотр информации о версии', + 'submit a bug report': 'Отправка отчёта об ошибке', + 'About Qwen Code': 'Об Qwen Code', + Status: 'Статус', + + // Keyboard shortcuts panel descriptions + 'for shell mode': 'режим оболочки', + 'for commands': 'меню команд', + 'for file paths': 'пути к файлам', + 'to clear input': 'очистить ввод', + 'to cycle approvals': 'переключить режим', + 'to quit': 'выход', + 'for newline': 'новая строка', + 'to clear screen': 'очистить экран', + 'to search history': 'поиск в истории', + 'to paste images': 'вставить изображения', + 'for external editor': 'внешний редактор', + + // ============================================================================ + // Поля системной информации + // ============================================================================ + 'Qwen Code': 'Qwen Code', + Runtime: 'Среда выполнения', + OS: 'ОС', + Auth: 'Аутентификация', + 'CLI Version': 'Версия CLI', + 'Git Commit': 'Git-коммит', + Model: 'Модель', + 'Fast Model': 'Быстрая модель', + Sandbox: 'Песочница', + 'OS Platform': 'Платформа ОС', + 'OS Arch': 'Архитектура ОС', + 'OS Release': 'Версия ОС', + 'Node.js Version': 'Версия Node.js', + 'NPM Version': 'Версия NPM', + 'Session ID': 'ID сессии', + 'Auth Method': 'Метод авторизации', + 'Base URL': 'Базовый URL', + Proxy: 'Прокси', + 'Memory Usage': 'Использование памяти', + 'IDE Client': 'Клиент IDE', + + // ============================================================================ + // Команды - Общие + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Анализ проекта и создание адаптированного файла QWEN.md', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]', + 'List available skills.': 'Показать доступные навыки.', + 'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:', + 'No tools available': 'Нет доступных инструментов', + 'View or change the approval mode for tool usage': + 'Просмотр или изменение режима подтверждения для использования инструментов', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + 'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}', + 'Approval mode set to "{{mode}}"': + 'Режим подтверждения установлен на "{{mode}}"', + 'View or change the language setting': + 'Просмотр или изменение настроек языка', + 'change the theme': 'Изменение темы', + 'Select Theme': 'Выбор темы', + Preview: 'Предпросмотр', + '(Use Enter to select, Tab to configure scope)': + '(Enter для выбора, Tab для настройки области)', + '(Use Enter to apply scope, Tab to go back)': + '(Enter для применения области, Tab для возврата)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Настройка темы недоступна из-за переменной окружения NO_COLOR.', + 'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Тема "{{themeName}}" не найдена в выбранной области.', + 'Clear conversation history and free up context': + 'Очистить историю диалога и освободить контекст', + 'Compresses the context by replacing it with a summary.': + 'Сжатие контекста заменой на краткую сводку', + 'open full Qwen Code documentation in your browser': + 'Открытие полной документации Qwen Code в браузере', + 'Configuration not available.': 'Конфигурация недоступна.', + 'change the auth method': 'Изменение метода авторизации', + 'Configure authentication information for login': + 'Настройка аутентификационной информации для входа', + 'Copy the last result or code snippet to clipboard': + 'Копирование последнего результата или фрагмента кода в буфер обмена', + + // ============================================================================ + // Команды - Агенты + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Управление подагентами для делегирования специализированных задач', + 'Manage existing subagents (view, edit, delete).': + 'Управление существующими подагентами (просмотр, правка, удаление)', + 'Create a new subagent with guided setup.': + 'Создание нового подагента с пошаговой настройкой', + + // ============================================================================ + // Агенты - Диалог управления + // ============================================================================ + Agents: 'Агенты', + 'Choose Action': 'Выберите действие', + 'Edit {{name}}': 'Редактировать {{name}}', + 'Edit Tools: {{name}}': 'Редактировать инструменты: {{name}}', + 'Edit Color: {{name}}': 'Редактировать цвет: {{name}}', + 'Delete {{name}}': 'Удалить {{name}}', + 'Unknown Step': 'Неизвестный шаг', + 'Esc to close': 'Esc для закрытия', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter для выбора, ↑↓ для навигации, Esc для закрытия', + 'Esc to go back': 'Esc для возврата', + 'Enter to confirm, Esc to cancel': 'Enter для подтверждения, Esc для отмены', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter для выбора, ↑↓ для навигации, Esc для возврата', + 'Enter to submit, Esc to go back': 'Enter для отправки, Esc для возврата', + 'Invalid step: {{step}}': 'Неверный шаг: {{step}}', + 'No subagents found.': 'Подагенты не найдены.', + "Use '/agents create' to create your first subagent.": + "Используйте '/agents create' для создания первого подагента.", + '(built-in)': '(встроенный)', + '(overridden by project level agent)': + '(переопределен агентом уровня проекта)', + 'Project Level ({{path}})': 'Уровень проекта ({{path}})', + 'User Level ({{path}})': 'Уровень пользователя ({{path}})', + 'Built-in Agents': 'Встроенные агенты', + 'Extension Agents': 'Агенты расширений', + 'Using: {{count}} agents': 'Используется: {{count}} агент(ов)', + 'View Agent': 'Просмотреть агента', + 'Edit Agent': 'Редактировать агента', + 'Delete Agent': 'Удалить агента', + Back: 'Назад', + 'No agent selected': 'Агент не выбран', + 'File Path: ': 'Путь к файлу: ', + 'Tools: ': 'Инструменты: ', + 'Color: ': 'Цвет: ', + 'Description:': 'Описание:', + 'System Prompt:': 'Системный промпт:', + 'Open in editor': 'Открыть в редакторе', + 'Edit tools': 'Редактировать инструменты', + 'Edit color': 'Редактировать цвет', + '❌ Error:': '❌ Ошибка:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Вы уверены, что хотите удалить агента "{{name}}"?', + // ============================================================================ + // Агенты - Мастер создания + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Уровень проекта (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'Уровень пользователя (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Подагент успешно создан!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Подагент "{{name}}" сохранен на уровне {{level}}.', + 'Name: ': 'Имя: ', + 'Location: ': 'Расположение: ', + '❌ Error saving subagent:': '❌ Ошибка сохранения подагента:', + 'Warnings:': 'Предупреждения:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Имя "{{name}}" уже существует на уровне {{level}} - существующий подагент будет перезаписан', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Имя "{{name}}" существует на уровне пользователя - уровень проекта будет иметь приоритет', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Имя "{{name}}" существует на уровне проекта - существующий подагент будет иметь приоритет', + 'Description is over {{length}} characters': + 'Описание превышает {{length}} символов', + 'System prompt is over {{length}} characters': + 'Системный промпт превышает {{length}} символов', + // Агенты - Шаги мастера создания + 'Step {{n}}: Choose Location': 'Шаг {{n}}: Выберите расположение', + 'Step {{n}}: Choose Generation Method': 'Шаг {{n}}: Выберите метод генерации', + 'Generate with Qwen Code (Recommended)': + 'Сгенерировать с помощью Qwen Code (Рекомендуется)', + 'Manual Creation': 'Ручное создание', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Опишите, что должен делать этот подагент и когда его следует использовать. (Будьте подробны для лучших результатов)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'например, Экспертный ревьювер кода, проверяющий код на соответствие лучшим практикам...', + 'Generating subagent configuration...': 'Генерация конфигурации подагента...', + 'Failed to generate subagent: {{error}}': + 'Не удалось сгенерировать подагента: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Шаг {{n}}: Опишите подагента', + 'Step {{n}}: Enter Subagent Name': 'Шаг {{n}}: Введите имя подагента', + 'Step {{n}}: Enter System Prompt': 'Шаг {{n}}: Введите системный промпт', + 'Step {{n}}: Enter Description': 'Шаг {{n}}: Введите описание', + // Агенты - Выбор инструментов + 'Step {{n}}: Select Tools': 'Шаг {{n}}: Выберите инструменты', + 'All Tools (Default)': 'Все инструменты (по умолчанию)', + 'All Tools': 'Все инструменты', + 'Read-only Tools': 'Инструменты только для чтения', + 'Read & Edit Tools': 'Инструменты для чтения и редактирования', + 'Read & Edit & Execution Tools': + 'Инструменты для чтения, редактирования и выполнения', + 'All tools selected, including MCP tools': + 'Все инструменты выбраны, включая инструменты MCP', + 'Selected tools:': 'Выбранные инструменты:', + 'Read-only tools:': 'Инструменты только для чтения:', + 'Edit tools:': 'Инструменты редактирования:', + 'Execution tools:': 'Инструменты выполнения:', + 'Step {{n}}: Choose Background Color': 'Шаг {{n}}: Выберите цвет фона', + 'Step {{n}}: Confirm and Save': 'Шаг {{n}}: Подтвердите и сохраните', + // Агенты - Навигация и инструкции + 'Esc to cancel': 'Esc для отмены', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter для сохранения, e для сохранения и редактирования, Esc для возврата', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter для продолжения, {{navigation}}Esc для {{action}}', + cancel: 'отмены', + 'go back': 'возврата', + '↑↓ to navigate, ': '↑↓ для навигации, ', + 'Enter a clear, unique name for this subagent.': + 'Введите четкое, уникальное имя для этого подагента.', + 'e.g., Code Reviewer': 'например, Ревьювер кода', + 'Name cannot be empty.': 'Имя не может быть пустым.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'Напишите системный промпт, определяющий поведение подагента. Будьте подробны для лучших результатов.', + 'e.g., You are an expert code reviewer...': + 'например, Вы экспертный ревьювер кода...', + 'System prompt cannot be empty.': 'Системный промпт не может быть пустым.', + 'Describe when and how this subagent should be used.': + 'Опишите, когда и как следует использовать этого подагента.', + 'e.g., Reviews code for best practices and potential bugs.': + 'например, Проверяет код на соответствие лучшим практикам и потенциальные ошибки.', + 'Description cannot be empty.': 'Описание не может быть пустым.', + 'Failed to launch editor: {{error}}': + 'Не удалось запустить редактор: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Не удалось сохранить и отредактировать подагента: {{error}}', + + // ============================================================================ + // Команды - Общие (продолжение) + // ============================================================================ + 'View and edit Qwen Code settings': 'Просмотр и изменение настроек Qwen Code', + Settings: 'Настройки', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.', + 'The command "/{{command}}" is not supported in non-interactive mode.': + 'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.', + // ============================================================================ + // Метки настроек + // ============================================================================ + 'Vim Mode': 'Режим Vim', + 'Disable Auto Update': 'Отключить автообновление', + 'Attribution: commit': 'Атрибуция: коммит', + 'Terminal Bell Notification': 'Звуковое уведомление терминала', + 'Enable Usage Statistics': 'Включить сбор статистики использования', + Theme: 'Тема', + 'Preferred Editor': 'Предпочтительный редактор', + 'Auto-connect to IDE': 'Автоподключение к IDE', + 'Enable Prompt Completion': 'Включить автодополнение промптов', + 'Debug Keystroke Logging': 'Логирование нажатий клавиш для отладки', + 'Language: UI': 'Язык: интерфейс', + 'Language: Model': 'Язык: модель', + 'Output Format': 'Формат вывода', + 'Hide Window Title': 'Скрыть заголовок окна', + 'Show Status in Title': 'Показывать статус в заголовке', + 'Hide Tips': 'Скрыть подсказки', + 'Show Line Numbers in Code': 'Показывать номера строк в коде', + 'Show Citations': 'Показывать цитаты', + 'Custom Witty Phrases': 'Пользовательские остроумные фразы', + 'Show Welcome Back Dialog': 'Показывать диалог приветствия', + 'Enable User Feedback': 'Включить отзывы пользователей', + 'How is Qwen doing this session? (optional)': + 'Как дела у Qwen в этой сессии? (необязательно)', + Bad: 'Плохо', + Fine: 'Нормально', + Good: 'Хорошо', + Dismiss: 'Отклонить', + 'Not Sure Yet': 'Пока не уверен', + 'Any other key': 'Любая другая клавиша', + 'Disable Loading Phrases': 'Отключить фразы при загрузке', + 'Screen Reader Mode': 'Режим программы чтения с экрана', + 'IDE Mode': 'Режим IDE', + 'Max Session Turns': 'Макс. количество ходов сессии', + 'Skip Next Speaker Check': 'Пропустить проверку следующего говорящего', + 'Skip Loop Detection': 'Пропустить обнаружение циклов', + 'Skip Startup Context': 'Пропустить начальный контекст', + 'Enable OpenAI Logging': 'Включить логирование OpenAI', + 'OpenAI Logging Directory': 'Директория логов OpenAI', + Timeout: 'Таймаут', + 'Max Retries': 'Макс. количество попыток', + 'Disable Cache Control': 'Отключить управление кэшем', + 'Memory Discovery Max Dirs': 'Макс. директорий для поиска в памяти', + 'Load Memory From Include Directories': + 'Загружать память из включенных директорий', + 'Respect .gitignore': 'Учитывать .gitignore', + 'Respect .qwenignore': 'Учитывать .qwenignore', + 'Enable Recursive File Search': 'Включить рекурсивный поиск файлов', + 'Disable Fuzzy Search': 'Отключить нечеткий поиск', + 'Interactive Shell (PTY)': 'Интерактивный терминал (PTY)', + 'Show Color': 'Показывать цвета', + 'Auto Accept': 'Автоподтверждение', + 'Use Ripgrep': 'Использовать Ripgrep', + 'Use Builtin Ripgrep': 'Использовать встроенный Ripgrep', + 'Enable Tool Output Truncation': 'Включить обрезку вывода инструментов', + 'Tool Output Truncation Threshold': 'Порог обрезки вывода инструментов', + 'Tool Output Truncation Lines': 'Лимит строк вывода инструментов', + 'Folder Trust': 'Доверие к папке', + 'Vision Model Preview': 'Визуальная модель (предпросмотр)', + 'Tool Schema Compliance': 'Соответствие схеме инструмента', + // Варианты перечислений настроек + 'Auto (detect from system)': 'Авто (определить из системы)', + Text: 'Текст', + JSON: 'JSON', + Plan: 'План', + Default: 'По умолчанию', + 'Auto Edit': 'Авторедактирование', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'Включение/выключение режима vim', + 'check session stats. Usage: /stats [model|tools]': + 'Просмотр статистики сессии. Использование: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Показать статистику использования модели.', + 'Show tool-specific usage statistics.': + 'Показать статистику использования инструментов.', + 'exit the cli': 'Выход из CLI', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Открыть диалог управления MCP или авторизоваться на сервере с поддержкой OAuth', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth', + 'Manage workspace directories': + 'Управление директориями рабочего пространства', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Добавить директории в рабочее пространство. Используйте запятую для разделения путей', + 'Show all directories in the workspace': + 'Показать все директории в рабочем пространстве', + 'set external editor preference': + 'Установка предпочитаемого внешнего редактора', + 'Select Editor': 'Выбрать редактор', + 'Editor Preference': 'Настройка редактора', + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.': + 'В настоящее время поддерживаются следующие редакторы. Обратите внимание, что некоторые редакторы нельзя использовать в режиме песочницы.', + 'Your preferred editor is:': 'Ваш предпочитаемый редактор:', + 'Manage extensions': 'Управление расширениями', + 'Manage installed extensions': 'Управлять установленными расширениями', + 'List active extensions': 'Показать активные расширения', + 'Update extensions. Usage: update |--all': + 'Обновить расширения. Использование: update |--all', + 'Disable an extension': 'Отключить расширение', + 'Enable an extension': 'Включить расширение', + 'Install an extension from a git repo or local path': + 'Установить расширение из Git-репозитория или локального пути', + 'Uninstall an extension': 'Удалить расширение', + 'No extensions installed.': 'Расширения не установлены.', + 'Usage: /extensions update |--all': + 'Использование: /extensions update <имена-расширений>|--all', + 'Extension "{{name}}" not found.': 'Расширение "{{name}}" не найдено.', + 'No extensions to update.': 'Нет расширений для обновления.', + 'Usage: /extensions install ': + 'Использование: /extensions install <источник>', + 'Installing extension from "{{source}}"...': + 'Установка расширения из "{{source}}"...', + 'Extension "{{name}}" installed successfully.': + 'Расширение "{{name}}" успешно установлено.', + 'Failed to install extension from "{{source}}": {{error}}': + 'Не удалось установить расширение из "{{source}}": {{error}}', + 'Usage: /extensions uninstall ': + 'Использование: /extensions uninstall <имя-расширения>', + 'Uninstalling extension "{{name}}"...': 'Удаление расширения "{{name}}"...', + 'Extension "{{name}}" uninstalled successfully.': + 'Расширение "{{name}}" успешно удалено.', + 'Failed to uninstall extension "{{name}}": {{error}}': + 'Не удалось удалить расширение "{{name}}": {{error}}', + 'Usage: /extensions {{command}} [--scope=]': + 'Использование: /extensions {{command}} <расширение> [--scope=]', + 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"': + 'Неподдерживаемая область "{{scope}}", должна быть "user" или "workspace"', + 'Extension "{{name}}" disabled for scope "{{scope}}"': + 'Расширение "{{name}}" отключено для области "{{scope}}"', + 'Extension "{{name}}" enabled for scope "{{scope}}"': + 'Расширение "{{name}}" включено для области "{{scope}}"', + 'Do you want to continue? [Y/n]: ': 'Хотите продолжить? [Y/n]: ', + 'Do you want to continue?': 'Хотите продолжить?', + 'Installing extension "{{name}}".': 'Установка расширения "{{name}}".', + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**': + '**Расширения могут вызывать неожиданное поведение. Убедитесь, что вы изучили источник расширения и доверяете автору.**', + 'This extension will run the following MCP servers:': + 'Это расширение запустит следующие MCP-серверы:', + local: 'локальный', + remote: 'удалённый', + 'This extension will add the following commands: {{commands}}.': + 'Это расширение добавит следующие команды: {{commands}}.', + 'This extension will append info to your QWEN.md context using {{fileName}}': + 'Это расширение добавит информацию в ваш контекст QWEN.md с помощью {{fileName}}', + 'This extension will exclude the following core tools: {{tools}}': + 'Это расширение исключит следующие основные инструменты: {{tools}}', + 'This extension will install the following skills:': + 'Это расширение установит следующие навыки:', + 'This extension will install the following subagents:': + 'Это расширение установит следующие подагенты:', + 'Installation cancelled for "{{name}}".': 'Установка "{{name}}" отменена.', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + 'Вы устанавливаете расширение от {{originSource}}. Некоторые функции могут работать не идеально с Qwen Code.', + '--ref and --auto-update are not applicable for marketplace extensions.': + '--ref и --auto-update неприменимы для расширений из маркетплейса.', + 'Extension "{{name}}" installed successfully and enabled.': + 'Расширение "{{name}}" успешно установлено и включено.', + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).': + 'Устанавливает расширение из URL Git-репозитория, локального пути или маркетплейса Claude (marketplace-url:plugin-name).', + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': + 'URL GitHub, локальный путь или источник в маркетплейсе (marketplace-url:plugin-name) устанавливаемого расширения.', + 'The git ref to install from.': 'Git-ссылка для установки.', + 'Enable auto-update for this extension.': + 'Включить автообновление для этого расширения.', + 'Enable pre-release versions for this extension.': + 'Включить пре-релизные версии для этого расширения.', + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': + 'Подтвердить риски безопасности установки расширения и пропустить запрос подтверждения.', + 'The source argument must be provided.': + 'Необходимо указать аргумент источника.', + 'Extension "{{name}}" successfully uninstalled.': + 'Расширение "{{name}}" успешно удалено.', + 'Uninstalls an extension.': 'Удаляет расширение.', + 'The name or source path of the extension to uninstall.': + 'Имя или путь к источнику удаляемого расширения.', + 'Please include the name of the extension to uninstall as a positional argument.': + 'Пожалуйста, укажите имя удаляемого расширения как позиционный аргумент.', + 'Enables an extension.': 'Включает расширение.', + 'The name of the extension to enable.': 'Имя включаемого расширения.', + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.': + 'Область для включения расширения. Если не задана, будет включено во всех областях.', + 'Extension "{{name}}" successfully enabled for scope "{{scope}}".': + 'Расширение "{{name}}" успешно включено для области "{{scope}}".', + 'Extension "{{name}}" successfully enabled in all scopes.': + 'Расширение "{{name}}" успешно включено во всех областях.', + 'Invalid scope: {{scope}}. Please use one of {{scopes}}.': + 'Недопустимая область: {{scope}}. Пожалуйста, используйте одну из {{scopes}}.', + 'Disables an extension.': 'Отключает расширение.', + 'The name of the extension to disable.': 'Имя отключаемого расширения.', + 'The scope to disable the extenison in.': + 'Область для отключения расширения.', + 'Extension "{{name}}" successfully disabled for scope "{{scope}}".': + 'Расширение "{{name}}" успешно отключено для области "{{scope}}".', + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.': + 'Расширение "{{name}}" успешно обновлено: {{oldVersion}} → {{newVersion}}.', + 'Unable to install extension "{{name}}" due to missing install metadata': + 'Невозможно установить расширение "{{name}}" из-за отсутствия метаданных установки', + 'Extension "{{name}}" is already up to date.': + 'Расширение "{{name}}" уже актуально.', + 'Updates all extensions or a named extension to the latest version.': + 'Обновляет все расширения или указанное расширение до последней версии.', + 'The name of the extension to update.': 'Имя обновляемого расширения.', + 'Update all extensions.': 'Обновить все расширения.', + 'Either an extension name or --all must be provided': + 'Необходимо указать имя расширения или --all', + 'Lists installed extensions.': 'Показывает установленные расширения.', + 'Path:': 'Путь:', + 'Source:': 'Источник:', + 'Type:': 'Тип:', + 'Ref:': 'Ссылка:', + 'Release tag:': 'Тег релиза:', + 'Enabled (User):': 'Включено (Пользователь):', + 'Enabled (Workspace):': 'Включено (Рабочее пространство):', + 'Context files:': 'Контекстные файлы:', + 'Skills:': 'Навыки:', + 'Agents:': 'Агенты:', + 'MCP servers:': 'MCP-серверы:', + 'Link extension failed to install.': + 'Не удалось установить связанное расширение.', + 'Extension "{{name}}" linked successfully and enabled.': + 'Расширение "{{name}}" успешно связано и включено.', + 'Links an extension from a local path. Updates made to the local path will always be reflected.': + 'Связывает расширение из локального пути. Изменения в локальном пути будут всегда отражаться.', + 'The name of the extension to link.': 'Имя связываемого расширения.', + 'Set a specific setting for an extension.': + 'Установить конкретную настройку для расширения.', + 'Name of the extension to configure.': 'Имя настраиваемого расширения.', + 'The setting to configure (name or env var).': + 'Настройка для конфигурирования (имя или переменная окружения).', + 'The scope to set the setting in.': 'Область для установки настройки.', + 'List all settings for an extension.': 'Показать все настройки расширения.', + 'Name of the extension.': 'Имя расширения.', + 'Extension "{{name}}" has no settings to configure.': + 'Расширение "{{name}}" не имеет настроек для конфигурирования.', + 'Settings for "{{name}}":': 'Настройки для "{{name}}":', + '(workspace)': '(рабочее пространство)', + '(user)': '(пользователь)', + '[not set]': '[не задано]', + '[value stored in keychain]': '[значение хранится в связке ключей]', + 'Manage extension settings.': 'Управление настройками расширений.', + 'You need to specify a command (set or list).': + 'Необходимо указать команду (set или list).', + // ============================================================================ + // Plugin Choice / Marketplace + // ============================================================================ + 'No plugins available in this marketplace.': + 'В этом маркетплейсе нет доступных плагинов.', + 'Select a plugin to install from marketplace "{{name}}":': + 'Выберите плагин для установки из маркетплейса "{{name}}":', + 'Plugin selection cancelled.': 'Выбор плагина отменён.', + 'Select a plugin from "{{name}}"': 'Выберите плагин из "{{name}}"', + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': + 'Используйте ↑↓ или j/k для навигации, Enter для выбора, Escape для отмены', + '{{count}} more above': 'ещё {{count}} выше', + '{{count}} more below': 'ещё {{count}} ниже', + 'manage IDE integration': 'Управление интеграцией с IDE', + 'check status of IDE integration': 'Проверить статус интеграции с IDE', + 'install required IDE companion for {{ideName}}': + 'Установить необходимый компаньон IDE для {{ideName}}', + 'enable IDE integration': 'Включение интеграции с IDE', + 'disable IDE integration': 'Отключение интеграции с IDE', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'Интеграция с IDE не поддерживается в вашем окружении. Для использования этой функции запустите Qwen Code в одной из поддерживаемых IDE: VS Code или форках VS Code.', + 'Set up GitHub Actions': 'Настройка GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Настройка привязки клавиш терминала для многострочного ввода (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Пожалуйста, перезапустите терминал для применения изменений.', + 'Failed to configure terminal: {{error}}': + 'Не удалось настроить терминал: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Не удалось определить путь конфигурации {{terminalName}} в Windows: переменная окружения APPDATA не установлена.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json существует, но не является корректным массивом JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'File: {{file}}': 'Файл: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Не удалось разобрать {{terminalName}} keybindings.json. Файл содержит некорректный JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'Error: {{error}}': 'Ошибка: {{error}}', + 'Shift+Enter binding already exists': 'Привязка Shift+Enter уже существует', + 'Ctrl+Enter binding already exists': 'Привязка Ctrl+Enter уже существует', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Обнаружены существующие привязки клавиш. Не будут изменены во избежание конфликтов.', + 'Please check and modify manually if needed: {{file}}': + 'Пожалуйста, проверьте и измените вручную при необходимости: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Добавлены привязки Shift+Enter и Ctrl+Enter для {{terminalName}}.', + 'Modified: {{file}}': 'Изменено: {{file}}', + '{{terminalName}} keybindings already configured.': + 'Привязки клавиш {{terminalName}} уже настроены.', + 'Failed to configure {{terminalName}}.': + 'Не удалось настроить {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Управлять хуками Qwen Code', + 'List all configured hooks': 'Показать все настроенные хуки', + 'Enable a disabled hook': 'Включить отключенный хук', + 'Disable an active hook': 'Отключить активный хук', + // Hooks - Dialog + Hooks: 'Хуки', + 'Loading hooks...': 'Загрузка хуков...', + 'Error loading hooks:': 'Ошибка загрузки хуков:', + 'Press Escape to close': 'Нажмите Escape для закрытия', + 'Press Escape, Ctrl+C, or Ctrl+D to cancel': + 'Нажмите Escape, Ctrl+C или Ctrl+D для отмены', + 'Press Space, Enter, or Escape to dismiss': + 'Нажмите Пробел, Enter или Escape для закрытия', + 'No hook selected': 'Хук не выбран', + // Hooks - List Step + 'No hook events found.': 'События хуков не найдены.', + '{{count}} hook configured': '{{count}} хук настроен', + '{{count}} hooks configured': '{{count}} хуков настроено', + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.': + 'Это меню только для чтения. Чтобы добавить или изменить хуки, отредактируйте settings.json напрямую или спросите Qwen Code.', + 'Enter to select · Esc to cancel': 'Enter для выбора · Esc для отмены', + // Hooks - Detail Step + 'Exit codes:': 'Коды выхода:', + 'Configured hooks:': 'Настроенные хуки:', + 'No hooks configured for this event.': + 'Для этого события нет настроенных хуков.', + 'To add hooks, edit settings.json directly or ask Qwen.': + 'Чтобы добавить хуки, отредактируйте settings.json напрямую или спросите Qwen.', + 'Enter to select · Esc to go back': 'Enter для выбора · Esc для возврата', + // Hooks - Config Detail Step + 'Hook details': 'Детали хука', + 'Event:': 'Событие:', + 'Extension:': 'Расширение:', + 'Desc:': 'Описание:', + 'No hook config selected': 'Конфигурация хука не выбрана', + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.': + 'Чтобы изменить или удалить этот хук, отредактируйте settings.json напрямую или спросите Qwen.', + // Hooks - Disabled Step + 'Hook Configuration - Disabled': 'Конфигурация хуков - Отключено', + 'All hooks are currently disabled. You have {{count}} that are not running.': + 'Все хуки в данный момент отключены. У вас {{count}} не выполняются.', + '{{count}} configured hook': '{{count}} настроенный хук', + '{{count}} configured hooks': '{{count}} настроенных хуков', + 'When hooks are disabled:': 'Когда хуки отключены:', + 'No hook commands will execute': 'Никакие команды хуков не будут выполняться', + 'StatusLine will not be displayed': 'StatusLine не будет отображаться', + 'Tool operations will proceed without hook validation': + 'Операции инструментов будут выполняться без проверки хуков', + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.': + 'Чтобы снова включить хуки, удалите "disableAllHooks" из settings.json или спросите Qwen Code.', + // Hooks - Source + Project: 'Проект', + User: 'Пользователь', + System: 'Система', + Extension: 'Расширение', + 'Local Settings': 'Локальные настройки', + 'User Settings': 'Пользовательские настройки', + 'System Settings': 'Системные настройки', + Extensions: 'Расширения', + // Hooks - Status + '✓ Enabled': '✓ Включен', + '✗ Disabled': '✗ Отключен', + // Hooks - Event Descriptions (short) + 'Before tool execution': 'Перед выполнением инструмента', + 'After tool execution': 'После выполнения инструмента', + 'After tool execution fails': 'При неудачном выполнении инструмента', + 'When notifications are sent': 'При отправке уведомлений', + 'When the user submits a prompt': 'Когда пользователь отправляет промпт', + 'When a new session is started': 'При запуске новой сессии', + 'Right before Qwen Code concludes its response': + 'Непосредственно перед завершением ответа Qwen Code', + 'When a subagent (Agent tool call) is started': + 'При запуске субагента (вызов инструмента Agent)', + 'Right before a subagent concludes its response': + 'Непосредственно перед завершением ответа субагента', + 'Before conversation compaction': 'Перед сжатием разговора', + 'When a session is ending': 'При завершении сессии', + 'When a permission dialog is displayed': 'При отображении диалога разрешений', + // Hooks - Event Descriptions (detailed) + 'Input to command is JSON of tool call arguments.': + 'Ввод в команду — это JSON аргументов вызова инструмента.', + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).': + 'Ввод в команду — это JSON с полями "inputs" (аргументы вызова инструмента) и "response" (ответ вызова инструмента).', + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.': + 'Ввод в команду — это JSON с tool_name, tool_input, tool_use_id, error, error_type, is_interrupt и is_timeout.', + 'Input to command is JSON with notification message and type.': + 'Ввод в команду — это JSON с сообщением уведомления и типом.', + 'Input to command is JSON with original user prompt text.': + 'Ввод в команду — это JSON с исходным текстом промпта пользователя.', + 'Input to command is JSON with session start source.': + 'Ввод в команду — это JSON с источником запуска сессии.', + 'Input to command is JSON with session end reason.': + 'Ввод в команду — это JSON с причиной завершения сессии.', + 'Input to command is JSON with agent_id and agent_type.': + 'Ввод в команду — это JSON с agent_id и agent_type.', + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.': + 'Ввод в команду — это JSON с agent_id, agent_type и agent_transcript_path.', + 'Input to command is JSON with compaction details.': + 'Ввод в команду — это JSON с деталями сжатия.', + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.': + 'Ввод в команду — это JSON с tool_name, tool_input и tool_use_id. Вывод — JSON с hookSpecificOutput, содержащим решение о разрешении или отказе.', + // Hooks - Exit Code Descriptions + 'stdout/stderr not shown': 'stdout/stderr не отображаются', + 'show stderr to model and continue conversation': + 'показать stderr модели и продолжить разговор', + 'show stderr to user only': 'показать stderr только пользователю', + 'stdout shown in transcript mode (ctrl+o)': + 'stdout отображается в режиме транскрипции (ctrl+o)', + 'show stderr to model immediately': 'показать stderr модели немедленно', + 'show stderr to user only but continue with tool call': + 'показать stderr только пользователю, но продолжить вызов инструмента', + 'block processing, erase original prompt, and show stderr to user only': + 'заблокировать обработку, стереть исходный промпт и показать stderr только пользователю', + 'stdout shown to Qwen': 'stdout показан Qwen', + 'show stderr to user only (blocking errors ignored)': + 'показать stderr только пользователю (блокирующие ошибки игнорируются)', + 'command completes successfully': 'команда успешно завершена', + 'stdout shown to subagent': 'stdout показан субагенту', + 'show stderr to subagent and continue having it run': + 'показать stderr субагенту и продолжить его выполнение', + 'stdout appended as custom compact instructions': + 'stdout добавлен как пользовательские инструкции сжатия', + 'block compaction': 'заблокировать сжатие', + 'show stderr to user only but continue with compaction': + 'показать stderr только пользователю, но продолжить сжатие', + 'use hook decision if provided': + 'использовать решение хука, если предоставлено', + // Hooks - Messages + 'Config not loaded.': 'Конфигурация не загружена.', + 'Hooks are not enabled. Enable hooks in settings to use this feature.': + 'Хуки не включены. Включите хуки в настройках, чтобы использовать эту функцию.', + 'No hooks configured. Add hooks in your settings.json file.': + 'Хуки не настроены. Добавьте хуки в файл settings.json.', + 'Configured Hooks ({{count}} total)': 'Настроенные хуки (всего {{count}})', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Экспортировать историю сообщений текущей сессии в файл', + 'Export session to HTML format': 'Экспортировать сессию в формат HTML', + 'Export session to JSON format': 'Экспортировать сессию в формат JSON', + 'Export session to JSONL format (one message per line)': + 'Экспортировать сессию в формат JSONL (одно сообщение на строку)', + 'Export session to markdown format': + 'Экспортировать сессию в формат Markdown', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'Создать персонализированные инсайты по программированию на основе истории чата', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Продолжить предыдущую сессию', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Восстановить вызов инструмента. Это вернет историю разговора и файлов к состоянию на момент, когда был предложен этот вызов инструмента', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Терминал "{{terminal}}" еще не поддерживается.', + + // ============================================================================ + // Команды - Язык + // ============================================================================ + 'Invalid language. Available: {{options}}': + 'Недопустимый язык. Доступны: {{options}}', + 'Language subcommands do not accept additional arguments.': + 'Подкоманды языка не принимают дополнительных аргументов.', + 'Current UI language: {{lang}}': 'Текущий язык интерфейса: {{lang}}', + 'Current LLM output language: {{lang}}': 'Текущий язык вывода LLM: {{lang}}', + 'LLM output language not set': 'Язык вывода LLM не установлен', + 'Set UI language': 'Установка языка интерфейса', + 'Set LLM output language': 'Установка языка вывода LLM', + 'Usage: /language ui [{{options}}]': + 'Использование: /language ui [{{options}}]', + 'Usage: /language output ': + 'Использование: /language output ', + 'Example: /language output 中文': 'Пример: /language output 中文', + 'Example: /language output English': 'Пример: /language output English', + 'Example: /language output 日本語': 'Пример: /language output 日本語', + 'Example: /language output Português': 'Пример: /language output Português', + 'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}', + 'LLM output language set to {{lang}}': + 'Язык вывода LLM установлен на {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'Файл правил языка вывода LLM создан в {{path}}', + 'Please restart the application for the changes to take effect.': + 'Пожалуйста, перезапустите приложение для применения изменений.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Не удалось создать файл правил языка вывода LLM: {{error}}', + 'Invalid command. Available subcommands:': + 'Неверная команда. Доступные подкоманды:', + 'Available subcommands:': 'Доступные подкоманды:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.', + 'Available options:': 'Доступные варианты:', + 'Set UI language to {{name}}': 'Установить язык интерфейса на {{name}}', + + // ============================================================================ + // Команды - Режим подтверждения + // ============================================================================ + 'Tool Approval Mode': 'Режим подтверждения инструментов', + 'Current approval mode: {{mode}}': 'Текущий режим подтверждения: {{mode}}', + 'Available approval modes:': 'Доступные режимы подтверждения:', + 'Approval mode changed to: {{mode}}': + 'Режим подтверждения изменен на: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Использование: /approval-mode [--session|--user|--project]', + 'Scope subcommands do not accept additional arguments.': + 'Подкоманды области не принимают дополнительных аргументов.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Режим планирования - только анализ, без изменения файлов или выполнения команд', + 'Default mode - Require approval for file edits or shell commands': + 'Режим по умолчанию - требуется подтверждение для редактирования файлов или команд терминала', + 'Auto-edit mode - Automatically approve file edits': + 'Режим авторедактирования - автоматическое подтверждение изменений файлов', + 'YOLO mode - Automatically approve all tools': + 'Режим YOLO - автоматическое подтверждение всех инструментов', + '{{mode}} mode': 'Режим {{mode}}', + 'Settings service is not available; unable to persist the approval mode.': + 'Служба настроек недоступна; невозможно сохранить режим подтверждения.', + 'Failed to save approval mode: {{error}}': + 'Не удалось сохранить режим подтверждения: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Не удалось изменить режим подтверждения: {{error}}', + 'Apply to current session only (temporary)': + 'Применить только к текущей сессии (временно)', + 'Persist for this project/workspace': + 'Сохранить для этого проекта/рабочего пространства', + 'Persist for this user on this machine': + 'Сохранить для этого пользователя на этой машине', + 'Analyze only, do not modify files or execute commands': + 'Только анализ, без изменения файлов или выполнения команд', + 'Require approval for file edits or shell commands': + 'Требуется подтверждение для редактирования файлов или команд терминала', + 'Automatically approve file edits': + 'Автоматически подтверждать изменения файлов', + 'Automatically approve all tools': + 'Автоматически подтверждать все инструменты', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Режим подтверждения рабочего пространства существует и имеет приоритет. Изменение на уровне пользователя не будет иметь эффекта.', + 'Apply To': 'Применить к', + 'Workspace Settings': 'Настройки рабочего пространства', + + // ============================================================================ + // Команды - Память + // ============================================================================ + 'Commands for interacting with memory.': + 'Команды для взаимодействия с памятью', + 'Show the current memory contents.': 'Показать текущее содержимое памяти.', + 'Show project-level memory contents.': 'Показать память уровня проекта.', + 'Show global memory contents.': 'Показать глобальную память.', + 'Add content to project-level memory.': + 'Добавить содержимое в память уровня проекта.', + 'Add content to global memory.': 'Добавить содержимое в глобальную память.', + 'Refresh the memory from the source.': 'Обновить память из источника.', + 'Usage: /memory add --project ': + 'Использование: /memory add --project <текст для запоминания>', + 'Usage: /memory add --global ': + 'Использование: /memory add --global <текст для запоминания>', + 'Attempting to save to project memory: "{{text}}"': + 'Попытка сохранить в память проекта: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Попытка сохранить в глобальную память: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Текущее содержимое памяти из {{count}} файла(ов):', + 'Memory is currently empty.': 'Память в настоящее время пуста.', + 'Project memory file not found or is currently empty.': + 'Файл памяти проекта не найден или в настоящее время пуст.', + 'Global memory file not found or is currently empty.': + 'Файл глобальной памяти не найден или в настоящее время пуст.', + 'Global memory is currently empty.': + 'Глобальная память в настоящее время пуста.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Содержимое глобальной памяти:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Содержимое памяти проекта из {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': + 'Память проекта в настоящее время пуста.', + 'Refreshing memory from source files...': + 'Обновление памяти из исходных файлов...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Добавить содержимое в память. Используйте --global для глобальной памяти или --project для памяти проекта.', + 'Usage: /memory add [--global|--project] ': + 'Использование: /memory add [--global|--project] <текст для запоминания>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Попытка сохранить в память {{scope}}: "{{fact}}"', + + // ============================================================================ + // Команды - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Авторизоваться на MCP-сервере с поддержкой OAuth', + 'List configured MCP servers and tools': + 'Просмотр настроенных MCP-серверов и инструментов', + 'Restarts MCP servers.': 'Перезапустить MCP-серверы.', + 'Could not retrieve tool registry.': + 'Не удалось получить реестр инструментов.', + 'No MCP servers configured with OAuth authentication.': + 'Нет MCP-серверов, настроенных с авторизацией OAuth.', + 'MCP servers with OAuth authentication:': 'MCP-серверы с авторизацией OAuth:', + 'Use /mcp auth to authenticate.': + 'Используйте /mcp auth <имя-сервера> для авторизации.', + "MCP server '{{name}}' not found.": "MCP-сервер '{{name}}' не найден.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Успешно авторизовано и обновлены инструменты для '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "Повторное обнаружение инструментов от '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "Обнаружено {{count}} инструмент(ов) от '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Аутентификация завершена. Возврат к деталям сервера...', + 'Authentication successful.': 'Аутентификация успешна.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Если браузер не открылся, скопируйте этот URL и вставьте его в браузер:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Убедитесь, что скопировали ПОЛНЫЙ URL — он может занимать несколько строк.', + + // ============================================================================ + // Команды - Чат + // ============================================================================ + 'Manage conversation history.': 'Управление историей диалогов.', + 'List saved conversation checkpoints': + 'Показать сохраненные точки восстановления диалога', + 'No saved conversation checkpoints found.': + 'Не найдено сохраненных точек восстановления диалога.', + 'List of saved conversations:': 'Список сохраненных диалогов:', + 'Note: Newest last, oldest first': + 'Примечание: новые последними, старые первыми', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Сохранить текущий диалог как точку восстановления. Использование: /chat save <тег>', + 'Missing tag. Usage: /chat save ': + 'Отсутствует тег. Использование: /chat save <тег>', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Удалить точку восстановления диалога. Использование: /chat delete <тег>', + 'Missing tag. Usage: /chat delete ': + 'Отсутствует тег. Использование: /chat delete <тег>', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Точка восстановления диалога '{{tag}}' удалена.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Ошибка: точка восстановления с тегом '{{tag}}' не найдена.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Возобновить диалог из точки восстановления. Использование: /chat resume <тег>', + 'Missing tag. Usage: /chat resume ': + 'Отсутствует тег. Использование: /chat resume <тег>', + 'No saved checkpoint found with tag: {{tag}}.': + 'Не найдена сохраненная точка восстановления с тегом: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'Точка восстановления с тегом {{tag}} уже существует. Перезаписать?', + 'No chat client available to save conversation.': + 'Нет доступного клиента чата для сохранения диалога.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Точка восстановления диалога сохранена с тегом: {{tag}}.', + 'No conversation found to save.': 'Нет диалога для сохранения.', + 'No chat client available to share conversation.': + 'Нет доступного клиента чата для экспорта диалога.', + 'Invalid file format. Only .md and .json are supported.': + 'Неверный формат файла. Поддерживаются только .md и .json.', + 'Error sharing conversation: {{error}}': + 'Ошибка при экспорте диалога: {{error}}', + 'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}', + 'No conversation found to share.': 'Нет диалога для экспорта.', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + 'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <файл>', + + // ============================================================================ + // Команды - Резюме + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Сгенерировать сводку проекта и сохранить её в .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'Нет доступного чат-клиента для генерации сводки.', + 'Already generating summary, wait for previous request to complete': + 'Генерация сводки уже выполняется, дождитесь завершения предыдущего запроса', + 'No conversation found to summarize.': + 'Не найдено диалогов для создания сводки.', + 'Failed to generate project context summary: {{error}}': + 'Не удалось сгенерировать сводку контекста проекта: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'Сводка проекта сохранена в {{filePathForDisplay}}', + 'Saving project summary...': 'Сохранение сводки проекта...', + 'Generating project summary...': 'Генерация сводки проекта...', + 'Failed to generate summary - no text content received from LLM response': + 'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM', + + // ============================================================================ + // Команды - Модель + // ============================================================================ + 'Switch the model for this session': 'Переключение модели для этой сессии', + 'Set fast model for background tasks': + 'Установить быструю модель для фоновых задач', + 'Content generator configuration not available.': + 'Конфигурация генератора содержимого недоступна.', + 'Authentication type not available.': 'Тип авторизации недоступен.', + 'No models available for the current authentication type ({{authType}}).': + 'Нет доступных моделей для текущего типа авторизации ({{authType}}).', + + // ============================================================================ + // Команды - Очистка + // ============================================================================ + 'Starting a new session, resetting chat, and clearing terminal.': + 'Начало новой сессии, сброс чата и очистка терминала.', + 'Starting a new session and clearing.': 'Начало новой сессии и очистка.', + + // ============================================================================ + // Команды - Сжатие + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Уже выполняется сжатие, дождитесь завершения предыдущего запроса', + 'Failed to compress chat history.': 'Не удалось сжать историю чата.', + 'Failed to compress chat history: {{error}}': + 'Не удалось сжать историю чата: {{error}}', + 'Compressing chat history': 'Сжатие истории чата', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'История чата сжата с {{originalTokens}} до {{newTokens}} токенов.', + 'Compression was not beneficial for this history size.': + 'Сжатие не было полезным для этого размера истории.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Сжатие истории чата не уменьшило размер. Это может указывать на проблемы с промптом сжатия.', + 'Could not compress chat history due to a token counting error.': + 'Не удалось сжать историю чата из-за ошибки подсчета токенов.', + 'Chat history is already compressed.': 'История чата уже сжата.', + + // ============================================================================ + // Команды - Директория + // ============================================================================ + 'Configuration is not available.': 'Конфигурация недоступна.', + 'Please provide at least one path to add.': + 'Пожалуйста, укажите хотя бы один путь для добавления.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.', + "Error adding '{{path}}': {{error}}": + "Ошибка при добавлении '{{path}}': {{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + 'Успешно добавлены файлы QWEN.md из следующих директорий (если они есть):\n- {{directories}}', + 'Error refreshing memory: {{error}}': + 'Ошибка при обновлении памяти: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Успешно добавлены директории:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Текущие директории рабочего пространства:\n{{directories}}', + + // ============================================================================ + // Команды - Документация + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Пожалуйста, откройте следующий URL в браузере для просмотра документации:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Открытие документации в браузере: {{url}}', + + // ============================================================================ + // Диалоги - Подтверждение инструментов + // ============================================================================ + 'Do you want to proceed?': 'Вы хотите продолжить?', + 'Yes, allow once': 'Да, разрешить один раз', + 'Allow always': 'Всегда разрешать', + Yes: 'Да', + No: 'Нет', + 'No (esc)': 'Нет (esc)', + 'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии', + + // MCP Management - Core translations + Disable: 'Отключить', + Enable: 'Включить', + Authenticate: 'Аутентификация', + 'Re-authenticate': 'Повторная аутентификация', + 'Clear Authentication': 'Очистить аутентификацию', + disabled: 'отключен', + 'Server:': 'Сервер:', + Reconnect: 'Переподключить', + 'View tools': 'Просмотреть инструменты', + '(disabled)': '(отключен)', + 'Error:': 'Ошибка:', + tool: 'инструмент', + connected: 'подключен', + connecting: 'подключение', + disconnected: 'отключен', + error: 'ошибка', + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} недействительных инструментов', + invalid: 'недействительный', + 'invalid: {{reason}}': 'недействительно: {{reason}}', + 'missing name': 'отсутствует имя', + 'missing description': 'отсутствует описание', + '(unnamed)': '(без имени)', + 'Warning: This tool cannot be called by the LLM': + 'Предупреждение: Этот инструмент не может быть вызван LLM', + Reason: 'Причина', + 'Tools must have both name and description to be used by the LLM.': + 'Инструменты должны иметь как имя, так и описание, чтобы использоваться LLM.', + 'Modify in progress:': 'Идет изменение:', + 'Save and close external editor to continue': + 'Сохраните и закройте внешний редактор для продолжения', + 'Apply this change?': 'Применить это изменение?', + 'Yes, allow always': 'Да, всегда разрешать', + 'Modify with external editor': 'Изменить во внешнем редакторе', + 'No, suggest changes (esc)': 'Нет, предложить изменения (esc)', + "Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?", + 'Yes, allow always ...': 'Да, всегда разрешать ...', + 'Always allow in this project': 'Всегда разрешать в этом проекте', + 'Always allow {{action}} in this project': + 'Всегда разрешать {{action}} в этом проекте', + 'Always allow for this user': 'Всегда разрешать для этого пользователя', + 'Always allow {{action}} for this user': + 'Всегда разрешать {{action}} для этого пользователя', + 'Yes, restore previous mode ({{mode}})': + 'Да, восстановить предыдущий режим ({{mode}})', + 'Yes, and auto-accept edits': 'Да, и автоматически принимать правки', + 'Yes, and manually approve edits': 'Да, и вручную подтверждать правки', + 'No, keep planning (esc)': 'Нет, продолжить планирование (esc)', + 'URLs to fetch:': 'URL для загрузки:', + 'MCP Server: {{server}}': 'MCP-сервер: {{server}}', + 'Tool: {{tool}}': 'Инструмент: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Разрешить выполнение инструмента MCP "{{tool}}" с сервера "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Да, всегда разрешать инструмент "{{tool}}" с сервера "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'Да, всегда разрешать все инструменты с сервера "{{server}}"', + + // ============================================================================ + // Диалоги - Подтверждение оболочки + // ============================================================================ + 'Shell Command Execution': 'Выполнение команды терминала', + 'A custom command wants to run the following shell commands:': + 'Пользовательская команда хочет выполнить следующие команды терминала:', + + // ============================================================================ + // Диалоги - Квота подписки Pro + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Исчерпана квота подписки Pro для {{model}}.', + 'Change auth (executes the /auth command)': + 'Изменить авторизацию (выполняет команду /auth)', + 'Continue with {{model}}': 'Продолжить с {{model}}', + + // ============================================================================ + // Диалоги - Приветствие при возвращении + // ============================================================================ + 'Current Plan:': 'Текущий план:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Прогресс: {{done}}/{{total}} задач выполнено', + ', {{inProgress}} in progress': ', {{inProgress}} в процессе', + 'Pending Tasks:': 'Ожидающие задачи:', + 'What would you like to do?': 'Что вы хотите сделать?', + 'Choose how to proceed with your session:': + 'Выберите, как продолжить сессию:', + 'Start new chat session': 'Начать новую сессию чата', + 'Continue previous conversation': 'Продолжить предыдущий диалог', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 С возвращением! (Последнее обновление: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Общая цель:', + + // ============================================================================ + // Диалоги - Авторизация + // ============================================================================ + 'Get started': 'Начать', + 'Select Authentication Method': 'Выберите метод авторизации', + 'OpenAI API key is required to use OpenAI authentication.': + 'Для использования авторизации OpenAI требуется ключ API OpenAI.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'Вы должны выбрать метод авторизации для продолжения. Нажмите Ctrl+C снова для выхода.', + 'Terms of Services and Privacy Notice': + 'Условия обслуживания и уведомление о конфиденциальности', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Платно \u00B7 До 6 000 запросов/5 часов \u00B7 Все модели Alibaba Cloud Coding Plan', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Используйте свой API-ключ', + 'API-KEY': 'API-KEY', + 'Use coding plan credentials or your own api-keys/providers.': + 'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Не удалось войти. Сообщение: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Авторизация должна быть {{enforcedType}}, но вы сейчас используете {{currentType}}.', + 'Please visit this URL to authorize:': + 'Пожалуйста, посетите этот URL для авторизации:', + 'Or scan the QR code below:': 'Или отсканируйте QR-код ниже:', + 'Waiting for authorization': 'Ожидание авторизации', + 'Time remaining:': 'Осталось времени:', + '(Press ESC or CTRL+C to cancel)': '(Нажмите ESC или CTRL+C для отмены)', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'Токен OAuth истек (более {{seconds}} секунд). Пожалуйста, выберите метод авторизации снова.', + 'Press any key to return to authentication type selection.': + 'Нажмите любую клавишу для возврата к выбору типа авторизации.', + 'Authentication timed out. Please try again.': + 'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + 'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Укажите settings.security.auth.apiKey или переменную окружения {{envKeyHint}}.', + '{{envKeyHint}} environment variable not found.': + 'Переменная окружения {{envKeyHint}} не найдена.', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + 'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + 'Переменная окружения {{envKeyHint}} не найдена (или установите settings.security.auth.apiKey). Укажите её в файле .env или среди системных переменных.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Установите переменную окружения {{envKeyHint}}.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'У провайдера Anthropic отсутствует обязательный baseUrl в modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'Переменная окружения ANTHROPIC_BASE_URL не найдена.', + 'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.', + 'Failed to authenticate. Message: {{message}}': + 'Не удалось авторизоваться. Сообщение: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Успешно авторизовано с учетными данными {{authType}}.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Неверное значение QWEN_DEFAULT_AUTH_TYPE: "{{value}}". Допустимые значения: {{validValues}}', + 'OpenAI Configuration Required': 'Требуется конфигурация OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Пожалуйста, введите конфигурацию OpenAI. Вы можете получить ключ API на', + 'API Key:': 'Ключ API:', + 'Invalid credentials: {{errorMessage}}': + 'Неверные учетные данные: {{errorMessage}}', + 'Failed to validate credentials': 'Не удалось проверить учетные данные', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Enter для продолжения, Tab/↑↓ для навигации, Esc для отмены', + + // ============================================================================ + // Диалоги - Модель + // ============================================================================ + 'Select Model': 'Выбрать модель', + '(Press Esc to close)': '(Нажмите Esc для закрытия)', + 'Current (effective) configuration': 'Текущая (фактическая) конфигурация', + AuthType: 'Тип авторизации', + 'API Key': 'API-ключ', + unset: 'не задано', + '(default)': '(по умолчанию)', + '(set)': '(установлено)', + '(not set)': '(не задано)', + Modality: 'Модальность', + 'Context Window': 'Контекстное окно', + text: 'текст', + 'text-only': 'только текст', + image: 'изображение', + pdf: 'PDF', + audio: 'аудио', + video: 'видео', + 'not set': 'не задано', + none: 'нет', + unknown: 'неизвестно', + "Failed to switch model to '{{modelId}}'.\n\n{{error}}": + "Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}", + 'Qwen 3.6 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.6 Plus — эффективная гибридная модель с лидирующей производительностью в программировании', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'Последняя модель Qwen Vision от Alibaba Cloud ModelStudio (версия: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Диалоги - Разрешения + // ============================================================================ + 'Manage folder trust settings': 'Управление настройками доверия к папкам', + 'Manage permission rules': 'Управление правилами разрешений', + Allow: 'Разрешить', + Ask: 'Спросить', + Deny: 'Запретить', + Workspace: 'Рабочая область', + "Qwen Code won't ask before using allowed tools.": + 'Qwen Code не будет спрашивать перед использованием разрешённых инструментов.', + 'Qwen Code will ask before using these tools.': + 'Qwen Code спросит перед использованием этих инструментов.', + 'Qwen Code is not allowed to use denied tools.': + 'Qwen Code не может использовать запрещённые инструменты.', + 'Manage trusted directories for this workspace.': + 'Управление доверенными каталогами для этой рабочей области.', + 'Any use of the {{tool}} tool': 'Любое использование инструмента {{tool}}', + "{{tool}} commands matching '{{pattern}}'": + "Команды {{tool}}, соответствующие '{{pattern}}'", + 'From user settings': 'Из пользовательских настроек', + 'From project settings': 'Из настроек проекта', + 'From session': 'Из сессии', + 'Project settings (local)': 'Настройки проекта (локальные)', + 'Saved in .qwen/settings.local.json': 'Сохранено в .qwen/settings.local.json', + 'Project settings': 'Настройки проекта', + 'Checked in at .qwen/settings.json': 'Зафиксировано в .qwen/settings.json', + 'User settings': 'Пользовательские настройки', + 'Saved in at ~/.qwen/settings.json': 'Сохранено в ~/.qwen/settings.json', + 'Add a new rule…': 'Добавить новое правило…', + 'Add {{type}} permission rule': 'Добавить правило разрешения {{type}}', + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.': + 'Правила разрешений — это имя инструмента, за которым может следовать спецификатор в скобках.', + 'e.g.,': 'напр.', + or: 'или', + 'Enter permission rule…': 'Введите правило разрешения…', + 'Enter to submit · Esc to cancel': 'Enter для отправки · Esc для отмены', + 'Where should this rule be saved?': 'Где сохранить это правило?', + 'Enter to confirm · Esc to cancel': + 'Enter для подтверждения · Esc для отмены', + 'Delete {{type}} rule?': 'Удалить правило {{type}}?', + 'Are you sure you want to delete this permission rule?': + 'Вы уверены, что хотите удалить это правило разрешения?', + 'Permissions:': 'Разрешения:', + '(←/→ or tab to cycle)': '(←/→ или Tab для переключения)', + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel': + '↑↓ навигация · Enter выбор · Ввод для поиска · Esc отмена', + 'Search…': 'Поиск…', + 'Use /trust to manage folder trust settings for this workspace.': + 'Используйте /trust для управления настройками доверия к папкам этой рабочей области.', + // Workspace directory management + 'Add directory…': 'Добавить каталог…', + 'Add directory to workspace': 'Добавить каталог в рабочую область', + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.': + 'Qwen Code может читать файлы в рабочей области и вносить правки, когда автоприём правок включён.', + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.': + 'Qwen Code сможет читать файлы в этом каталоге и вносить правки, когда автоприём правок включён.', + 'Enter the path to the directory:': 'Введите путь к каталогу:', + 'Enter directory path…': 'Введите путь к каталогу…', + 'Tab to complete · Enter to add · Esc to cancel': + 'Tab для завершения · Enter для добавления · Esc для отмены', + 'Remove directory?': 'Удалить каталог?', + 'Are you sure you want to remove this directory from the workspace?': + 'Вы уверены, что хотите удалить этот каталог из рабочей области?', + ' (Original working directory)': ' (Исходный рабочий каталог)', + ' (from settings)': ' (из настроек)', + 'Directory does not exist.': 'Каталог не существует.', + 'Path is not a directory.': 'Путь не является каталогом.', + 'This directory is already in the workspace.': + 'Этот каталог уже есть в рабочей области.', + 'Already covered by existing directory: {{dir}}': + 'Уже охвачен существующим каталогом: {{dir}}', + + // ============================================================================ + // Строка состояния + // ============================================================================ + 'Using:': 'Используется:', + '{{count}} open file': '{{count}} открытый файл', + '{{count}} open files': '{{count}} открытых файла(ов)', + '(ctrl+g to view)': '(ctrl+g для просмотра)', + '{{count}} {{name}} file': '{{count}} файл {{name}}', + '{{count}} {{name}} files': '{{count}} файла(ов) {{name}}', + '{{count}} MCP server': '{{count}} MCP-сервер', + '{{count}} MCP servers': '{{count}} MCP-сервера(ов)', + '{{count}} Blocked': '{{count}} заблокирован(о)', + '(ctrl+t to view)': '(ctrl+t для просмотра)', + '(ctrl+t to toggle)': '(ctrl+t для переключения)', + 'Press Ctrl+C again to exit.': 'Нажмите Ctrl+C снова для выхода.', + 'Press Ctrl+D again to exit.': 'Нажмите Ctrl+D снова для выхода.', + 'Press Esc again to clear.': 'Нажмите Esc снова для очистки.', + + // ============================================================================ + // Статус MCP + // ============================================================================ + 'No MCP servers configured.': 'Не настроено MCP-серверов.', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP-серверы запускаются ({{count}} инициализируется)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Примечание: Первый запуск может занять больше времени. Доступность инструментов обновится автоматически.', + 'Configured MCP servers:': 'Настроенные MCP-серверы:', + Ready: 'Готов', + 'Starting... (first startup may take longer)': + 'Запуск... (первый запуск может занять больше времени)', + Disconnected: 'Отключен', + '{{count}} tool': '{{count}} инструмент', + '{{count}} tools': '{{count}} инструмента(ов)', + '{{count}} prompt': '{{count}} промпт', + '{{count}} prompts': '{{count}} промпта(ов)', + '(from {{extensionName}})': '(от {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth истек', + 'OAuth not authenticated': 'OAuth не авторизован', + 'tools and prompts will appear when ready': + 'инструменты и промпты появятся, когда будут готовы', + '{{count}} tools cached': '{{count}} инструмента(ов) в кэше', + 'Tools:': 'Инструменты:', + 'Parameters:': 'Параметры:', + 'Prompts:': 'Промпты:', + Blocked: 'Заблокировано', + '💡 Tips:': '💡 Подсказки:', + Use: 'Используйте', + 'to show server and tool descriptions': + 'для показа описаний сервера и инструментов', + 'to show tool parameter schemas': 'для показа схем параметров инструментов', + 'to hide descriptions': 'для скрытия описаний', + 'to authenticate with OAuth-enabled servers': + 'для авторизации на серверах с поддержкой OAuth', + Press: 'Нажмите', + 'to toggle tool descriptions on/off': + 'для переключения описаний инструментов', + "Starting OAuth authentication for MCP server '{{name}}'...": + "Начало авторизации OAuth для MCP-сервера '{{name}}'...", + 'Restarting MCP servers...': 'Перезапуск MCP-серверов...', + + // ============================================================================ + // Подсказки при запуске + // ============================================================================ + 'Tips for getting started:': 'Подсказки для начала работы:', + '1. Ask questions, edit files, or run commands.': + '1. Задавайте вопросы, редактируйте файлы или выполняйте команды.', + '2. Be specific for the best results.': + '2. Будьте конкретны для лучших результатов.', + 'files to customize your interactions with Qwen Code.': + 'файлы для настройки взаимодействия с Qwen Code.', + 'for more information.': 'для получения дополнительной информации.', + + // ============================================================================ + // Экран выхода / Статистика + // ============================================================================ + 'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!', + 'To continue this session, run': 'Для продолжения этой сессии, выполните', + 'Interaction Summary': 'Сводка взаимодействия', + 'Session ID:': 'ID сессии:', + 'Tool Calls:': 'Вызовы инструментов:', + 'Success Rate:': 'Процент успеха:', + 'User Agreement:': 'Согласие пользователя:', + reviewed: 'проверено', + 'Code Changes:': 'Изменения кода:', + Performance: 'Производительность', + 'Wall Time:': 'Общее время:', + 'Agent Active:': 'Активность агента:', + 'API Time:': 'Время API:', + 'Tool Time:': 'Время инструментов:', + 'Session Stats': 'Статистика сессии', + 'Model Usage': 'Использование модели', + Reqs: 'Запросов', + 'Input Tokens': 'Входных токенов', + 'Output Tokens': 'Выходных токенов', + 'Savings Highlight:': 'Экономия:', + 'of input tokens were served from the cache, reducing costs.': + 'входных токенов обслужено из кэша, снижая затраты.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Подсказка: Для полной разбивки токенов выполните `/stats model`.', + 'Model Stats For Nerds': 'Статистика модели для гиков', + 'Tool Stats For Nerds': 'Статистика инструментов для гиков', + Metric: 'Метрика', + API: 'API', + Requests: 'Запросы', + Errors: 'Ошибки', + 'Avg Latency': 'Средняя задержка', + Tokens: 'Токены', + Total: 'Всего', + Prompt: 'Промпт', + Cached: 'Кэшировано', + Thoughts: 'Размышления', + Tool: 'Инструмент', + Output: 'Вывод', + 'No API calls have been made in this session.': + 'В этой сессии не было вызовов API.', + 'Tool Name': 'Имя инструмента', + Calls: 'Вызовы', + 'Success Rate': 'Процент успеха', + 'Avg Duration': 'Средняя длительность', + 'User Decision Summary': 'Сводка решений пользователя', + 'Total Reviewed Suggestions:': 'Всего проверено предложений:', + ' » Accepted:': ' » Принято:', + ' » Rejected:': ' » Отклонено:', + ' » Modified:': ' » Изменено:', + ' Overall Agreement Rate:': ' Общий процент согласия:', + 'No tool calls have been made in this session.': + 'В этой сессии не было вызовов инструментов.', + 'Session start time is unavailable, cannot calculate stats.': + 'Время начала сессии недоступно, невозможно рассчитать статистику.', + + // ============================================================================ + // Command Format Migration + // ============================================================================ + 'Command Format Migration': 'Миграция формата команд', + 'Found {{count}} TOML command file:': 'Найден {{count}} файл команд TOML:', + 'Found {{count}} TOML command files:': + 'Найдено {{count}} файлов команд TOML:', + '... and {{count}} more': '... и ещё {{count}}', + 'The TOML format is deprecated. Would you like to migrate them to Markdown format?': + 'Формат TOML устарел. Хотите перенести их в формат Markdown?', + '(Backups will be created and original files will be preserved)': + '(Будут созданы резервные копии, исходные файлы будут сохранены)', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': + 'Ожидание подтверждения от пользователя...', + '(esc to cancel, {{time}})': '(esc для отмены, {{time}})', + + // ============================================================================ + + // ============================================================================ + // Loading Phrases + // ============================================================================ + WITTY_LOADING_PHRASES: [ + 'Мне повезёт!', + 'Доставляем крутизну... ', + 'Рисуем засечки на буквах...', + 'Пробираемся через слизевиков..', + 'Советуемся с цифровыми духами...', + 'Сглаживание сплайнов...', + 'Разогреваем ИИ-хомячков...', + 'Спрашиваем волшебную ракушку...', + 'Генерируем остроумный ответ...', + 'Полируем алгоритмы...', + 'Не торопите совершенство (или мой код)...', + 'Завариваем свежие байты...', + 'Пересчитываем электроны...', + 'Задействуем когнитивные процессоры...', + 'Ищем синтаксические ошибки во вселенной...', + 'Секундочку, оптимизируем юмор...', + 'Перетасовываем панчлайны...', + 'Распутаваем нейросети...', + 'Компилируем гениальность...', + 'Загружаем yumor.exe...', + 'Призываем облако мудрости...', + 'Готовим остроумный ответ...', + 'Секунду, идёт отладка реальности...', + 'Запутываем варианты...', + 'Настраиваем космические частоты...', + 'Создаем ответ, достойный вашего терпения...', + 'Компилируем единички и нолики...', + 'Разрешаем зависимости... и экзистенциальные кризисы...', + 'Дефрагментация памяти... и оперативной, и личной...', + 'Перезагрузка модуля юмора...', + 'Кэшируем самое важное (в основном мемы с котиками)...', + 'Оптимизация для безумной скорости', + 'Меняем биты... только байтам не говорите...', + 'Сборка мусора... скоро вернусь...', + 'Сборка интернетов...', + 'Превращаем кофе в код...', + 'Обновляем синтаксис реальности...', + 'Переподключаем синапсы...', + 'Ищем лишнюю точку с запятой...', + 'Смазываем шестерёнки машины...', + 'Разогреваем серверы...', + 'Калибруем потоковый накопитель...', + 'Включаем двигатель невероятности...', + 'Направляем Силу...', + 'Выравниваем звёзды для оптимального ответа...', + 'Так скажем мы все...', + 'Загрузка следующей великой идеи...', + 'Минутку, я в потоке...', + 'Готовлюсь ослепить вас гениальностью...', + 'Секунду, полирую остроумие...', + 'Держитесь, создаю шедевр...', + 'Мигом, отлаживаю вселенную...', + 'Момент, выравниваю пиксели...', + 'Секунду, оптимизирую юмор...', + 'Момент, настраиваю алгоритмы...', + 'Варп-прыжок активирован...', + 'Добываем кристаллы дилития...', + 'Без паники...', + 'Следуем за белым кроликом...', + 'Истина где-то здесь... внутри...', + 'Продуваем картридж...', + 'Загрузка... Сделай бочку!', + 'Ждем респауна...', + 'Делаем Дугу Кесселя менее чем за 12 парсеков...', + 'Тортик — не ложь, он просто ещё грузится...', + 'Возимся с экраном создания персонажа...', + 'Минутку, ищу подходящий мем...', + "Нажимаем 'A' для продолжения...", + 'Пасём цифровых котов...', + 'Полируем пиксели...', + 'Ищем подходящий каламбур для экрана загрузки...', + 'Отвлекаем вас этой остроумной фразой...', + 'Почти готово... вроде...', + 'Наши хомячки работают изо всех сил...', + 'Гладим Облачко по голове...', + 'Гладим кота...', + 'Рикроллим начальника...', + 'Never gonna give you up, never gonna let you down...', + 'Лабаем бас-гитару...', + 'Пробуем снузберри на вкус...', + 'Иду до конца, иду на скорость...', + 'Is this the real life? Is this just fantasy?...', + 'У меня хорошее предчувствие...', + 'Дразним медведя... (Не лезь...)', + 'Изучаем свежие мемы...', + 'Думаем, как сделать это остроумнее...', + 'Хмм... дайте подумать...', + 'Как называется бумеранг, который не возвращается? Палка...', + 'Почему компьютер простудился? Потому что оставил окна открытыми...', + 'Почему программисты не любят гулять на улице? Там среда не настроена...', + 'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...', + 'Почему разработчик разорился? Потому что потратил весь свой кэш...', + 'Что можно делать со сломанным карандашом? Ничего — он тупой...', + 'Провожу настройку методом тыка...', + 'Ищем, какой стороной вставлять флешку...', + 'Следим, чтобы волшебный дым не вышел из проводов...', + 'Пытаемся выйти из Vim...', + 'Раскручиваем колесо для хомяка...', + 'Это не баг, а фича...', + 'Поехали!', + 'Я вернусь... с ответом.', + 'Мой другой процесс — это ТАРДИС...', + 'Общаемся с духом машины...', + 'Даем мыслям замариноваться...', + 'Только что вспомнил, куда положил ключи...', + 'Размышляю над сферой...', + 'Я видел такое, что вам, людям, и не снилось... пользователя, читающего эти сообщения.', + 'Инициируем задумчивый взгляд...', + 'Что сервер заказывает в баре? Пинг-коладу.', + 'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...', + 'Заряжаем лазер... пиу-пиу!', + 'Делим на ноль... шучу!', + 'Ищу взрослых для присмот... в смысле, обрабатываю.', + 'Делаем бип-буп.', + 'Буферизация... даже ИИ нужно время подумать.', + 'Запутываем квантовые частицы для быстрого ответа...', + 'Полируем хром... на алгоритмах.', + 'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!', + 'Призываем гремлинов кода... для помощи, конечно же.', + 'Ждем, пока закончится звук dial-up модема...', + 'Перекалибровка юморометра.', + 'Мой другой экран загрузки ещё смешнее.', + 'Кажется, где-то по клавиатуре гуляет кот...', + 'Улучшаем... Ещё улучшаем... Всё ещё грузится.', + 'Это не баг, это фича... экрана загрузки.', + 'Пробовали выключить и включить снова? (Экран загрузки, не меня!)', + 'Нужно построить больше пилонов...', + ], + + // ============================================================================ + // Extension Settings Input + // ============================================================================ + 'Enter value...': 'Введите значение...', + 'Enter sensitive value...': 'Введите секретное значение...', + 'Press Enter to submit, Escape to cancel': + 'Нажмите Enter для отправки, Escape для отмены', + + // ============================================================================ + // Command Migration Tool + // ============================================================================ + 'Markdown file already exists: {{filename}}': + 'Markdown-файл уже существует: {{filename}}', + 'TOML Command Format Deprecation Notice': + 'Уведомление об устаревании формата TOML', + 'Found {{count}} command file(s) in TOML format:': + 'Найдено {{count}} файл(ов) команд в формате TOML:', + 'The TOML format for commands is being deprecated in favor of Markdown format.': + 'Формат TOML для команд устаревает в пользу формата Markdown.', + 'Markdown format is more readable and easier to edit.': + 'Формат Markdown более читаемый и простой для редактирования.', + 'You can migrate these files automatically using:': + 'Вы можете автоматически мигрировать эти файлы с помощью:', + 'Or manually convert each file:': 'Или вручную конвертировать каждый файл:', + 'TOML: prompt = "..." / description = "..."': + 'TOML: prompt = "..." / description = "..."', + 'Markdown: YAML frontmatter + content': + 'Markdown: YAML frontmatter + содержимое', + 'The migration tool will:': 'Инструмент миграции:', + 'Convert TOML files to Markdown': 'Конвертирует TOML-файлы в Markdown', + 'Create backups of original files': 'Создаёт резервные копии исходных файлов', + 'Preserve all command functionality': 'Сохраняет всю функциональность команд', + 'TOML format will continue to work for now, but migration is recommended.': + 'Формат TOML пока продолжит работать, но миграция рекомендуется.', + + // ============================================================================ + // Extensions - Explore Command + // ============================================================================ + 'Open extensions page in your browser': + 'Открыть страницу расширений в браузере', + 'Unknown extensions source: {{source}}.': + 'Неизвестный источник расширений: {{source}}.', + 'Would open extensions page in your browser: {{url}} (skipped in test environment)': + 'Страница расширений была бы открыта в браузере: {{url}} (пропущено в тестовой среде)', + 'View available extensions at {{url}}': + 'Посмотреть доступные расширения на {{url}}', + 'Opening extensions page in your browser: {{url}}': + 'Открываем страницу расширений в браузере: {{url}}', + 'Failed to open browser. Check out the extensions gallery at {{url}}': + 'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Используйте /compress, когда разговор становится длинным, чтобы подвести итог и освободить контекст.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'Начните новую идею с /clear или /new; предыдущая сессия останется в истории.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Используйте /bug, чтобы сообщить о проблемах разработчикам.', + 'Switch auth type quickly with /auth.': + 'Быстро переключите тип аутентификации с помощью /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Вы можете выполнять любые shell-команды в Qwen Code с помощью ! (например, !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'Введите /, чтобы открыть меню команд; Tab автодополняет слэш-команды и сохранённые промпты.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'Вы можете продолжить предыдущий разговор, запустив qwen --continue или qwen --resume.', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + 'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.', + 'You can switch permission mode quickly with Tab or /approval-mode.': + 'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Попробуйте /insight, чтобы получить персонализированные выводы из истории чатов.', + + // ============================================================================ + // Custom API Key Configuration + // ============================================================================ + 'You can configure your API key and models in settings.json': + 'Вы можете настроить API-ключ и модели в settings.json', + 'Refer to the documentation for setup instructions': + 'Инструкции по настройке см. в документации', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'API key cannot be empty.': 'API-ключ не может быть пустым.', + 'You can get your Coding Plan API key here': + 'Вы можете получить API-ключ Coding Plan здесь', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'Доступны новые конфигурации моделей для Alibaba Cloud Coding Plan. Обновить сейчас?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'API-ключ Coding Plan не найден. Пожалуйста, повторно авторизуйтесь с Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Не удалось обновить конфигурацию Coding Plan: {{message}}', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of ModelStudio Coding Plan and you're all set!": + 'Вставьте ваш API-ключ ModelStudio Coding Plan и всё готово!', + Custom: 'Пользовательский', + 'More instructions about configuring `modelProviders` manually.': + 'Дополнительные инструкции по ручной настройке `modelProviders`.', + 'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:', + '(Press Escape to go back)': '(Нажмите Escape для возврата)', + '(Press Enter to submit, Escape to cancel)': + '(Нажмите Enter для отправки, Escape для отмены)', + 'More instructions please check:': 'Дополнительные инструкции см.:', + 'Select Region for Coding Plan': 'Выберите регион Coding Plan', + 'Choose based on where your account is registered': + 'Выберите в зависимости от места регистрации вашего аккаунта', + 'Enter Coding Plan API Key': 'Введите API-ключ Coding Plan', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Использование контекста', + 'No API response yet. Send a message to see actual usage.': + 'Пока нет ответа от API. Отправьте сообщение, чтобы увидеть фактическое использование.', + 'Estimated pre-conversation overhead': + 'Оценочные накладные расходы перед беседой', + 'Context window': 'Контекстное окно', + tokens: 'токенов', + Used: 'Использовано', + Free: 'Свободно', + 'Autocompact buffer': 'Буфер автоупаковки', + 'Usage by category': 'Использование по категориям', + 'System prompt': 'Системная подсказка', + 'Built-in tools': 'Встроенные инструменты', + 'MCP tools': 'Инструменты MCP', + 'Memory files': 'Файлы памяти', + Skills: 'Навыки', + Messages: 'Сообщения', + 'Show context window usage breakdown.': + 'Показать разбивку использования контекстного окна.', + 'Run /context detail for per-item breakdown.': + 'Выполните /context detail для детализации по элементам.', + active: 'активно', + 'body loaded': 'содержимое загружено', + memory: 'память', + // MCP Management Dialog + // ============================================================================ + 'MCP Management': 'Управление MCP', + 'Server List': 'Список серверов', + 'Server Detail': 'Детали сервера', + 'Disable Server': 'Отключить сервер', + 'Tool List': 'Список инструментов', + 'Tool Detail': 'Детали инструмента', + 'Loading...': 'Загрузка...', + 'Unknown step': 'Неизвестный шаг', + 'Esc to back': 'Esc для возврата', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ навигация · Enter выбрать · Esc закрыть', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ навигация · Enter выбрать · Esc назад', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ навигация · Enter подтвердить · Esc назад', + 'User Settings (global)': 'Настройки пользователя (глобальные)', + 'Workspace Settings (project-specific)': + 'Настройки рабочего пространства (проектные)', + 'Disable server:': 'Отключить сервер:', + 'Select where to add the server to the exclude list:': + 'Выберите, где добавить сервер в список исключений:', + 'Press Enter to confirm, Esc to cancel': + 'Enter для подтверждения, Esc для отмены', + 'Status:': 'Статус:', + 'Command:': 'Команда:', + 'Working Directory:': 'Рабочий каталог:', + 'Capabilities:': 'Возможности:', + 'No server selected': 'Сервер не выбран', + + // MCP Server List + 'User MCPs': 'MCP пользователя', + 'Project MCPs': 'MCP проекта', + 'Extension MCPs': 'MCP расширений', + server: 'сервер', + servers: 'серверов', + 'Add MCP servers to your settings to get started.': + 'Добавьте серверы MCP в настройки, чтобы начать.', + 'Run qwen --debug to see error logs': + 'Запустите qwen --debug для просмотра журналов ошибок', + + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth-аутентификация', + 'Press Enter to start authentication, Esc to go back': + 'Нажмите Enter для начала аутентификации, Esc для возврата', + 'Authenticating... Please complete the login in your browser.': + 'Аутентификация... Пожалуйста, завершите вход в браузере.', + 'Press Enter or Esc to go back': 'Нажмите Enter или Esc для возврата', + + // MCP Tool List + 'No tools available for this server.': + 'Для этого сервера нет доступных инструментов.', + destructive: 'деструктивный', + 'read-only': 'только чтение', + 'open-world': 'открытый мир', + idempotent: 'идемпотентный', + 'Tools for {{name}}': 'Инструменты для {{name}}', + 'Tools for {{serverName}}': 'Инструменты для {{serverName}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'обязательный', + Type: 'Тип', + Enum: 'Перечисление', + Parameters: 'Параметры', + 'No tool selected': 'Инструмент не выбран', + Annotations: 'Аннотации', + Title: 'Заголовок', + 'Read Only': 'Только чтение', + Destructive: 'Деструктивный', + Idempotent: 'Идемпотентный', + 'Open World': 'Открытый мир', + Server: 'Сервер', + '{{region}} configuration updated successfully.': + 'Конфигурация {{region}} успешно обновлена.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Совет: Используйте /model для переключения между доступными моделями Coding Plan.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Пожалуйста, ответьте на следующий(ие) вопрос(ы):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Невозможно задавать вопросы пользователю в неинтерактивном режиме. Пожалуйста, запустите в интерактивном режиме для использования этого инструмента.', + 'User declined to answer the questions.': + 'Пользователь отказался отвечать на вопросы.', + 'User has provided the following answers:': + 'Пользователь предоставил следующие ответы:', + 'Failed to process user answers:': + 'Не удалось обработать ответы пользователя:', + 'Type something...': 'Введите что-то...', + Submit: 'Отправить', + 'Submit answers': 'Отправить ответы', + Cancel: 'Отмена', + 'Your answers:': 'Ваши ответы:', + '(not answered)': '(не отвечено)', + 'Ready to submit your answers?': 'Готовы отправить свои ответы?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Навигация | ←/→: Переключение вкладок | Enter: Выбор', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Навигация | ←/→: Переключение вкладок | Space/Enter: Переключить | Esc: Отмена', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Навигация | Enter: Выбор | Esc: Отмена', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Authenticate using Alibaba Cloud Coding Plan': + 'Аутентификация через Alibaba Cloud Coding Plan', + 'Region for Coding Plan (china/global)': + 'Регион для Coding Plan (china/global)', + 'API key for Coding Plan': 'API-ключ для Coding Plan', + 'Show current authentication status': + 'Показать текущий статус аутентификации', + 'Authentication completed successfully.': 'Аутентификация успешно завершена.', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Обработка аутентификации Alibaba Cloud Coding Plan...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Успешная аутентификация через Alibaba Cloud Coding Plan.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Ошибка аутентификации через Coding Plan: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Глобальный', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Выберите регион для Coding Plan:', + 'Enter your Coding Plan API key: ': 'Введите ваш API-ключ Coding Plan: ', + 'Select authentication method:': 'Выберите метод аутентификации:', + '\n=== Authentication Status ===\n': '\n=== Статус аутентификации ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ Метод аутентификации не настроен.\n', + 'Run one of the following commands to get started:\n': + 'Выполните одну из следующих команд для начала:\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Аутентификация через Alibaba Cloud Coding Plan\n', + 'Or simply run:': 'Или просто выполните:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Интерактивная настройка аутентификации\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Метод аутентификации: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Глобальный - Alibaba Cloud', + ' Region: {{region}}': ' Регион: {{region}}', + ' Current Model: {{model}}': ' Текущая модель: {{model}}', + ' Config Version: {{version}}': ' Версия конфигурации: {{version}}', + ' Status: API key configured\n': ' Статус: API-ключ настроен\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Метод аутентификации: Alibaba Cloud Coding Plan (Не завершён)', + ' Issue: API key not found in environment or settings\n': + ' Проблема: API-ключ не найден в окружении или настройках\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Выполните `qwen auth coding-plan` для повторной настройки.\n', + '✓ Authentication Method: {{type}}': '✓ Метод аутентификации: {{type}}', + ' Status: Configured\n': ' Статус: Настроено\n', + 'Failed to check authentication status: {{error}}': + 'Не удалось проверить статус аутентификации: {{error}}', + 'Select an option:': 'Выберите вариант:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n', + verbose: 'подробный', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Показывать полный вывод инструментов и процесс рассуждений в подробном режиме (переключить с помощью Ctrl+O).', + 'Press Ctrl+O to show full tool output': + 'Нажмите Ctrl+O для показа полного вывода инструментов', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', + + "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", +}; diff --git a/apps/airiscode-cli/src/i18n/locales/zh.js b/apps/airiscode-cli/src/i18n/locales/zh.js new file mode 100644 index 000000000..a73f686bd --- /dev/null +++ b/apps/airiscode-cli/src/i18n/locales/zh.js @@ -0,0 +1,1805 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Chinese translations for Qwen Code CLI + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ 管理附件', + '← → select, Delete to remove, ↓ to exit': '← → 选择,Delete 删除,↓ 退出', + 'Attachments: ': '附件:', + + 'Basics:': '基础功能:', + 'Add context': '添加上下文', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + '使用 {{symbol}} 指定文件作为上下文(例如,{{example}}),用于定位特定文件或文件夹', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Shell 模式', + 'YOLO mode': 'YOLO 模式', + 'plan mode': '规划模式', + 'auto-accept edits': '自动接受编辑', + 'Accepting edits': '接受编辑', + '(shift + tab to cycle)': '(shift + tab 切换)', + '(tab to cycle)': '(按 tab 切换)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + '通过 {{symbol}} 执行 shell 命令(例如,{{example1}})或使用自然语言(例如,{{example2}})', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': '命令:', + 'shell command': 'shell 命令', + 'Model Context Protocol command (from external servers)': + '模型上下文协议命令(来自外部服务器)', + 'Keyboard Shortcuts:': '键盘快捷键:', + 'Toggle this help display': '切换此帮助显示', + 'Toggle shell mode': '切换命令行模式', + 'Open command menu': '打开命令菜单', + 'Add file context': '添加文件上下文', + 'Accept suggestion / Autocomplete': '接受建议 / 自动补全', + 'Reverse search history': '反向搜索历史', + 'Press ? again to close': '再次按 ? 关闭', + // Keyboard shortcuts panel descriptions + 'for shell mode': '命令行模式', + 'for commands': '命令菜单', + 'for file paths': '文件路径', + 'to clear input': '清空输入', + 'to cycle approvals': '切换审批模式', + 'to quit': '退出', + 'for newline': '换行', + 'to clear screen': '清屏', + 'to search history': '搜索历史', + 'to paste images': '粘贴图片', + 'for external editor': '外部编辑器', + 'Jump through words in the input': '在输入中按单词跳转', + 'Close dialogs, cancel requests, or quit application': + '关闭对话框、取消请求或退出应用程序', + 'New line': '换行', + 'New line (Alt+Enter works for certain linux distros)': + '换行(某些 Linux 发行版支持 Alt+Enter)', + 'Clear the screen': '清屏', + 'Open input in external editor': '在外部编辑器中打开输入', + 'Send message': '发送消息', + 'Initializing...': '正在初始化...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + '正在连接到 MCP 服务器... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': '输入您的消息或 @ 文件路径', + '? for shortcuts': '按 ? 查看快捷键', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "按 'i' 进入插入模式,按 'Esc' 进入普通模式", + 'Cancel operation / Clear input (double press)': + '取消操作 / 清空输入(双击)', + 'Cycle approval modes': '循环切换审批模式', + 'Cycle through your prompt history': '循环浏览提示历史', + 'For a full list of shortcuts, see {{docPath}}': + '完整快捷键列表,请参阅 {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': '获取 Qwen Code 帮助', + 'show version info': '显示版本信息', + 'submit a bug report': '提交错误报告', + 'About Qwen Code': '关于 Qwen Code', + Status: '状态', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'Qwen Code': 'Qwen Code', + Runtime: '运行环境', + OS: '操作系统', + Auth: '认证', + 'CLI Version': 'CLI 版本', + 'Git Commit': 'Git 提交', + Model: '模型', + 'Fast Model': '快速模型', + Sandbox: '沙箱', + 'OS Platform': '操作系统平台', + 'OS Arch': '操作系统架构', + 'OS Release': '操作系统版本', + 'Node.js Version': 'Node.js 版本', + 'NPM Version': 'NPM 版本', + 'Session ID': '会话 ID', + 'Auth Method': '认证方式', + 'Base URL': '基础 URL', + Proxy: '代理', + 'Memory Usage': '内存使用', + 'IDE Client': 'IDE 客户端', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + '分析项目并创建定制的 QWEN.md 文件', + 'List available Qwen Code tools. Usage: /tools [desc]': + '列出可用的 Qwen Code 工具。用法:/tools [desc]', + 'List available skills.': '列出可用技能。', + 'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:', + 'No tools available': '没有可用工具', + 'View or change the approval mode for tool usage': + '查看或更改工具使用的审批模式', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + '无效的审批模式 "{{arg}}"。有效模式:{{modes}}', + 'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"', + 'View or change the language setting': '查看或更改语言设置', + 'change the theme': '更改主题', + 'Select Theme': '选择主题', + Preview: '预览', + '(Use Enter to select, Tab to configure scope)': + '(使用 Enter 选择,Tab 配置作用域)', + '(Use Enter to apply scope, Tab to go back)': + '(使用 Enter 应用作用域,Tab 返回)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + '由于 NO_COLOR 环境变量,主题配置不可用。', + 'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。', + 'Theme "{{themeName}}" not found in selected scope.': + '在所选作用域中未找到主题 "{{themeName}}"。', + 'Clear conversation history and free up context': '清除对话历史并释放上下文', + 'Compresses the context by replacing it with a summary.': + '通过摘要替换来压缩上下文', + 'open full Qwen Code documentation in your browser': + '在浏览器中打开完整的 Qwen Code 文档', + 'Configuration not available.': '配置不可用', + 'change the auth method': '更改认证方法', + 'Configure authentication information for login': '配置登录认证信息', + 'Copy the last result or code snippet to clipboard': + '将最后的结果或代码片段复制到剪贴板', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + '管理用于专门任务委派的子智能体', + 'Manage existing subagents (view, edit, delete).': + '管理现有子智能体(查看、编辑、删除)', + 'Create a new subagent with guided setup.': '通过引导式设置创建新的子智能体', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: '智能体', + 'Choose Action': '选择操作', + 'Edit {{name}}': '编辑 {{name}}', + 'Edit Tools: {{name}}': '编辑工具: {{name}}', + 'Edit Color: {{name}}': '编辑颜色: {{name}}', + 'Delete {{name}}': '删除 {{name}}', + 'Unknown Step': '未知步骤', + 'Esc to close': '按 Esc 关闭', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter 选择,↑↓ 导航,Esc 关闭', + 'Esc to go back': '按 Esc 返回', + 'Enter to confirm, Esc to cancel': 'Enter 确认,Esc 取消', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter 选择,↑↓ 导航,Esc 返回', + 'Enter to submit, Esc to go back': 'Enter 提交,Esc 返回', + 'Invalid step: {{step}}': '无效步骤: {{step}}', + 'No subagents found.': '未找到子智能体。', + "Use '/agents create' to create your first subagent.": + "使用 '/agents create' 创建您的第一个子智能体。", + '(built-in)': '(内置)', + '(overridden by project level agent)': '(已被项目级智能体覆盖)', + 'Project Level ({{path}})': '项目级 ({{path}})', + 'User Level ({{path}})': '用户级 ({{path}})', + 'Built-in Agents': '内置智能体', + 'Extension Agents': '扩展智能体', + 'Using: {{count}} agents': '使用中: {{count}} 个智能体', + 'View Agent': '查看智能体', + 'Edit Agent': '编辑智能体', + 'Delete Agent': '删除智能体', + Back: '返回', + 'No agent selected': '未选择智能体', + 'File Path: ': '文件路径: ', + 'Tools: ': '工具: ', + 'Color: ': '颜色: ', + 'Description:': '描述:', + 'System Prompt:': '系统提示:', + 'Open in editor': '在编辑器中打开', + 'Edit tools': '编辑工具', + 'Edit color': '编辑颜色', + '❌ Error:': '❌ 错误:', + 'Are you sure you want to delete agent "{{name}}"?': + '您确定要删除智能体 "{{name}}" 吗?', + // ============================================================================ + // Agents - Creation Wizard + // ============================================================================ + 'Project Level (.qwen/agents/)': '项目级 (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': '用户级 (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ 子智能体创建成功!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + '子智能体 "{{name}}" 已保存到 {{level}} 级别。', + 'Name: ': '名称: ', + 'Location: ': '位置: ', + '❌ Error saving subagent:': '❌ 保存子智能体时出错:', + 'Warnings:': '警告:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + '名称 "{{name}}" 在 {{level}} 级别已存在 - 将覆盖现有子智能体', + 'Name "{{name}}" exists at user level - project level will take precedence': + '名称 "{{name}}" 在用户级别存在 - 项目级别将优先', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + '名称 "{{name}}" 在项目级别存在 - 现有子智能体将优先', + 'Description is over {{length}} characters': '描述超过 {{length}} 个字符', + 'System prompt is over {{length}} characters': + '系统提示超过 {{length}} 个字符', + // Agents - Creation Wizard Steps + 'Step {{n}}: Choose Location': '步骤 {{n}}: 选择位置', + 'Step {{n}}: Choose Generation Method': '步骤 {{n}}: 选择生成方式', + 'Generate with Qwen Code (Recommended)': '使用 Qwen Code 生成(推荐)', + 'Manual Creation': '手动创建', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + '描述此子智能体应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + '例如:专业的代码审查员,根据最佳实践审查代码...', + 'Generating subagent configuration...': '正在生成子智能体配置...', + 'Failed to generate subagent: {{error}}': '生成子智能体失败: {{error}}', + 'Step {{n}}: Describe Your Subagent': '步骤 {{n}}: 描述您的子智能体', + 'Step {{n}}: Enter Subagent Name': '步骤 {{n}}: 输入子智能体名称', + 'Step {{n}}: Enter System Prompt': '步骤 {{n}}: 输入系统提示', + 'Step {{n}}: Enter Description': '步骤 {{n}}: 输入描述', + // Agents - Tool Selection + 'Step {{n}}: Select Tools': '步骤 {{n}}: 选择工具', + 'All Tools (Default)': '所有工具(默认)', + 'All Tools': '所有工具', + 'Read-only Tools': '只读工具', + 'Read & Edit Tools': '读取和编辑工具', + 'Read & Edit & Execution Tools': '读取、编辑和执行工具', + 'All tools selected, including MCP tools': '已选择所有工具,包括 MCP 工具', + 'Selected tools:': '已选择的工具:', + 'Read-only tools:': '只读工具:', + 'Edit tools:': '编辑工具:', + 'Execution tools:': '执行工具:', + 'Step {{n}}: Choose Background Color': '步骤 {{n}}: 选择背景颜色', + 'Step {{n}}: Confirm and Save': '步骤 {{n}}: 确认并保存', + // Agents - Navigation & Instructions + 'Esc to cancel': '按 Esc 取消', + 'Press Enter to save, e to save and edit, Esc to go back': + '按 Enter 保存,e 保存并编辑,Esc 返回', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + '按 Enter 继续,{{navigation}}Esc {{action}}', + cancel: '取消', + 'go back': '返回', + '↑↓ to navigate, ': '↑↓ 导航,', + 'Enter a clear, unique name for this subagent.': + '为此子智能体输入一个清晰、唯一的名称。', + 'e.g., Code Reviewer': '例如:代码审查员', + 'Name cannot be empty.': '名称不能为空。', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + '编写定义此子智能体行为的系统提示。为了获得最佳效果,请全面描述。', + 'e.g., You are an expert code reviewer...': + '例如:您是一位专业的代码审查员...', + 'System prompt cannot be empty.': '系统提示不能为空。', + 'Describe when and how this subagent should be used.': + '描述何时以及如何使用此子智能体。', + 'e.g., Reviews code for best practices and potential bugs.': + '例如:审查代码以查找最佳实践和潜在错误。', + 'Description cannot be empty.': '描述不能为空。', + 'Failed to launch editor: {{error}}': '启动编辑器失败: {{error}}', + 'Failed to save and edit subagent: {{error}}': + '保存并编辑子智能体失败: {{error}}', + + // ============================================================================ + // Extensions - Management Dialog + // ============================================================================ + 'Manage Extensions': '管理扩展', + 'Extension Details': '扩展详情', + 'View Extension': '查看扩展', + 'Update Extension': '更新扩展', + 'Disable Extension': '禁用扩展', + 'Enable Extension': '启用扩展', + 'Uninstall Extension': '卸载扩展', + 'Select Scope': '选择作用域', + 'User Scope': '用户作用域', + 'Workspace Scope': '工作区作用域', + 'No extensions found.': '未找到扩展。', + Active: '已启用', + Disabled: '已禁用', + 'Update available': '有可用更新', + 'Up to date': '已是最新', + 'Checking...': '检查中...', + 'Updating...': '更新中...', + Unknown: '未知', + Error: '错误', + 'Version:': '版本:', + 'Status:': '状态:', + 'Are you sure you want to uninstall extension "{{name}}"?': + '确定要卸载扩展 "{{name}}" 吗?', + 'This action cannot be undone.': '此操作无法撤销。', + 'Extension "{{name}}" disabled successfully.': '扩展 "{{name}}" 禁用成功。', + 'Extension "{{name}}" enabled successfully.': '扩展 "{{name}}" 启用成功。', + 'Extension "{{name}}" updated successfully.': '扩展 "{{name}}" 更新成功。', + 'Failed to update extension "{{name}}": {{error}}': + '更新扩展 "{{name}}" 失败:{{error}}', + 'Select the scope for this action:': '选择此操作的作用域:', + 'User - Applies to all projects': '用户 - 应用于所有项目', + 'Workspace - Applies to current project only': '工作区 - 仅应用于当前项目', + // Extension dialog - missing keys + 'Name:': '名称:', + 'MCP Servers:': 'MCP 服务器:', + 'Settings:': '设置:', + active: '已启用', + 'View Details': '查看详情', + 'Update failed:': '更新失败:', + 'Updating {{name}}...': '正在更新 {{name}}...', + 'Update complete!': '更新完成!', + 'User (global)': '用户(全局)', + 'Workspace (project-specific)': '工作区(项目特定)', + 'Disable "{{name}}" - Select Scope': '禁用 "{{name}}" - 选择作用域', + 'Enable "{{name}}" - Select Scope': '启用 "{{name}}" - 选择作用域', + 'No extension selected': '未选择扩展', + 'Press Y/Enter to confirm, N/Esc to cancel': '按 Y/Enter 确认,N/Esc 取消', + 'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter 确认,N/Esc 取消', + '{{count}} extensions installed': '已安装 {{count}} 个扩展', + "Use '/extensions install' to install your first extension.": + "使用 '/extensions install' 安装您的第一个扩展。", + // Update status values + 'up to date': '已是最新', + 'update available': '有可用更新', + 'checking...': '检查中...', + 'not updatable': '不可更新', + error: '错误', + + // ============================================================================ + // Commands - General (continued) + // ============================================================================ + 'View and edit Qwen Code settings': '查看和编辑 Qwen Code 设置', + Settings: '设置', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + '要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。', + 'The command "/{{command}}" is not supported in non-interactive mode.': + '不支持在非交互模式下使用命令 "/{{command}}"。', + // ============================================================================ + // Settings Labels + // ============================================================================ + 'Vim Mode': 'Vim 模式', + 'Disable Auto Update': '禁用自动更新', + 'Attribution: commit': '署名:提交', + 'Terminal Bell Notification': '终端响铃通知', + 'Enable Usage Statistics': '启用使用统计', + Theme: '主题', + 'Preferred Editor': '首选编辑器', + 'Auto-connect to IDE': '自动连接到 IDE', + 'Enable Prompt Completion': '启用提示补全', + 'Debug Keystroke Logging': '调试按键记录', + 'Language: UI': '语言:界面', + 'Language: Model': '语言:模型', + 'Output Format': '输出格式', + 'Hide Window Title': '隐藏窗口标题', + 'Show Status in Title': '在标题中显示状态', + 'Hide Tips': '隐藏提示', + 'Show Line Numbers in Code': '在代码中显示行号', + 'Show Citations': '显示引用', + 'Custom Witty Phrases': '自定义诙谐短语', + 'Show Welcome Back Dialog': '显示欢迎回来对话框', + 'Enable User Feedback': '启用用户反馈', + 'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)', + Bad: '不满意', + Fine: '还行', + Good: '满意', + Dismiss: '忽略', + 'Not Sure Yet': '暂不评价', + 'Any other key': '任意其他键', + 'Disable Loading Phrases': '禁用加载短语', + 'Screen Reader Mode': '屏幕阅读器模式', + 'IDE Mode': 'IDE 模式', + 'Max Session Turns': '最大会话轮次', + 'Skip Next Speaker Check': '跳过下一个说话者检查', + 'Skip Loop Detection': '跳过循环检测', + 'Skip Startup Context': '跳过启动上下文', + 'Enable OpenAI Logging': '启用 OpenAI 日志', + 'OpenAI Logging Directory': 'OpenAI 日志目录', + Timeout: '超时', + 'Max Retries': '最大重试次数', + 'Disable Cache Control': '禁用缓存控制', + 'Memory Discovery Max Dirs': '内存发现最大目录数', + 'Load Memory From Include Directories': '从包含目录加载内存', + 'Respect .gitignore': '遵守 .gitignore', + 'Respect .qwenignore': '遵守 .qwenignore', + 'Enable Recursive File Search': '启用递归文件搜索', + 'Disable Fuzzy Search': '禁用模糊搜索', + 'Interactive Shell (PTY)': '交互式 Shell (PTY)', + 'Show Color': '显示颜色', + 'Auto Accept': '自动接受', + 'Use Ripgrep': '使用 Ripgrep', + 'Use Builtin Ripgrep': '使用内置 Ripgrep', + 'Enable Tool Output Truncation': '启用工具输出截断', + 'Tool Output Truncation Threshold': '工具输出截断阈值', + 'Tool Output Truncation Lines': '工具输出截断行数', + 'Folder Trust': '文件夹信任', + 'Vision Model Preview': '视觉模型预览', + 'Tool Schema Compliance': '工具 Schema 兼容性', + // Settings enum options + 'Auto (detect from system)': '自动(从系统检测)', + Text: '文本', + JSON: 'JSON', + Plan: '规划', + Default: '默认', + 'Auto Edit': '自动编辑', + YOLO: 'YOLO', + 'toggle vim mode on/off': '切换 vim 模式开关', + 'check session stats. Usage: /stats [model|tools]': + '检查会话统计信息。用法:/stats [model|tools]', + 'Show model-specific usage statistics.': '显示模型相关的使用统计信息', + 'Show tool-specific usage statistics.': '显示工具相关的使用统计信息', + 'exit the cli': '退出命令行界面', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + '打开 MCP 管理对话框,或在支持 OAuth 的服务器上进行身份验证', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + '列出已配置的 MCP 服务器和工具,或使用支持 OAuth 的服务器进行身份验证', + 'Manage workspace directories': '管理工作区目录', + 'Add directories to the workspace. Use comma to separate multiple paths': + '将目录添加到工作区。使用逗号分隔多个路径', + 'Show all directories in the workspace': '显示工作区中的所有目录', + 'set external editor preference': '设置外部编辑器首选项', + 'Select Editor': '选择编辑器', + 'Editor Preference': '编辑器首选项', + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.': + '当前支持以下编辑器。请注意,某些编辑器无法在沙箱模式下使用。', + 'Your preferred editor is:': '您的首选编辑器是:', + 'Manage extensions': '管理扩展', + 'Manage installed extensions': '管理已安装的扩展', + 'List active extensions': '列出活动扩展', + 'Update extensions. Usage: update |--all': + '更新扩展。用法:update |--all', + 'Disable an extension': '禁用扩展', + 'Enable an extension': '启用扩展', + 'Install an extension from a git repo or local path': + '从 Git 仓库或本地路径安装扩展', + 'Uninstall an extension': '卸载扩展', + 'No extensions installed.': '未安装扩展。', + 'Usage: /extensions update |--all': + '用法:/extensions update <扩展名>|--all', + 'Extension "{{name}}" not found.': '未找到扩展 "{{name}}"。', + 'No extensions to update.': '没有可更新的扩展。', + 'Usage: /extensions install ': '用法:/extensions install <来源>', + 'Installing extension from "{{source}}"...': + '正在从 "{{source}}" 安装扩展...', + 'Extension "{{name}}" installed successfully.': '扩展 "{{name}}" 安装成功。', + 'Failed to install extension from "{{source}}": {{error}}': + '从 "{{source}}" 安装扩展失败:{{error}}', + 'Usage: /extensions uninstall ': + '用法:/extensions uninstall <扩展名>', + 'Uninstalling extension "{{name}}"...': '正在卸载扩展 "{{name}}"...', + 'Extension "{{name}}" uninstalled successfully.': + '扩展 "{{name}}" 卸载成功。', + 'Failed to uninstall extension "{{name}}": {{error}}': + '卸载扩展 "{{name}}" 失败:{{error}}', + 'Usage: /extensions {{command}} [--scope=]': + '用法:/extensions {{command}} <扩展> [--scope=]', + 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"': + '不支持的作用域 "{{scope}}",应为 "user" 或 "workspace"', + 'Extension "{{name}}" disabled for scope "{{scope}}"': + '扩展 "{{name}}" 已在作用域 "{{scope}}" 中禁用', + 'Extension "{{name}}" enabled for scope "{{scope}}"': + '扩展 "{{name}}" 已在作用域 "{{scope}}" 中启用', + 'Do you want to continue? [Y/n]: ': '是否继续?[Y/n]:', + 'Do you want to continue?': '是否继续?', + 'Installing extension "{{name}}".': '正在安装扩展 "{{name}}"。', + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**': + '**扩展可能会引入意外行为。请确保您已调查过扩展源并信任作者。**', + 'This extension will run the following MCP servers:': + '此扩展将运行以下 MCP 服务器:', + local: '本地', + remote: '远程', + 'This extension will add the following commands: {{commands}}.': + '此扩展将添加以下命令:{{commands}}。', + 'This extension will append info to your QWEN.md context using {{fileName}}': + '此扩展将使用 {{fileName}} 向您的 QWEN.md 上下文追加信息', + 'This extension will exclude the following core tools: {{tools}}': + '此扩展将排除以下核心工具:{{tools}}', + 'This extension will install the following skills:': '此扩展将安装以下技能:', + 'This extension will install the following subagents:': + '此扩展将安装以下子智能体:', + 'Installation cancelled for "{{name}}".': '已取消安装 "{{name}}"。', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + '您正在安装来自 {{originSource}} 的扩展。某些功能可能无法完美兼容 Qwen Code。', + '--ref and --auto-update are not applicable for marketplace extensions.': + '--ref 和 --auto-update 不适用于市场扩展。', + 'Extension "{{name}}" installed successfully and enabled.': + '扩展 "{{name}}" 安装成功并已启用。', + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).': + '从 Git 仓库 URL、本地路径或 Claude 市场(marketplace-url:plugin-name)安装扩展。', + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': + '要安装的扩展的 GitHub URL、本地路径或市场源(marketplace-url:plugin-name)。', + 'The git ref to install from.': '要安装的 Git 引用。', + 'Enable auto-update for this extension.': '为此扩展启用自动更新。', + 'Enable pre-release versions for this extension.': '为此扩展启用预发布版本。', + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': + '确认安装扩展的安全风险并跳过确认提示。', + 'The source argument must be provided.': '必须提供来源参数。', + 'Extension "{{name}}" successfully uninstalled.': + '扩展 "{{name}}" 卸载成功。', + 'Uninstalls an extension.': '卸载扩展。', + 'The name or source path of the extension to uninstall.': + '要卸载的扩展的名称或源路径。', + 'Please include the name of the extension to uninstall as a positional argument.': + '请将要卸载的扩展名称作为位置参数。', + 'Enables an extension.': '启用扩展。', + 'The name of the extension to enable.': '要启用的扩展名称。', + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.': + '启用扩展的作用域。如果未设置,将在所有作用域中启用。', + 'Extension "{{name}}" successfully enabled for scope "{{scope}}".': + '扩展 "{{name}}" 已在作用域 "{{scope}}" 中启用。', + 'Extension "{{name}}" successfully enabled in all scopes.': + '扩展 "{{name}}" 已在所有作用域中启用。', + 'Invalid scope: {{scope}}. Please use one of {{scopes}}.': + '无效的作用域:{{scope}}。请使用 {{scopes}} 之一。', + 'Disables an extension.': '禁用扩展。', + 'The name of the extension to disable.': '要禁用的扩展名称。', + 'The scope to disable the extenison in.': '禁用扩展的作用域。', + 'Extension "{{name}}" successfully disabled for scope "{{scope}}".': + '扩展 "{{name}}" 已在作用域 "{{scope}}" 中禁用。', + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.': + '扩展 "{{name}}" 更新成功:{{oldVersion}} → {{newVersion}}。', + 'Unable to install extension "{{name}}" due to missing install metadata': + '由于缺少安装元数据,无法安装扩展 "{{name}}"', + 'Extension "{{name}}" is already up to date.': + '扩展 "{{name}}" 已是最新版本。', + 'Updates all extensions or a named extension to the latest version.': + '将所有扩展或指定扩展更新到最新版本。', + 'The name of the extension to update.': '要更新的扩展名称。', + 'Update all extensions.': '更新所有扩展。', + 'Either an extension name or --all must be provided': + '必须提供扩展名称或 --all', + 'Lists installed extensions.': '列出已安装的扩展。', + 'Path:': '路径:', + 'Source:': '来源:', + 'Type:': '类型:', + 'Ref:': '引用:', + 'Release tag:': '发布标签:', + 'Enabled (User):': '已启用(用户):', + 'Enabled (Workspace):': '已启用(工作区):', + 'Context files:': '上下文文件:', + 'Skills:': '技能:', + 'Agents:': '智能体:', + 'MCP servers:': 'MCP 服务器:', + 'Link extension failed to install.': '链接扩展安装失败。', + 'Extension "{{name}}" linked successfully and enabled.': + '扩展 "{{name}}" 链接成功并已启用。', + 'Links an extension from a local path. Updates made to the local path will always be reflected.': + '从本地路径链接扩展。对本地路径的更新将始终反映。', + 'The name of the extension to link.': '要链接的扩展名称。', + 'Set a specific setting for an extension.': '为扩展设置特定配置。', + 'Name of the extension to configure.': '要配置的扩展名称。', + 'The setting to configure (name or env var).': + '要配置的设置(名称或环境变量)。', + 'The scope to set the setting in.': '设置配置的作用域。', + 'List all settings for an extension.': '列出扩展的所有设置。', + 'Name of the extension.': '扩展名称。', + 'Extension "{{name}}" has no settings to configure.': + '扩展 "{{name}}" 没有可配置的设置。', + 'Settings for "{{name}}":': '"{{name}}" 的设置:', + '(workspace)': '(工作区)', + '(user)': '(用户)', + '[not set]': '[未设置]', + '[value stored in keychain]': '[值存储在钥匙串中]', + 'Manage extension settings.': '管理扩展设置。', + 'You need to specify a command (set or list).': + '您需要指定命令(set 或 list)。', + // ============================================================================ + // Plugin Choice / Marketplace + // ============================================================================ + 'No plugins available in this marketplace.': '此市场中没有可用的插件。', + 'Select a plugin to install from marketplace "{{name}}":': + '从市场 "{{name}}" 中选择要安装的插件:', + 'Plugin selection cancelled.': '插件选择已取消。', + 'Select a plugin from "{{name}}"': '从 "{{name}}" 中选择插件', + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': + '使用 ↑↓ 或 j/k 导航,回车选择,Esc 取消', + '{{count}} more above': '上方还有 {{count}} 项', + '{{count}} more below': '下方还有 {{count}} 项', + 'manage IDE integration': '管理 IDE 集成', + 'check status of IDE integration': '检查 IDE 集成状态', + 'install required IDE companion for {{ideName}}': + '安装 {{ideName}} 所需的 IDE 配套工具', + 'enable IDE integration': '启用 IDE 集成', + 'disable IDE integration': '禁用 IDE 集成', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + '您当前环境不支持 IDE 集成。要使用此功能,请在以下支持的 IDE 之一中运行 Qwen Code:VS Code 或 VS Code 分支版本。', + 'Set up GitHub Actions': '设置 GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + '配置终端按键绑定以支持多行输入(VS Code、Cursor、Windsurf、Trae)', + 'Please restart your terminal for the changes to take effect.': + '请重启终端以使更改生效。', + 'Failed to configure terminal: {{error}}': '配置终端失败:{{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + '无法确定 {{terminalName}} 在 Windows 上的配置路径:未设置 APPDATA 环境变量。', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json 存在但不是有效的 JSON 数组。请手动修复文件或删除它以允许自动配置。', + 'File: {{file}}': '文件:{{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + '解析 {{terminalName}} keybindings.json 失败。文件包含无效的 JSON。请手动修复文件或删除它以允许自动配置。', + 'Error: {{error}}': '错误:{{error}}', + 'Shift+Enter binding already exists': 'Shift+Enter 绑定已存在', + 'Ctrl+Enter binding already exists': 'Ctrl+Enter 绑定已存在', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + '检测到现有按键绑定。为避免冲突,不会修改。', + 'Please check and modify manually if needed: {{file}}': + '如有需要,请手动检查并修改:{{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + '已为 {{terminalName}} 添加 Shift+Enter 和 Ctrl+Enter 按键绑定。', + 'Modified: {{file}}': '已修改:{{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}} 按键绑定已配置。', + 'Failed to configure {{terminalName}}.': '配置 {{terminalName}} 失败。', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + '您的终端已配置为支持多行输入(Shift+Enter 和 Ctrl+Enter)的最佳体验。', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': '管理 Qwen Code Hook', + 'List all configured hooks': '列出所有已配置的 Hook', + 'Enable a disabled hook': '启用已禁用的 Hook', + 'Disable an active hook': '禁用已启用的 Hook', + // Hooks - Dialog + Hooks: 'Hook', + 'Loading hooks...': '正在加载 Hook...', + 'Error loading hooks:': '加载 Hook 出错:', + 'Press Escape to close': '按 Escape 关闭', + 'Press Escape, Ctrl+C, or Ctrl+D to cancel': + '按 Escape、Ctrl+C 或 Ctrl+D 取消', + 'Press Space, Enter, or Escape to dismiss': '按空格、回车或 Escape 关闭', + 'No hook selected': '未选择 Hook', + // Hooks - List Step + 'No hook events found.': '未找到 Hook 事件。', + '{{count}} hook configured': '{{count}} 个 Hook 已配置', + '{{count}} hooks configured': '{{count}} 个 Hook 已配置', + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.': + '此菜单为只读。要添加或修改 Hook,请直接编辑 settings.json 或询问 Qwen Code。', + 'Enter to select · Esc to cancel': 'Enter 选择 · Esc 取消', + // Hooks - Detail Step + 'Exit codes:': '退出码:', + 'Configured hooks:': '已配置的 Hook:', + 'No hooks configured for this event.': '此事件未配置 Hook。', + 'To add hooks, edit settings.json directly or ask Qwen.': + '要添加 Hook,请直接编辑 settings.json 或询问 Qwen。', + 'Enter to select · Esc to go back': 'Enter 选择 · Esc 返回', + // Hooks - Config Detail Step + 'Hook details': 'Hook 详情', + 'Event:': '事件:', + 'Extension:': '扩展:', + 'Desc:': '描述:', + 'No hook config selected': '未选择 Hook 配置', + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.': + '要修改或删除此 Hook,请直接编辑 settings.json 或询问 Qwen。', + // Hooks - Disabled Step + 'Hook Configuration - Disabled': 'Hook 配置 - 已禁用', + 'All hooks are currently disabled. You have {{count}} that are not running.': + '所有 Hook 当前已禁用。您有 {{count}} 未运行。', + '{{count}} configured hook': '{{count}} 个已配置的 Hook', + '{{count}} configured hooks': '{{count}} 个已配置的 Hook', + 'When hooks are disabled:': '当 Hook 被禁用时:', + 'No hook commands will execute': '不会执行任何 Hook 命令', + 'StatusLine will not be displayed': '不会显示状态栏', + 'Tool operations will proceed without hook validation': + '工具操作将在没有 Hook 验证的情况下继续', + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.': + '要重新启用 Hook,请从 settings.json 中删除 "disableAllHooks" 或询问 Qwen Code。', + // Hooks - Source + Project: '项目', + User: '用户', + System: '系统', + Extension: '扩展', + 'Local Settings': '本地设置', + 'User Settings': '用户设置', + 'System Settings': '系统设置', + Extensions: '扩展', + // Hooks - Status + '✓ Enabled': '✓ 已启用', + '✗ Disabled': '✗ 已禁用', + // Hooks - Event Descriptions (short) + 'Before tool execution': '工具执行前', + 'After tool execution': '工具执行后', + 'After tool execution fails': '工具执行失败后', + 'When notifications are sent': '发送通知时', + 'When the user submits a prompt': '用户提交提示时', + 'When a new session is started': '新会话开始时', + 'Right before Qwen Code concludes its response': 'Qwen Code 结束响应之前', + 'When a subagent (Agent tool call) is started': + '子智能体(Agent 工具调用)启动时', + 'Right before a subagent concludes its response': '子智能体结束响应之前', + 'Before conversation compaction': '对话压缩前', + 'When a session is ending': '会话结束时', + 'When a permission dialog is displayed': '显示权限对话框时', + // Hooks - Event Descriptions (detailed) + 'Input to command is JSON of tool call arguments.': + '命令输入为工具调用参数的 JSON。', + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).': + '命令输入为包含 "inputs"(工具调用参数)和 "response"(工具调用响应)字段的 JSON。', + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.': + '命令输入为包含 tool_name、tool_input、tool_use_id、error、error_type、is_interrupt 和 is_timeout 的 JSON。', + 'Input to command is JSON with notification message and type.': + '命令输入为包含通知消息和类型的 JSON。', + 'Input to command is JSON with original user prompt text.': + '命令输入为包含原始用户提示文本的 JSON。', + 'Input to command is JSON with session start source.': + '命令输入为包含会话启动来源的 JSON。', + 'Input to command is JSON with session end reason.': + '命令输入为包含会话结束原因的 JSON。', + 'Input to command is JSON with agent_id and agent_type.': + '命令输入为包含 agent_id 和 agent_type 的 JSON。', + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.': + '命令输入为包含 agent_id、agent_type 和 agent_transcript_path 的 JSON。', + 'Input to command is JSON with compaction details.': + '命令输入为包含压缩详情的 JSON。', + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.': + '命令输入为包含 tool_name、tool_input 和 tool_use_id 的 JSON。输出包含 hookSpecificOutput 的 JSON,其中包含允许或拒绝的决定。', + // Hooks - Exit Code Descriptions + 'stdout/stderr not shown': 'stdout/stderr 不显示', + 'show stderr to model and continue conversation': + '向模型显示 stderr 并继续对话', + 'show stderr to user only': '仅向用户显示 stderr', + 'stdout shown in transcript mode (ctrl+o)': 'stdout 以转录模式显示 (ctrl+o)', + 'show stderr to model immediately': '立即向模型显示 stderr', + 'show stderr to user only but continue with tool call': + '仅向用户显示 stderr 但继续工具调用', + 'block processing, erase original prompt, and show stderr to user only': + '阻止处理,擦除原始提示,仅向用户显示 stderr', + 'stdout shown to Qwen': '向 Qwen 显示 stdout', + 'show stderr to user only (blocking errors ignored)': + '仅向用户显示 stderr(忽略阻塞错误)', + 'command completes successfully': '命令成功完成', + 'stdout shown to subagent': '向子智能体显示 stdout', + 'show stderr to subagent and continue having it run': + '向子智能体显示 stderr 并继续运行', + 'stdout appended as custom compact instructions': + 'stdout 作为自定义压缩指令追加', + 'block compaction': '阻止压缩', + 'show stderr to user only but continue with compaction': + '仅向用户显示 stderr 但继续压缩', + 'use hook decision if provided': '如果提供则使用 Hook 决定', + // Hooks - Messages + 'Config not loaded.': '配置未加载。', + 'Hooks are not enabled. Enable hooks in settings to use this feature.': + 'Hook 未启用。请在设置中启用 Hook 以使用此功能。', + 'No hooks configured. Add hooks in your settings.json file.': + '未配置 Hook。请在 settings.json 文件中添加 Hook。', + 'Configured Hooks ({{count}} total)': '已配置的 Hook(共 {{count}} 个)', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + '将当前会话的消息记录导出到文件', + 'Export session to HTML format': '将会话导出为 HTML 文件', + 'Export session to JSON format': '将会话导出为 JSON 文件', + 'Export session to JSONL format (one message per line)': + '将会话导出为 JSONL 文件(每行一条消息)', + 'Export session to markdown format': '将会话导出为 Markdown 文件', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + '根据你的聊天记录生成个性化编程洞察', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': '恢复先前会话', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + '恢复某次工具调用。这将把对话与文件历史重置到提出该工具调用建议时的状态', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + '无法检测终端类型。支持的终端:VS Code、Cursor、Windsurf 和 Trae。', + 'Terminal "{{terminal}}" is not supported yet.': + '终端 "{{terminal}}" 尚未支持。', + + // ============================================================================ + // Commands - Language + // ============================================================================ + 'Invalid language. Available: {{options}}': + '无效的语言。可用选项:{{options}}', + 'Language subcommands do not accept additional arguments.': + '语言子命令不接受额外参数', + 'Current UI language: {{lang}}': '当前 UI 语言:{{lang}}', + 'Current LLM output language: {{lang}}': '当前 LLM 输出语言:{{lang}}', + 'LLM output language not set': '未设置 LLM 输出语言', + 'Set UI language': '设置 UI 语言', + 'Set LLM output language': '设置 LLM 输出语言', + 'Usage: /language ui [{{options}}]': '用法:/language ui [{{options}}]', + 'Usage: /language output ': '用法:/language output <语言>', + 'Example: /language output 中文': '示例:/language output 中文', + 'Example: /language output English': '示例:/language output English', + 'Example: /language output 日本語': '示例:/language output 日本語', + 'Example: /language output Português': '示例:/language output Português', + 'UI language changed to {{lang}}': 'UI 语言已更改为 {{lang}}', + 'LLM output language set to {{lang}}': 'LLM 输出语言已设置为 {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'LLM 输出语言规则文件已生成于 {{path}}', + 'Please restart the application for the changes to take effect.': + '请重启应用程序以使更改生效。', + 'Failed to generate LLM output language rule file: {{error}}': + '生成 LLM 输出语言规则文件失败:{{error}}', + 'Invalid command. Available subcommands:': '无效的命令。可用的子命令:', + 'Available subcommands:': '可用的子命令:', + 'To request additional UI language packs, please open an issue on GitHub.': + '如需请求其他 UI 语言包,请在 GitHub 上提交 issue', + 'Available options:': '可用选项:', + 'Set UI language to {{name}}': '将 UI 语言设置为 {{name}}', + + // ============================================================================ + // Commands - Approval Mode + // ============================================================================ + 'Tool Approval Mode': '工具审批模式', + 'Current approval mode: {{mode}}': '当前审批模式:{{mode}}', + 'Available approval modes:': '可用的审批模式:', + 'Approval mode changed to: {{mode}}': '审批模式已更改为:{{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + '审批模式已更改为:{{mode}}(已保存到{{scope}}设置{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + '用法:/approval-mode [--session|--user|--project]', + + 'Scope subcommands do not accept additional arguments.': + '作用域子命令不接受额外参数', + 'Plan mode - Analyze only, do not modify files or execute commands': + '规划模式 - 仅分析,不修改文件或执行命令', + 'Default mode - Require approval for file edits or shell commands': + '默认模式 - 需要批准文件编辑或 shell 命令', + 'Auto-edit mode - Automatically approve file edits': + '自动编辑模式 - 自动批准文件编辑', + 'YOLO mode - Automatically approve all tools': 'YOLO 模式 - 自动批准所有工具', + '{{mode}} mode': '{{mode}} 模式', + 'Settings service is not available; unable to persist the approval mode.': + '设置服务不可用;无法持久化审批模式。', + 'Failed to save approval mode: {{error}}': '保存审批模式失败:{{error}}', + 'Failed to change approval mode: {{error}}': '更改审批模式失败:{{error}}', + 'Apply to current session only (temporary)': '仅应用于当前会话(临时)', + 'Persist for this project/workspace': '持久化到此项目/工作区', + 'Persist for this user on this machine': '持久化到此机器上的此用户', + 'Analyze only, do not modify files or execute commands': + '仅分析,不修改文件或执行命令', + 'Require approval for file edits or shell commands': + '需要批准文件编辑或 shell 命令', + 'Automatically approve file edits': '自动批准文件编辑', + 'Automatically approve all tools': '自动批准所有工具', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + '工作区审批模式已存在并具有优先级。用户级别的更改将无效。', + 'Apply To': '应用于', + 'Workspace Settings': '工作区设置', + + // ============================================================================ + // Commands - Memory + // ============================================================================ + 'Commands for interacting with memory.': '用于与记忆交互的命令', + 'Show the current memory contents.': '显示当前记忆内容', + 'Show project-level memory contents.': '显示项目级记忆内容', + 'Show global memory contents.': '显示全局记忆内容', + 'Add content to project-level memory.': '添加内容到项目级记忆', + 'Add content to global memory.': '添加内容到全局记忆', + 'Refresh the memory from the source.': '从源刷新记忆', + 'Usage: /memory add --project ': + '用法:/memory add --project <要记住的文本>', + 'Usage: /memory add --global ': + '用法:/memory add --global <要记住的文本>', + 'Attempting to save to project memory: "{{text}}"': + '正在尝试保存到项目记忆:"{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + '正在尝试保存到全局记忆:"{{text}}"', + 'Current memory content from {{count}} file(s):': + '来自 {{count}} 个文件的当前记忆内容:', + 'Memory is currently empty.': '记忆当前为空', + 'Project memory file not found or is currently empty.': + '项目记忆文件未找到或当前为空', + 'Global memory file not found or is currently empty.': + '全局记忆文件未找到或当前为空', + 'Global memory is currently empty.': '全局记忆当前为空', + 'Global memory content:\n\n---\n{{content}}\n---': + '全局记忆内容:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + '项目记忆内容来自 {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': '项目记忆当前为空', + 'Refreshing memory from source files...': '正在从源文件刷新记忆...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + '添加内容到记忆。使用 --global 表示全局记忆,使用 --project 表示项目记忆', + 'Usage: /memory add [--global|--project] ': + '用法:/memory add [--global|--project] <要记住的文本>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + '正在尝试保存到记忆 {{scope}}:"{{fact}}"', + + // ============================================================================ + // Commands - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + '使用支持 OAuth 的 MCP 服务器进行认证', + 'List configured MCP servers and tools': '列出已配置的 MCP 服务器和工具', + 'Restarts MCP servers.': '重启 MCP 服务器', + 'Open MCP management dialog': '打开 MCP 管理对话框', + 'Could not retrieve tool registry.': '无法检索工具注册表', + 'No MCP servers configured with OAuth authentication.': + '未配置支持 OAuth 认证的 MCP 服务器', + 'MCP servers with OAuth authentication:': '支持 OAuth 认证的 MCP 服务器:', + 'Use /mcp auth to authenticate.': + '使用 /mcp auth 进行认证', + "MCP server '{{name}}' not found.": "未找到 MCP 服务器 '{{name}}'", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "成功认证并刷新了 '{{name}}' 的工具", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "认证 MCP 服务器 '{{name}}' 失败:{{error}}", + "Re-discovering tools from '{{name}}'...": + "正在重新发现 '{{name}}' 的工具...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "从 '{{name}}' 发现了 {{count}} 个工具。", + 'Authentication complete. Returning to server details...': + '认证完成,正在返回服务器详情...', + 'Authentication successful.': '认证成功。', + 'If the browser does not open, copy and paste this URL into your browser:': + '如果浏览器未自动打开,请复制以下 URL 并粘贴到浏览器中:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ 请确保复制完整的 URL —— 它可能跨越多行。', + + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'Manage MCP servers': '管理 MCP 服务器', + 'Server Detail': '服务器详情', + 'Disable Server': '禁用服务器', + Tools: '工具', + 'Tool Detail': '工具详情', + 'MCP Management': 'MCP 管理', + 'Loading...': '加载中...', + 'Unknown step': '未知步骤', + 'Esc to back': 'Esc 返回', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ 导航 · Enter 选择 · Esc 关闭', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ 导航 · Enter 选择 · Esc 返回', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ 导航 · Enter 确认 · Esc 返回', + 'User Settings (global)': '用户设置(全局)', + 'Workspace Settings (project-specific)': '工作区设置(项目级)', + 'Disable server:': '禁用服务器:', + 'Select where to add the server to the exclude list:': + '选择将服务器添加到排除列表的位置:', + 'Press Enter to confirm, Esc to cancel': '按 Enter 确认,Esc 取消', + 'View tools': '查看工具', + Reconnect: '重新连接', + Enable: '启用', + Disable: '禁用', + Authenticate: '认证', + 'Re-authenticate': '重新认证', + 'Clear Authentication': '清空认证', + disabled: '已禁用', + 'Server:': '服务器:', + '(disabled)': '(已禁用)', + 'Error:': '错误:', + tool: '工具', + tools: '个工具', + connected: '已连接', + connecting: '连接中', + disconnected: '已断开', + + // MCP Server List + 'User MCPs': '用户 MCP', + 'Project MCPs': '项目 MCP', + 'Extension MCPs': '扩展 MCP', + server: '个服务器', + servers: '个服务器', + 'Add MCP servers to your settings to get started.': + '请在设置中添加 MCP 服务器以开始使用。', + 'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志', + + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth 认证', + 'Press Enter to start authentication, Esc to go back': + '按 Enter 开始认证,Esc 返回', + 'Authenticating... Please complete the login in your browser.': + '认证中... 请在浏览器中完成登录。', + 'Press Enter or Esc to go back': '按 Enter 或 Esc 返回', + + // MCP Server Detail + 'Command:': '命令:', + 'Working Directory:': '工作目录:', + 'Capabilities:': '功能:', + + // MCP Tool List + 'No tools available for this server.': '此服务器没有可用工具。', + destructive: '破坏性', + 'read-only': '只读', + 'open-world': '开放世界', + idempotent: '幂等', + 'Tools for {{name}}': '{{name}} 的工具', + 'Tools for {{serverName}}': '{{serverName}} 的工具', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + Type: '类型', + Parameters: '参数', + 'No tool selected': '未选择工具', + Annotations: '注解', + Title: '标题', + 'Read Only': '只读', + Destructive: '破坏性', + Idempotent: '幂等', + 'Open World': '开放世界', + Server: '服务器', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 个无效工具', + invalid: '无效', + 'invalid: {{reason}}': '无效:{{reason}}', + 'missing name': '缺少名称', + 'missing description': '缺少描述', + '(unnamed)': '(未命名)', + 'Warning: This tool cannot be called by the LLM': + '警告:此工具无法被 LLM 调用', + Reason: '原因', + 'Tools must have both name and description to be used by the LLM.': + '工具必须同时具有名称和描述才能被 LLM 使用。', + + // ============================================================================ + // Commands - Chat + // ============================================================================ + 'Manage conversation history.': '管理对话历史', + 'List saved conversation checkpoints': '列出已保存的对话检查点', + 'No saved conversation checkpoints found.': '未找到已保存的对话检查点', + 'List of saved conversations:': '已保存的对话列表:', + 'Note: Newest last, oldest first': '注意:最新的在最后,最旧的在最前', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + '将当前对话保存为检查点。用法:/chat save ', + 'Missing tag. Usage: /chat save ': '缺少标签。用法:/chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + '删除对话检查点。用法:/chat delete ', + 'Missing tag. Usage: /chat delete ': + '缺少标签。用法:/chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "对话检查点 '{{tag}}' 已删除", + "Error: No checkpoint found with tag '{{tag}}'.": + "错误:未找到标签为 '{{tag}}' 的检查点", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + '从检查点恢复对话。用法:/chat resume ', + 'Missing tag. Usage: /chat resume ': + '缺少标签。用法:/chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + '未找到标签为 {{tag}} 的已保存检查点', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + '标签为 {{tag}} 的检查点已存在。您要覆盖它吗?', + 'No chat client available to save conversation.': + '没有可用的聊天客户端来保存对话', + 'Conversation checkpoint saved with tag: {{tag}}.': + '对话检查点已保存,标签:{{tag}}', + 'No conversation found to save.': '未找到要保存的对话', + 'No chat client available to share conversation.': + '没有可用的聊天客户端来分享对话', + 'Invalid file format. Only .md and .json are supported.': + '无效的文件格式。仅支持 .md 和 .json 文件', + 'Error sharing conversation: {{error}}': '分享对话时出错:{{error}}', + 'Conversation shared to {{filePath}}': '对话已分享到 {{filePath}}', + 'No conversation found to share.': '未找到要分享的对话', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + '将当前对话分享到 markdown 或 json 文件。用法:/chat share ', + + // ============================================================================ + // Commands - Summary + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + '生成项目摘要并保存到 .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + '没有可用的聊天客户端来生成摘要', + 'Already generating summary, wait for previous request to complete': + '正在生成摘要,请等待上一个请求完成', + 'No conversation found to summarize.': '未找到要总结的对话', + 'Failed to generate project context summary: {{error}}': + '生成项目上下文摘要失败:{{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + '项目摘要已保存到 {{filePathForDisplay}}', + 'Saving project summary...': '正在保存项目摘要...', + 'Generating project summary...': '正在生成项目摘要...', + 'Failed to generate summary - no text content received from LLM response': + '生成摘要失败 - 未从 LLM 响应中接收到文本内容', + + // ============================================================================ + // Commands - Model + // ============================================================================ + 'Switch the model for this session': '切换此会话的模型', + 'Set fast model for background tasks': '设置后台任务的快速模型', + 'Content generator configuration not available.': '内容生成器配置不可用', + 'Authentication type not available.': '认证类型不可用', + 'No models available for the current authentication type ({{authType}}).': + '当前认证类型 ({{authType}}) 没有可用的模型', + + // ============================================================================ + // Commands - Clear + // ============================================================================ + 'Starting a new session, resetting chat, and clearing terminal.': + '正在开始新会话,重置聊天并清屏。', + 'Starting a new session and clearing.': '正在开始新会话并清屏。', + + // ============================================================================ + // Commands - Compress + // ============================================================================ + 'Already compressing, wait for previous request to complete': + '正在压缩中,请等待上一个请求完成', + 'Failed to compress chat history.': '压缩聊天历史失败', + 'Failed to compress chat history: {{error}}': '压缩聊天历史失败:{{error}}', + 'Compressing chat history': '正在压缩聊天历史', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + '聊天历史已从 {{originalTokens}} 个 token 压缩到 {{newTokens}} 个 token。', + 'Compression was not beneficial for this history size.': + '对于此历史记录大小,压缩没有益处。', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + '聊天历史压缩未能减小大小。这可能表明压缩提示存在问题。', + 'Could not compress chat history due to a token counting error.': + '由于 token 计数错误,无法压缩聊天历史。', + 'Chat history is already compressed.': '聊天历史已经压缩。', + + // ============================================================================ + // Commands - Directory + // ============================================================================ + 'Configuration is not available.': '配置不可用。', + 'Please provide at least one path to add.': '请提供至少一个要添加的路径。', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + '/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。', + "Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + '如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}', + 'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}', + 'Successfully added directories:\n- {{directories}}': + '成功添加目录:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + '当前工作区目录:\n{{directories}}', + + // ============================================================================ + // Commands - Docs + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + '请在浏览器中打开以下 URL 以查看文档:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + '正在浏览器中打开文档:{{url}}', + + // ============================================================================ + // Dialogs - Tool Confirmation + // ============================================================================ + 'Do you want to proceed?': '是否继续?', + 'Yes, allow once': '是,允许一次', + 'Allow always': '总是允许', + Yes: '是', + No: '否', + 'No (esc)': '否 (esc)', + 'Yes, allow always for this session': '是,本次会话总是允许', + 'Modify in progress:': '正在修改:', + 'Save and close external editor to continue': '保存并关闭外部编辑器以继续', + 'Apply this change?': '是否应用此更改?', + 'Yes, allow always': '是,总是允许', + 'Modify with external editor': '使用外部编辑器修改', + 'No, suggest changes (esc)': '否,建议更改 (esc)', + "Allow execution of: '{{command}}'?": "允许执行:'{{command}}'?", + 'Yes, allow always ...': '是,总是允许 ...', + 'Always allow in this project': '在本项目中总是允许', + 'Always allow {{action}} in this project': '在本项目中总是允许{{action}}', + 'Always allow for this user': '对该用户总是允许', + 'Always allow {{action}} for this user': '对该用户总是允许{{action}}', + 'Yes, restore previous mode ({{mode}})': '是,恢复之前的模式 ({{mode}})', + 'Yes, and auto-accept edits': '是,并自动接受编辑', + 'Yes, and manually approve edits': '是,并手动批准编辑', + 'No, keep planning (esc)': '否,继续规划 (esc)', + 'URLs to fetch:': '要获取的 URL:', + 'MCP Server: {{server}}': 'MCP 服务器:{{server}}', + 'Tool: {{tool}}': '工具:{{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + '允许执行来自服务器 "{{server}}" 的 MCP 工具 "{{tool}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + '是,总是允许来自服务器 "{{server}}" 的工具 "{{tool}}"', + 'Yes, always allow all tools from server "{{server}}"': + '是,总是允许来自服务器 "{{server}}" 的所有工具', + + // ============================================================================ + // Dialogs - Shell Confirmation + // ============================================================================ + 'Shell Command Execution': 'Shell 命令执行', + 'A custom command wants to run the following shell commands:': + '自定义命令想要运行以下 shell 命令:', + + // ============================================================================ + // Dialogs - Pro Quota + // ============================================================================ + 'Pro quota limit reached for {{model}}.': '{{model}} 的 Pro 配额已达到上限', + 'Change auth (executes the /auth command)': '更改认证(执行 /auth 命令)', + 'Continue with {{model}}': '使用 {{model}} 继续', + + // ============================================================================ + // Dialogs - Welcome Back + // ============================================================================ + 'Current Plan:': '当前计划:', + 'Progress: {{done}}/{{total}} tasks completed': + '进度:已完成 {{done}}/{{total}} 个任务', + ', {{inProgress}} in progress': ',{{inProgress}} 个进行中', + 'Pending Tasks:': '待处理任务:', + 'What would you like to do?': '您想要做什么?', + 'Choose how to proceed with your session:': '选择如何继续您的会话:', + 'Start new chat session': '开始新的聊天会话', + 'Continue previous conversation': '继续之前的对话', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 欢迎回来!(最后更新:{{timeAgo}})', + '🎯 Overall Goal:': '🎯 总体目标:', + + // ============================================================================ + // Dialogs - Auth + // ============================================================================ + 'Get started': '开始使用', + 'Select Authentication Method': '选择认证方式', + 'OpenAI API key is required to use OpenAI authentication.': + '使用 OpenAI 认证需要 OpenAI API 密钥', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + '您必须选择认证方法才能继续。再次按 Ctrl+C 退出', + 'Terms of Services and Privacy Notice': '服务条款和隐私声明', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + '付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型', + 'Alibaba Cloud Coding Plan': '阿里云百炼 Coding Plan', + 'Bring your own API key': '使用自己的 API 密钥', + 'Use coding plan credentials or your own api-keys/providers.': + '使用 Coding Plan 凭证或您自己的 API 密钥/提供商。', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': '登录失败。消息:{{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + '认证方式被强制设置为 {{enforcedType}},但您当前使用的是 {{currentType}}', + 'Please visit this URL to authorize:': '请访问此 URL 进行授权:', + 'Or scan the QR code below:': '或扫描下方的二维码:', + 'Waiting for authorization': '等待授权中', + 'Time remaining:': '剩余时间:', + '(Press ESC or CTRL+C to cancel)': '(按 ESC 或 CTRL+C 取消)', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuth 令牌已过期(超过 {{seconds}} 秒)。请重新选择认证方法', + 'Press any key to return to authentication type selection.': + '按任意键返回认证类型选择', + 'Authentication timed out. Please try again.': '认证超时。请重试。', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + '正在等待认证...(按 ESC 或 CTRL+C 取消)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + '缺少 OpenAI 兼容认证的 API 密钥。请设置 settings.security.auth.apiKey 或设置 {{envKeyHint}} 环境变量。', + '{{envKeyHint}} environment variable not found.': + '未找到 {{envKeyHint}} 环境变量。', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + '未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + '未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey)。请在 .env 文件或系统环境变量中进行设置。', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + '缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic 提供商缺少必需的 baseUrl,请在 modelProviders[].baseUrl 中配置。', + 'ANTHROPIC_BASE_URL environment variable not found.': + '未找到 ANTHROPIC_BASE_URL 环境变量。', + 'Invalid auth method selected.': '选择了无效的认证方式。', + 'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}', + 'Authenticated successfully with {{authType}} credentials.': + '使用 {{authType}} 凭据成功认证。', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + '无效的 QWEN_DEFAULT_AUTH_TYPE 值:"{{value}}"。有效值为:{{validValues}}', + 'OpenAI Configuration Required': '需要配置 OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + '请输入您的 OpenAI 配置。您可以从以下地址获取 API 密钥:', + 'API Key:': 'API 密钥:', + 'Invalid credentials: {{errorMessage}}': '凭据无效:{{errorMessage}}', + 'Failed to validate credentials': '验证凭据失败', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + '按 Enter 继续,Tab/↑↓ 导航,Esc 取消', + + // ============================================================================ + // Dialogs - Model + // ============================================================================ + 'Select Model': '选择模型', + '(Press Esc to close)': '(按 Esc 关闭)', + 'Current (effective) configuration': '当前(实际生效)配置', + AuthType: '认证方式', + 'API Key': 'API 密钥', + unset: '未设置', + '(default)': '(默认)', + '(set)': '(已设置)', + '(not set)': '(未设置)', + Modality: '模态', + 'Context Window': '上下文窗口', + text: '文本', + 'text-only': '纯文本', + image: '图像', + pdf: 'PDF', + audio: '音频', + video: '视频', + 'not set': '未设置', + none: '无', + unknown: '未知', + "Failed to switch model to '{{modelId}}'.\n\n{{error}}": + "无法切换到模型 '{{modelId}}'.\n\n{{error}}", + 'Qwen 3.6 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.6 Plus — 高效混合架构,编程性能业界领先', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + '来自阿里云 ModelStudio 的最新 Qwen Vision 模型(版本:qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Dialogs - Permissions + // ============================================================================ + 'Manage folder trust settings': '管理文件夹信任设置', + 'Manage permission rules': '管理权限规则', + Allow: '允许', + Ask: '询问', + Deny: '拒绝', + Workspace: '工作区', + "Qwen Code won't ask before using allowed tools.": + 'Qwen Code 使用已允许的工具前不会询问。', + 'Qwen Code will ask before using these tools.': + 'Qwen Code 使用这些工具前会先询问。', + 'Qwen Code is not allowed to use denied tools.': + 'Qwen Code 不允许使用被拒绝的工具。', + 'Manage trusted directories for this workspace.': + '管理此工作区的受信任目录。', + 'Any use of the {{tool}} tool': '{{tool}} 工具的任何使用', + "{{tool}} commands matching '{{pattern}}'": + "匹配 '{{pattern}}' 的 {{tool}} 命令", + 'From user settings': '来自用户设置', + 'From project settings': '来自项目设置', + 'From session': '来自会话', + 'Project settings (local)': '项目设置(本地)', + 'Saved in .qwen/settings.local.json': '保存在 .qwen/settings.local.json', + 'Project settings': '项目设置', + 'Checked in at .qwen/settings.json': '保存在 .qwen/settings.json', + 'User settings': '用户设置', + 'Saved in at ~/.qwen/settings.json': '保存在 ~/.qwen/settings.json', + 'Add a new rule…': '添加新规则…', + 'Add {{type}} permission rule': '添加{{type}}权限规则', + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.': + '权限规则是一个工具名称,可选地后跟括号中的限定符。', + 'e.g.,': '例如', + or: '或', + 'Enter permission rule…': '输入权限规则…', + 'Enter to submit · Esc to cancel': '回车提交 · Esc 取消', + 'Where should this rule be saved?': '此规则应保存在哪里?', + 'Enter to confirm · Esc to cancel': '回车确认 · Esc 取消', + 'Delete {{type}} rule?': '删除{{type}}规则?', + 'Are you sure you want to delete this permission rule?': + '确定要删除此权限规则吗?', + 'Permissions:': '权限:', + '(←/→ or tab to cycle)': '(←/→ 或 tab 切换)', + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel': + '按 ↑↓ 导航 · 回车选择 · 输入搜索 · Esc 取消', + 'Search…': '搜索…', + 'Use /trust to manage folder trust settings for this workspace.': + '使用 /trust 管理此工作区的文件夹信任设置。', + // Workspace directory management + 'Add directory…': '添加目录…', + 'Add directory to workspace': '添加工作区目录', + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.': + 'Qwen Code 可以读取工作区中的文件,并在自动接受编辑模式开启时进行编辑。', + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.': + 'Qwen Code 将能够读取此目录中的文件,并在自动接受编辑模式开启时进行编辑。', + 'Enter the path to the directory:': '输入目录路径:', + 'Enter directory path…': '输入目录路径…', + 'Tab to complete · Enter to add · Esc to cancel': + 'Tab 补全 · 回车添加 · Esc 取消', + 'Remove directory?': '删除目录?', + 'Are you sure you want to remove this directory from the workspace?': + '确定要将此目录从工作区中移除吗?', + ' (Original working directory)': ' (原始工作目录)', + ' (from settings)': ' (来自设置)', + 'Directory does not exist.': '目录不存在。', + 'Path is not a directory.': '路径不是目录。', + 'This directory is already in the workspace.': '此目录已在工作区中。', + 'Already covered by existing directory: {{dir}}': '已被现有目录覆盖:{{dir}}', + + // ============================================================================ + // Status Bar + // ============================================================================ + 'Using:': '已加载: ', + '{{count}} open file': '{{count}} 个打开的文件', + '{{count}} open files': '{{count}} 个打开的文件', + '(ctrl+g to view)': '(按 ctrl+g 查看)', + '{{count}} {{name}} file': '{{count}} 个 {{name}} 文件', + '{{count}} {{name}} files': '{{count}} 个 {{name}} 文件', + '{{count}} MCP server': '{{count}} 个 MCP 服务器', + '{{count}} MCP servers': '{{count}} 个 MCP 服务器', + '{{count}} Blocked': '{{count}} 个已阻止', + '(ctrl+t to view)': '(按 ctrl+t 查看)', + '(ctrl+t to toggle)': '(按 ctrl+t 切换)', + 'Press Ctrl+C again to exit.': '再次按 Ctrl+C 退出', + 'Press Ctrl+D again to exit.': '再次按 Ctrl+D 退出', + 'Press Esc again to clear.': '再次按 Esc 清除', + + // ============================================================================ + // MCP Status + // ============================================================================ + 'No MCP servers configured.': '未配置 MCP 服务器', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP 服务器正在启动({{count}} 个正在初始化)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + '注意:首次启动可能需要更长时间。工具可用性将自动更新', + 'Configured MCP servers:': '已配置的 MCP 服务器:', + Ready: '就绪', + 'Starting... (first startup may take longer)': + '正在启动...(首次启动可能需要更长时间)', + Disconnected: '已断开连接', + '{{count}} tool': '{{count}} 个工具', + '{{count}} tools': '{{count}} 个工具', + '{{count}} prompt': '{{count}} 个提示', + '{{count}} prompts': '{{count}} 个提示', + '(from {{extensionName}})': '(来自 {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth 已过期', + 'OAuth not authenticated': 'OAuth 未认证', + 'tools and prompts will appear when ready': '工具和提示将在就绪时显示', + '{{count}} tools cached': '{{count}} 个工具已缓存', + 'Tools:': '工具:', + 'Parameters:': '参数:', + 'Prompts:': '提示:', + Blocked: '已阻止', + '💡 Tips:': '💡 提示:', + Use: '使用', + 'to show server and tool descriptions': '显示服务器和工具描述', + 'to show tool parameter schemas': '显示工具参数架构', + 'to hide descriptions': '隐藏描述', + 'to authenticate with OAuth-enabled servers': + '使用支持 OAuth 的服务器进行认证', + Press: '按', + 'to toggle tool descriptions on/off': '切换工具描述开关', + "Starting OAuth authentication for MCP server '{{name}}'...": + "正在为 MCP 服务器 '{{name}}' 启动 OAuth 认证...", + 'Restarting MCP servers...': '正在重启 MCP 服务器...', + + // ============================================================================ + // Startup Tips + // ============================================================================ + 'Tips:': '提示:', + 'Use /compress when the conversation gets long to summarize history and free up context.': + '对话变长时用 /compress,总结历史并释放上下文。', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + '用 /clear 或 /new 开启新思路;之前的会话会保留在历史记录中。', + 'Use /bug to submit issues to the maintainers when something goes off.': + '遇到问题时,用 /bug 将问题提交给维护者。', + 'Switch auth type quickly with /auth.': '用 /auth 快速切换认证方式。', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + '在 Qwen Code 中使用 ! 可运行任意 shell 命令(例如 !ls)。', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + '输入 / 打开命令弹窗;按 Tab 自动补全斜杠命令和保存的提示词。', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + '运行 qwen --continue 或 qwen --resume 可继续之前的会话。', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + '按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。', + 'You can switch permission mode quickly with Tab or /approval-mode.': + '按 Tab 或输入 /approval-mode 可快速切换权限模式。', + 'Try /insight to generate personalized insights from your chat history.': + '试试 /insight,从聊天记录中生成个性化洞察。', + + // ============================================================================ + // Exit Screen / Stats + // ============================================================================ + 'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!', + 'To continue this session, run': '要继续此会话,请运行', + 'Interaction Summary': '交互摘要', + 'Session ID:': '会话 ID:', + 'Tool Calls:': '工具调用:', + 'Success Rate:': '成功率:', + 'User Agreement:': '用户同意率:', + reviewed: '已审核', + 'Code Changes:': '代码变更:', + Performance: '性能', + 'Wall Time:': '总耗时:', + 'Agent Active:': '智能体活跃时间:', + 'API Time:': 'API 时间:', + 'Tool Time:': '工具时间:', + 'Session Stats': '会话统计', + 'Model Usage': '模型使用情况', + Reqs: '请求数', + 'Input Tokens': '输入 token 数', + 'Output Tokens': '输出 token 数', + 'Savings Highlight:': '节省亮点:', + 'of input tokens were served from the cache, reducing costs.': + '从缓存载入 token ,降低了成本', + 'Tip: For a full token breakdown, run `/stats model`.': + '提示:要查看完整的令牌明细,请运行 `/stats model`', + 'Model Stats For Nerds': '模型统计(技术细节)', + 'Tool Stats For Nerds': '工具统计(技术细节)', + Metric: '指标', + API: 'API', + Requests: '请求数', + Errors: '错误数', + 'Avg Latency': '平均延迟', + Tokens: '令牌', + Total: '总计', + Prompt: '提示', + Cached: '缓存', + Thoughts: '思考', + Tool: '工具', + Output: '输出', + 'No API calls have been made in this session.': + '本次会话中未进行任何 API 调用', + 'Tool Name': '工具名称', + Calls: '调用次数', + 'Success Rate': '成功率', + 'Avg Duration': '平均耗时', + 'User Decision Summary': '用户决策摘要', + 'Total Reviewed Suggestions:': '已审核建议总数:', + ' » Accepted:': ' » 已接受:', + ' » Rejected:': ' » 已拒绝:', + ' » Modified:': ' » 已修改:', + ' Overall Agreement Rate:': ' 总体同意率:', + 'No tool calls have been made in this session.': + '本次会话中未进行任何工具调用', + 'Session start time is unavailable, cannot calculate stats.': + '会话开始时间不可用,无法计算统计信息', + + // ============================================================================ + // Command Format Migration + // ============================================================================ + 'Command Format Migration': '命令格式迁移', + 'Found {{count}} TOML command file:': '发现 {{count}} 个 TOML 命令文件:', + 'Found {{count}} TOML command files:': '发现 {{count}} 个 TOML 命令文件:', + '... and {{count}} more': '... 以及其他 {{count}} 个', + 'The TOML format is deprecated. Would you like to migrate them to Markdown format?': + 'TOML 格式已弃用。是否将它们迁移到 Markdown 格式?', + '(Backups will be created and original files will be preserved)': + '(将创建备份,原始文件将保留)', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': '等待用户确认...', + '(esc to cancel, {{time}})': '(按 esc 取消,{{time}})', + WITTY_LOADING_PHRASES: [ + // --- 职场搬砖系列 --- + '正在努力搬砖,请稍候...', + '老板在身后,快加载啊!', + '头发掉光前,一定能加载完...', + '服务器正在深呼吸,准备放大招...', + '正在向服务器投喂咖啡...', + + // --- 大厂黑话系列 --- + '正在赋能全链路,寻找关键抓手...', + '正在降本增效,优化加载路径...', + '正在打破部门壁垒,沉淀方法论...', + '正在拥抱变化,迭代核心价值...', + '正在对齐颗粒度,打磨底层逻辑...', + '大力出奇迹,正在强行加载...', + + // --- 程序员自嘲系列 --- + '只要我不写代码,代码就没有 Bug...', + '正在把 Bug 转化为 Feature...', + '只要我不尴尬,Bug 就追不上我...', + '正在试图理解去年的自己写了什么...', + '正在猿力觉醒中,请耐心等待...', + + // --- 合作愉快系列 --- + '正在询问产品经理:这需求是真的吗?', + '正在给产品经理画饼,请稍等...', + + // --- 温暖治愈系列 --- + '每一行代码,都在努力让世界变得更好一点点...', + '每一个伟大的想法,都值得这份耐心的等待...', + '别急,美好的事物总是需要一点时间去酝酿...', + '愿你的代码永无 Bug,愿你的梦想终将成真...', + '哪怕只有 0.1% 的进度,也是在向目标靠近...', + '加载的是字节,承载的是对技术的热爱...', + ], + + // ============================================================================ + // Extension Settings Input + // ============================================================================ + 'Enter value...': '请输入值...', + 'Enter sensitive value...': '请输入敏感值...', + 'Press Enter to submit, Escape to cancel': '按 Enter 提交,Escape 取消', + + // ============================================================================ + // Command Migration Tool + // ============================================================================ + 'Markdown file already exists: {{filename}}': + 'Markdown 文件已存在:{{filename}}', + 'TOML Command Format Deprecation Notice': 'TOML 命令格式弃用通知', + 'Found {{count}} command file(s) in TOML format:': + '发现 {{count}} 个 TOML 格式的命令文件:', + 'The TOML format for commands is being deprecated in favor of Markdown format.': + '命令的 TOML 格式正在被弃用,推荐使用 Markdown 格式。', + 'Markdown format is more readable and easier to edit.': + 'Markdown 格式更易读、更易编辑。', + 'You can migrate these files automatically using:': + '您可以使用以下命令自动迁移这些文件:', + 'Or manually convert each file:': '或手动转换每个文件:', + 'TOML: prompt = "..." / description = "..."': + 'TOML:prompt = "..." / description = "..."', + 'Markdown: YAML frontmatter + content': 'Markdown:YAML frontmatter + 内容', + 'The migration tool will:': '迁移工具将:', + 'Convert TOML files to Markdown': '将 TOML 文件转换为 Markdown', + 'Create backups of original files': '创建原始文件的备份', + 'Preserve all command functionality': '保留所有命令功能', + 'TOML format will continue to work for now, but migration is recommended.': + 'TOML 格式目前仍可使用,但建议迁移。', + + // ============================================================================ + // Extensions - Explore Command + // ============================================================================ + 'Open extensions page in your browser': '在浏览器中打开扩展市场页面', + 'Unknown extensions source: {{source}}.': '未知的扩展来源:{{source}}。', + 'Would open extensions page in your browser: {{url}} (skipped in test environment)': + '将在浏览器中打开扩展页面:{{url}}(测试环境中已跳过)', + 'View available extensions at {{url}}': '在 {{url}} 查看可用扩展', + 'Opening extensions page in your browser: {{url}}': + '正在浏览器中打开扩展页面:{{url}}', + 'Failed to open browser. Check out the extensions gallery at {{url}}': + '打开浏览器失败。请访问扩展市场:{{url}}', + + // ============================================================================ + // Retry / Rate Limit + // ============================================================================ + 'Rate limit error: {{reason}}': '触发限流:{{reason}}', + 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})': + '将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)', + 'Press Ctrl+Y to retry': '按 Ctrl+Y 重试。', + 'No failed request to retry.': '没有可重试的失败请求。', + 'to retry last request': '重试上一次请求', + + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'API key cannot be empty.': 'API Key 不能为空。', + 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.': + '无效的 API Key,Coding Plan API Key 均以 "sk-sp-" 开头,请检查', + 'You can get your Coding Plan API key here': + '您可以在这里获取 Coding Plan API Key', + 'API key is stored in settings.env. You can migrate it to a .env file for better security.': + 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + '阿里云百炼 Coding Plan 有新模型配置可用。是否立即更新?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan 配置更新成功。新模型现已可用。', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + '未找到 Coding Plan API Key。请重新通过 Coding Plan 认证。', + 'Failed to update Coding Plan configuration: {{message}}': + '更新 Coding Plan 配置失败:{{message}}', + + // ============================================================================ + // Custom API Key Configuration + // ============================================================================ + 'You can configure your API key and models in settings.json': + '您可以在 settings.json 中配置 API Key 和模型', + 'Refer to the documentation for setup instructions': '请参考文档了解配置说明', + + // ============================================================================ + // Auth Dialog - View Titles and Labels + // ============================================================================ + 'API-KEY': 'API-KEY', + 'Coding Plan': 'Coding Plan', + "Paste your api key of ModelStudio Coding Plan and you're all set!": + '粘贴您的百炼 Coding Plan API Key,即可完成设置!', + Custom: '自定义', + 'More instructions about configuring `modelProviders` manually.': + '关于手动配置 `modelProviders` 的更多说明。', + 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', + '(Press Escape to go back)': '(按 Escape 键返回)', + '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', + 'Select Region for Coding Plan': '选择 Coding Plan 区域', + 'Choose based on where your account is registered': + '请根据您的账号注册地区选择', + 'Enter Coding Plan API Key': '输入 Coding Plan API Key', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} 有新的模型配置可用。是否立即更新?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。', + + // ============================================================================ + // Context Usage + // ============================================================================ + 'Context Usage': '上下文使用情况', + 'Context window': '上下文窗口', + Used: '已用', + Free: '空闲', + 'Autocompact buffer': '自动压缩缓冲区', + 'Usage by category': '分类用量', + 'System prompt': '系统提示', + 'Built-in tools': '内置工具', + 'MCP tools': 'MCP 工具', + 'Memory files': '记忆文件', + Skills: '技能', + Messages: '消息', + tokens: 'tokens', + 'Estimated pre-conversation overhead': '预估对话前开销', + 'No API response yet. Send a message to see actual usage.': + '暂无 API 响应。发送消息以查看实际使用情况。', + 'Show context window usage breakdown.': '显示上下文窗口使用情况分解。', + 'Run /context detail for per-item breakdown.': + '运行 /context detail 查看详细分解。', + 'body loaded': '内容已加载', + memory: '记忆', + '{{region}} configuration updated successfully.': '{{region}} 配置更新成功。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。', + 'Tip: Use /model to switch between available Coding Plan models.': + '提示:使用 /model 切换可用的 Coding Plan 模型。', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': '请回答以下问题:', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + '无法在非交互模式下询问用户问题。请在交互模式下运行以使用此工具。', + 'User declined to answer the questions.': '用户拒绝回答问题。', + 'User has provided the following answers:': '用户提供了以下答案:', + 'Failed to process user answers:': '处理用户答案失败:', + 'Type something...': '输入内容...', + Submit: '提交', + 'Submit answers': '提交答案', + Cancel: '取消', + 'Your answers:': '您的答案:', + '(not answered)': '(未回答)', + 'Ready to submit your answers?': '准备好提交您的答案了吗?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: 导航 | ←/→: 切换标签页 | Enter: 选择', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 导航 | ←/→: 切换标签页 | Space/Enter: 切换 | Esc: 取消', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: 导航 | Enter: 选择 | Esc: 取消', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Authenticate using Alibaba Cloud Coding Plan': + '使用阿里云百炼 Coding Plan 进行认证', + 'Region for Coding Plan (china/global)': 'Coding Plan 区域 (china/global)', + 'API key for Coding Plan': 'Coding Plan 的 API 密钥', + 'Show current authentication status': '显示当前认证状态', + 'Authentication completed successfully.': '认证完成。', + 'Processing Alibaba Cloud Coding Plan authentication...': + '正在处理阿里云百炼 Coding Plan 认证...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + '已成功通过阿里云百炼 Coding Plan 认证。', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Coding Plan 认证失败:{{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: '全球', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': '选择 Coding Plan 区域:', + 'Enter your Coding Plan API key: ': '请输入您的 Coding Plan API 密钥:', + 'Select authentication method:': '选择认证方式:', + '\n=== Authentication Status ===\n': '\n=== 认证状态 ===\n', + '⚠️ No authentication method configured.\n': '⚠️ 未配置认证方式。\n', + 'Run one of the following commands to get started:\n': + '运行以下命令之一开始配置:\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - 使用阿里云百炼 Coding Plan 认证\n', + 'Or simply run:': '或者直接运行:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - 交互式认证配置\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ 认证方式:阿里云百炼 Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': '全球 - Alibaba Cloud', + ' Region: {{region}}': ' 区域:{{region}}', + ' Current Model: {{model}}': ' 当前模型:{{model}}', + ' Config Version: {{version}}': ' 配置版本:{{version}}', + ' Status: API key configured\n': ' 状态:API 密钥已配置\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ 认证方式:阿里云百炼 Coding Plan(不完整)', + ' Issue: API key not found in environment or settings\n': + ' 问题:在环境变量或设置中未找到 API 密钥\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' 运行 `qwen auth coding-plan` 重新配置。\n', + '✓ Authentication Method: {{type}}': '✓ 认证方式:{{type}}', + ' Status: Configured\n': ' 状态:已配置\n', + 'Failed to check authentication status: {{error}}': + '检查认证状态失败:{{error}}', + 'Select an option:': '请选择:', + 'Raw mode not available. Please run in an interactive terminal.': + '原始模式不可用。请在交互式终端中运行。', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n', + verbose: '详细', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + '详细模式下显示完整工具输出和思考过程(Ctrl+O 切换)。', + 'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果', + + 'Switch to plan mode or exit plan mode': '切换到计划模式或退出计划模式', + 'Exited plan mode. Previous approval mode restored.': + '已退出计划模式,已恢复之前的审批模式。', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + '启用计划模式。智能体将只分析和规划,而不执行工具。', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + '已处于计划模式。使用 "/plan exit" 退出计划模式。', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + '未处于计划模式。请先使用 "/plan" 进入计划模式。', + + "Set up Qwen Code's status line UI": '配置 Qwen Code 的状态栏', +}; diff --git a/apps/airiscode-cli/src/index.ts b/apps/airiscode-cli/src/index.ts index 8e2201e32..be8e4f0e6 100644 --- a/apps/airiscode-cli/src/index.ts +++ b/apps/airiscode-cli/src/index.ts @@ -1,66 +1,86 @@ #!/usr/bin/env node + /** - * AIRIS Code CLI - * Terminal-first autonomous coding runner + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ -import { Command } from 'commander'; -import chalk from 'chalk'; -import { createCodeCommand } from './commands/code.js'; -import { createConfigCommand } from './commands/config.js'; -import { createSessionCommand } from './commands/session.js'; -import { createChatCommand } from './commands/chat.js'; +import './gemini.js'; +import { main } from './gemini.js'; +import { FatalError } from '@airiscode/core'; +import { writeStderrLine } from './utils/stdioHelpers.js'; -const program = new Command(); +// --- Global Entry Point --- -program - .name('airis') - .description('AIRIS Code - Terminal-first autonomous coding runner') - .version('0.1.0'); +// Suppress known race conditions in @lydell/node-pty. +// +// PTY errors that are expected due to timing races between process exit +// and I/O operations. These should not crash the app. +// +// References: +// - https://github.com/microsoft/node-pty/issues/178 (EIO on macOS/Linux) +// - https://github.com/microsoft/node-pty/issues/827 (resize on Windows) +const getErrnoCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +}; -// Add subcommands -program.addCommand(createCodeCommand()); -program.addCommand(createConfigCommand()); -program.addCommand(createSessionCommand()); -program.addCommand(createChatCommand()); +const isExpectedPtyRaceError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } -// Default action - treat as 'code' command when task is provided directly -program - .argument('[task]', 'Task description (shorthand for "airis code ")') - .option('-d, --driver ', 'Driver to use (ollama, openai)') - .option('-a, --adapter ', 'Adapter to use (claude-code, cursor)') - .option('-p, --policy ', 'Policy level (restricted, sandboxed, untrusted)') - .option('--cwd ', 'Working directory') - .option('-s, --session ', 'Session name') - .option('--json', 'Output in JSON format') - .option('-v, --verbose', 'Verbose output') - .action(async (task, options) => { - if (task) { - // If task is provided, execute as 'code' command - const { executeCodeCommand } = await import('./commands/code.js'); - await executeCodeCommand(task, options); - } else { - // No task provided, start interactive chat mode - const { executeChatCommand } = await import('./commands/chat.js'); - await executeChatCommand({ cwd: options.cwd }); - } - }); + const message = error.message; + const code = getErrnoCode(error); + + if ( + (code === 'EIO' && message.includes('read')) || + message.includes('read EIO') + ) { + return true; + } + + if ( + message.includes('ioctl(2) failed, EBADF') || + message.includes('Cannot resize a pty that has already exited') + ) { + return true; + } + + return false; +}; -// Global error handler process.on('uncaughtException', (error) => { - console.error(chalk.red('\nUncaught Exception:')); - console.error(chalk.red(error.message)); - if (process.env.DEBUG) { - console.error(error.stack); + if (isExpectedPtyRaceError(error)) { + return; + } + + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + } else { + writeStderrLine(String(error)); } process.exit(1); }); -process.on('unhandledRejection', (reason) => { - console.error(chalk.red('\nUnhandled Rejection:')); - console.error(chalk.red(String(reason))); +main().catch((error) => { + if (error instanceof FatalError) { + let errorMessage = error.message; + if (!process.env['NO_COLOR']) { + errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; + } + console.error(errorMessage); + process.exit(error.exitCode); + } + console.error('An unexpected critical error occurred:'); + if (error instanceof Error) { + console.error(error.stack); + } else { + console.error(String(error)); + } process.exit(1); }); - -// Parse arguments -program.parse(process.argv); diff --git a/apps/airiscode-cli/src/index.tsx b/apps/airiscode-cli/src/index.tsx deleted file mode 100644 index f5b9bc170..000000000 --- a/apps/airiscode-cli/src/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -// Use Gemini CLI's full implementation with all features including日本語 input support -import { main } from './gemini-base/gemini.js'; - -main(); diff --git a/apps/airiscode-cli/src/nonInteractive/control/ControlContext.ts b/apps/airiscode-cli/src/nonInteractive/control/ControlContext.ts new file mode 100644 index 000000000..bc284f70e --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/ControlContext.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Context + * + * Layer 1 of the control plane architecture. Provides shared, session-scoped + * state for all controllers and services, eliminating the need for prop + * drilling. Mutable fields are intentionally exposed so controllers can track + * runtime state (e.g. permission mode, active MCP clients). + */ + +import type { Config, MCPServerConfig } from '@airiscode/core'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js'; +import type { PermissionMode } from '../types.js'; + +/** + * Control Context interface + * + * Provides shared access to session-scoped resources and mutable state + * for all controllers across both ControlDispatcher (protocol routing) and + * ControlService (programmatic API). + */ +export interface IControlContext { + readonly config: Config; + readonly streamJson: StreamJsonOutputAdapter; + readonly sessionId: string; + readonly abortSignal: AbortSignal; + readonly debugMode: boolean; + + permissionMode: PermissionMode; + sdkMcpServers: Set; + mcpClients: Map; + inputClosed: boolean; + + onInterrupt?: () => void; +} + +/** + * Control Context implementation + */ +export class ControlContext implements IControlContext { + readonly config: Config; + readonly streamJson: StreamJsonOutputAdapter; + readonly sessionId: string; + readonly abortSignal: AbortSignal; + readonly debugMode: boolean; + + permissionMode: PermissionMode; + sdkMcpServers: Set; + mcpClients: Map; + inputClosed: boolean; + + onInterrupt?: () => void; + + constructor(options: { + config: Config; + streamJson: StreamJsonOutputAdapter; + sessionId: string; + abortSignal: AbortSignal; + permissionMode?: PermissionMode; + onInterrupt?: () => void; + }) { + this.config = options.config; + this.streamJson = options.streamJson; + this.sessionId = options.sessionId; + this.abortSignal = options.abortSignal; + this.debugMode = options.config.getDebugMode(); + this.permissionMode = options.permissionMode || 'default'; + this.sdkMcpServers = new Set(); + this.mcpClients = new Map(); + this.inputClosed = false; + this.onInterrupt = options.onInterrupt; + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/ControlDispatcher.ts b/apps/airiscode-cli/src/nonInteractive/control/ControlDispatcher.ts new file mode 100644 index 000000000..7b579e3e1 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/ControlDispatcher.ts @@ -0,0 +1,432 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Dispatcher + * + * Layer 2 of the control plane architecture. Routes control requests between + * SDK and CLI to appropriate controllers, manages pending request registries, + * and handles cancellation/cleanup. Application code MUST NOT depend on + * controller instances exposed by this class; instead, use ControlService, + * which wraps these controllers with a stable programmatic API. + * + * Controllers: + * - SystemController: initialize, interrupt, set_model, supported_commands + * - PermissionController: can_use_tool, set_permission_mode + * - SdkMcpController: mcp_server_status (mcp_message handled via callback) + * - HookController: hook_callback + * + * Note: mcp_message requests are NOT routed through the dispatcher. CLI MCP + * clients send messages via SdkMcpController.createSendSdkMcpMessage() callback. + * + * Note: Control request types are centrally defined in the ControlRequestType + * enum in packages/sdk/typescript/src/types/controlRequests.ts + */ + +import type { IControlContext } from './ControlContext.js'; +import type { IPendingRequestRegistry } from './controllers/baseController.js'; +import { SystemController } from './controllers/systemController.js'; +import { PermissionController } from './controllers/permissionController.js'; +import { SdkMcpController } from './controllers/sdkMcpController.js'; +// import { HookController } from './controllers/hookController.js'; +import type { + CLIControlRequest, + CLIControlResponse, + ControlResponse, + ControlRequestPayload, +} from '../types.js'; +import { createDebugLogger } from '@airiscode/core'; + +const debugLogger = createDebugLogger('CONTROL_DISPATCHER'); + +/** + * Tracks an incoming request from SDK awaiting CLI response + */ +interface PendingIncomingRequest { + controller: string; + abortController: AbortController; + timeoutId: NodeJS.Timeout; +} + +/** + * Tracks an outgoing request from CLI awaiting SDK response + */ +interface PendingOutgoingRequest { + controller: string; + resolve: (response: ControlResponse) => void; + reject: (error: Error) => void; + timeoutId: NodeJS.Timeout; +} + +/** + * Central coordinator for control plane communication. + * Routes requests to controllers and manages request lifecycle. + */ +export class ControlDispatcher implements IPendingRequestRegistry { + private context: IControlContext; + + // Make controllers publicly accessible + readonly systemController: SystemController; + readonly permissionController: PermissionController; + readonly sdkMcpController: SdkMcpController; + // readonly hookController: HookController; + + // Central pending request registries + private pendingIncomingRequests: Map = + new Map(); + private pendingOutgoingRequests: Map = + new Map(); + + private abortHandler: (() => void) | null = null; + + constructor(context: IControlContext) { + this.context = context; + + // Create domain controllers with context and registry + this.systemController = new SystemController( + context, + this, + 'SystemController', + ); + this.permissionController = new PermissionController( + context, + this, + 'PermissionController', + ); + this.sdkMcpController = new SdkMcpController( + context, + this, + 'SdkMcpController', + ); + // this.hookController = new HookController(context, this, 'HookController'); + + // Listen for main abort signal + this.abortHandler = () => { + this.shutdown(); + }; + this.context.abortSignal.addEventListener('abort', this.abortHandler); + } + + /** + * Routes an incoming request to the appropriate controller and sends response + */ + async dispatch(request: CLIControlRequest): Promise { + const { request_id, request: payload } = request; + + try { + // Route to appropriate controller + const controller = this.getControllerForRequest(payload.subtype); + const response = await controller.handleRequest(payload, request_id); + + // Send success response + this.sendSuccessResponse(request_id, response); + } catch (error) { + // Send error response + const errorMessage = + error instanceof Error ? error.message : String(error); + this.sendErrorResponse(request_id, errorMessage); + } + } + + /** + * Processes response from SDK for an outgoing request + */ + handleControlResponse(response: CLIControlResponse): void { + const responsePayload = response.response; + const requestId = responsePayload.request_id; + + const pending = this.pendingOutgoingRequests.get(requestId); + if (!pending) { + // No pending request found - may have timed out or been cancelled + debugLogger.debug( + `[ControlDispatcher] No pending outgoing request for: ${requestId}`, + ); + return; + } + + // Deregister + this.deregisterOutgoingRequest(requestId); + + // Resolve or reject based on response type + if (responsePayload.subtype === 'success') { + pending.resolve(responsePayload); + } else { + const errorMessage = + typeof responsePayload.error === 'string' + ? responsePayload.error + : (responsePayload.error?.message ?? 'Unknown error'); + pending.reject(new Error(errorMessage)); + } + } + + /** + * Sends a control request to SDK and waits for response + */ + async sendControlRequest( + payload: ControlRequestPayload, + timeoutMs?: number, + ): Promise { + // Delegate to system controller (or any controller, they all have the same method) + return this.systemController.sendControlRequest(payload, timeoutMs); + } + + /** + * Cancels a specific request or all pending requests + */ + handleCancel(requestId?: string): void { + if (requestId) { + // Cancel specific incoming request + const pending = this.pendingIncomingRequests.get(requestId); + if (pending) { + pending.abortController.abort(); + this.deregisterIncomingRequest(requestId); + this.sendErrorResponse(requestId, 'Request cancelled'); + + debugLogger.debug( + `[ControlDispatcher] Cancelled incoming request: ${requestId}`, + ); + } + } else { + // Cancel ALL pending incoming requests + const requestIds = Array.from(this.pendingIncomingRequests.keys()); + for (const id of requestIds) { + const pending = this.pendingIncomingRequests.get(id); + if (pending) { + pending.abortController.abort(); + this.deregisterIncomingRequest(id); + this.sendErrorResponse(id, 'All requests cancelled'); + } + } + + debugLogger.debug( + `[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`, + ); + } + } + + /** + * Marks stdin as closed and rejects all pending outgoing requests. + * After this is called, new outgoing requests will be rejected immediately. + * This should be called when stdin closes to avoid waiting for responses. + */ + markInputClosed(): void { + if (this.context.inputClosed) { + return; // Already marked as closed + } + + this.context.inputClosed = true; + + const requestIds = Array.from(this.pendingOutgoingRequests.keys()); + + if (this.context.debugMode) { + debugLogger.debug( + `[ControlDispatcher] Input closed, rejecting ${requestIds.length} pending outgoing requests`, + ); + } + + // Reject all currently pending outgoing requests + for (const id of requestIds) { + const pending = this.pendingOutgoingRequests.get(id); + if (pending) { + this.deregisterOutgoingRequest(id); + pending.reject(new Error('Input closed')); + } + } + } + + /** + * Stops all pending requests and cleans up all controllers + */ + shutdown(): void { + debugLogger.debug('[ControlDispatcher] Shutting down'); + + // Remove abort listener to prevent memory leak + if (this.abortHandler) { + this.context.abortSignal.removeEventListener('abort', this.abortHandler); + this.abortHandler = null; + } + + // Cancel all incoming requests + for (const [ + _requestId, + pending, + ] of this.pendingIncomingRequests.entries()) { + pending.abortController.abort(); + clearTimeout(pending.timeoutId); + } + this.pendingIncomingRequests.clear(); + + // Cancel all outgoing requests + for (const [ + _requestId, + pending, + ] of this.pendingOutgoingRequests.entries()) { + clearTimeout(pending.timeoutId); + pending.reject(new Error('Dispatcher shutdown')); + } + this.pendingOutgoingRequests.clear(); + + // Cleanup controllers + this.systemController.cleanup(); + this.permissionController.cleanup(); + this.sdkMcpController.cleanup(); + // this.hookController.cleanup(); + } + + /** + * Registers an incoming request in the pending registry. + */ + registerIncomingRequest( + requestId: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ): void { + this.pendingIncomingRequests.set(requestId, { + controller, + abortController, + timeoutId, + }); + } + + /** + * Removes an incoming request from the pending registry + */ + deregisterIncomingRequest(requestId: string): void { + const pending = this.pendingIncomingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingIncomingRequests.delete(requestId); + } + } + + /** + * Registers an outgoing request in the pending registry + */ + registerOutgoingRequest( + requestId: string, + controller: string, + resolve: (response: ControlResponse) => void, + reject: (error: Error) => void, + timeoutId: NodeJS.Timeout, + ): void { + this.pendingOutgoingRequests.set(requestId, { + controller, + resolve, + reject, + timeoutId, + }); + } + + /** + * Removes an outgoing request from the pending registry + */ + deregisterOutgoingRequest(requestId: string): void { + const pending = this.pendingOutgoingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingOutgoingRequests.delete(requestId); + } + } + + /** + * Get count of pending incoming requests (for debugging) + */ + getPendingIncomingRequestCount(): number { + return this.pendingIncomingRequests.size; + } + + /** + * Wait for all incoming request handlers to complete. + * + * Uses polling since we don't have direct Promise references to handlers. + * The pendingIncomingRequests map is managed by BaseController: + * - Registered when handler starts (in handleRequest) + * - Deregistered when handler completes (success or error) + * + * @param pollIntervalMs - How often to check (default 50ms) + * @param timeoutMs - Maximum wait time (default 30s) + */ + async waitForPendingIncomingRequests( + pollIntervalMs: number = 50, + timeoutMs: number = 30000, + ): Promise { + const startTime = Date.now(); + + while (this.pendingIncomingRequests.size > 0) { + if (Date.now() - startTime > timeoutMs) { + debugLogger.warn( + `[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`, + ); + break; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + if (this.pendingIncomingRequests.size === 0) { + debugLogger.debug('[ControlDispatcher] All incoming requests completed'); + } + } + + /** + * Returns the controller that handles the given request subtype + */ + private getControllerForRequest(subtype: string) { + switch (subtype) { + case 'initialize': + case 'interrupt': + case 'set_model': + case 'supported_commands': + return this.systemController; + + case 'can_use_tool': + case 'set_permission_mode': + return this.permissionController; + + case 'mcp_server_status': + return this.sdkMcpController; + + // case 'hook_callback': + // return this.hookController; + + default: + throw new Error(`Unknown control request subtype: ${subtype}`); + } + } + + /** + * Sends a success response back to SDK + */ + private sendSuccessResponse( + requestId: string, + response: Record, + ): void { + const controlResponse: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response, + }, + }; + this.context.streamJson.send(controlResponse); + } + + /** + * Sends an error response back to SDK + */ + private sendErrorResponse(requestId: string, error: string): void { + const controlResponse: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error, + }, + }; + this.context.streamJson.send(controlResponse); + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/ControlService.ts b/apps/airiscode-cli/src/nonInteractive/control/ControlService.ts new file mode 100644 index 000000000..671a18530 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/ControlService.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Service - Public Programmatic API + * + * Provides type-safe access to control plane functionality for internal + * CLI code. This is the ONLY programmatic interface that should be used by: + * - nonInteractiveCli + * - Session managers + * - Tool execution handlers + * - Internal CLI logic + * + * DO NOT use ControlDispatcher or controllers directly from application code. + * + * Architecture: + * - ControlContext stores shared session state (Layer 1) + * - ControlDispatcher handles protocol-level routing (Layer 2) + * - ControlService provides programmatic API for internal CLI usage (Layer 3) + * + * ControlService and ControlDispatcher share controller instances to ensure + * a single source of truth. All higher level code MUST access the control + * plane exclusively through ControlService. + */ + +import type { IControlContext } from './ControlContext.js'; +import type { ControlDispatcher } from './ControlDispatcher.js'; +import type { + PermissionServiceAPI, + SystemServiceAPI, + // McpServiceAPI, + // HookServiceAPI, +} from './types/serviceAPIs.js'; + +/** + * Control Service + * + * Facade layer providing domain-grouped APIs for control plane operations. + * Shares controller instances with ControlDispatcher to ensure single source + * of truth and state consistency. + */ +export class ControlService { + private dispatcher: ControlDispatcher; + + /** + * Construct ControlService + * + * @param context - Control context (unused directly, passed to dispatcher) + * @param dispatcher - Control dispatcher that owns the controller instances + */ + constructor(context: IControlContext, dispatcher: ControlDispatcher) { + this.dispatcher = dispatcher; + } + + /** + * Permission Domain API + * + * Handles tool execution permissions, approval checks, and callbacks. + * Delegates to the shared PermissionController instance. + */ + get permission(): PermissionServiceAPI { + const controller = this.dispatcher.permissionController; + return { + /** + * Build UI suggestions for tool confirmation dialogs + * + * Creates actionable permission suggestions based on tool confirmation details. + * + * @param confirmationDetails - Tool confirmation details + * @returns Array of permission suggestions or null + */ + buildPermissionSuggestions: + controller.buildPermissionSuggestions.bind(controller), + + /** + * Get callback for monitoring tool call status updates + * + * Returns callback function for integration with CoreToolScheduler. + * + * @returns Callback function for tool call updates + */ + getToolCallUpdateCallback: + controller.getToolCallUpdateCallback.bind(controller), + }; + } + + /** + * System Domain API + * + * Handles system-level operations and session management. + * Delegates to the shared SystemController instance. + */ + get system(): SystemServiceAPI { + const controller = this.dispatcher.systemController; + return { + /** + * Get control capabilities + * + * Returns the control capabilities object indicating what control + * features are available. Used exclusively for the initialize + * control response. System messages do not include capabilities. + * + * @returns Control capabilities object + */ + getControlCapabilities: () => controller.buildControlCapabilities(), + }; + } + + /** + * MCP Domain API + * + * Handles Model Context Protocol server interactions. + * Delegates to the shared MCPController instance. + */ + // get mcp(): McpServiceAPI { + // return { + // /** + // * Get or create MCP client for a server (lazy initialization) + // * + // * Returns existing client or creates new connection. + // * + // * @param serverName - Name of the MCP server + // * @returns Promise with client and config + // */ + // getMcpClient: async (serverName: string) => { + // // MCPController has a private method getOrCreateMcpClient + // // We need to expose it via the API + // // For now, throw error as placeholder + // // The actual implementation will be added when we update MCPController + // throw new Error( + // `getMcpClient not yet implemented in ControlService. Server: ${serverName}`, + // ); + // }, + // + // /** + // * List all available MCP servers + // * + // * Returns names of configured/connected MCP servers. + // * + // * @returns Array of server names + // */ + // listServers: () => { + // // Get servers from context + // const sdkServers = Array.from( + // this.dispatcher.mcpController['context'].sdkMcpServers, + // ); + // const cliServers = Array.from( + // this.dispatcher.mcpController['context'].mcpClients.keys(), + // ); + // return [...new Set([...sdkServers, ...cliServers])]; + // }, + // }; + // } + + /** + * Hook Domain API + * + * Handles hook callback processing (placeholder for future expansion). + * Delegates to the shared HookController instance. + */ + // get hook(): HookServiceAPI { + // // HookController has no public methods yet - controller access reserved for future use + // return {}; + // } + + /** + * Cleanup all controllers + * + * Should be called on session shutdown. Delegates to dispatcher's shutdown + * method to ensure all controllers are properly cleaned up. + */ + cleanup(): void { + // Delegate to dispatcher which manages controller cleanup + this.dispatcher.shutdown(); + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/controllers/baseController.ts b/apps/airiscode-cli/src/nonInteractive/control/controllers/baseController.ts new file mode 100644 index 000000000..4f9ee462c --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/controllers/baseController.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Base Controller + * + * Abstract base class for domain-specific control plane controllers. + * Provides common functionality for: + * - Handling incoming control requests (SDK -> CLI) + * - Sending outgoing control requests (CLI -> SDK) + * - Request lifecycle management with timeout and cancellation + * - Integration with central pending request registry + */ + +import { randomUUID } from 'node:crypto'; +import type { DebugLogger } from '@airiscode/core'; +import { createDebugLogger } from '@airiscode/core'; +import type { IControlContext } from '../ControlContext.js'; +import type { + ControlRequestPayload, + ControlResponse, + CLIControlRequest, +} from '../../types.js'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds + +/** + * Registry interface for controllers to register/deregister pending requests + */ +export interface IPendingRequestRegistry { + registerIncomingRequest( + requestId: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ): void; + deregisterIncomingRequest(requestId: string): void; + + registerOutgoingRequest( + requestId: string, + controller: string, + resolve: (response: ControlResponse) => void, + reject: (error: Error) => void, + timeoutId: NodeJS.Timeout, + ): void; + deregisterOutgoingRequest(requestId: string): void; +} + +/** + * Abstract base controller class + * + * Subclasses should implement handleRequestPayload() to process specific + * control request types. + */ +export abstract class BaseController { + protected context: IControlContext; + protected registry: IPendingRequestRegistry; + protected controllerName: string; + protected debugLogger: DebugLogger; + + constructor( + context: IControlContext, + registry: IPendingRequestRegistry, + controllerName: string, + ) { + this.context = context; + this.registry = registry; + this.controllerName = controllerName; + this.debugLogger = createDebugLogger(); + } + + /** + * Handle an incoming control request + * + * Manages lifecycle: register -> process -> deregister + */ + async handleRequest( + payload: ControlRequestPayload, + requestId: string, + ): Promise> { + const requestAbortController = new AbortController(); + + // Setup timeout + const timeoutId = setTimeout(() => { + requestAbortController.abort(); + this.registry.deregisterIncomingRequest(requestId); + this.debugLogger.warn( + `[${this.controllerName}] Request timeout: ${requestId}`, + ); + }, DEFAULT_REQUEST_TIMEOUT_MS); + + // Register with central registry + this.registry.registerIncomingRequest( + requestId, + this.controllerName, + requestAbortController, + timeoutId, + ); + + try { + const response = await this.handleRequestPayload( + payload, + requestAbortController.signal, + ); + + // Success - deregister + this.registry.deregisterIncomingRequest(requestId); + + return response; + } catch (error) { + // Error - deregister + this.registry.deregisterIncomingRequest(requestId); + throw error; + } + } + + /** + * Send an outgoing control request to SDK + * + * Manages lifecycle: register -> send -> wait for response -> deregister + * Respects the provided AbortSignal for cancellation. + */ + async sendControlRequest( + payload: ControlRequestPayload, + timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, + signal?: AbortSignal, + ): Promise { + // Check if stream is closed + if (this.context.inputClosed) { + throw new Error('Input closed'); + } + + // Check if already aborted + if (signal?.aborted) { + throw new Error('Request aborted'); + } + + const requestId = randomUUID(); + + return new Promise((resolve, reject) => { + // Setup abort handler + const abortHandler = () => { + this.registry.deregisterOutgoingRequest(requestId); + reject(new Error('Request aborted')); + this.debugLogger.warn( + `[${this.controllerName}] Outgoing request aborted: ${requestId}`, + ); + }; + + if (signal) { + signal.addEventListener('abort', abortHandler, { once: true }); + } + + // Setup timeout + const timeoutId = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + this.registry.deregisterOutgoingRequest(requestId); + reject(new Error('Control request timeout')); + this.debugLogger.warn( + `[${this.controllerName}] Outgoing request timeout: ${requestId}`, + ); + }, timeoutMs); + + // Wrap resolve/reject to clean up abort listener + const wrappedResolve = (response: ControlResponse) => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + resolve(response); + }; + + const wrappedReject = (error: Error) => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + reject(error); + }; + + // Register with central registry + this.registry.registerOutgoingRequest( + requestId, + this.controllerName, + wrappedResolve, + wrappedReject, + timeoutId, + ); + + // Send control request + const request: CLIControlRequest = { + type: 'control_request', + request_id: requestId, + request: payload, + }; + + try { + this.context.streamJson.send(request); + } catch (error) { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + this.registry.deregisterOutgoingRequest(requestId); + reject(error); + } + }); + } + + /** + * Abstract method: Handle specific request payload + * + * Subclasses must implement this to process their domain-specific requests. + */ + protected abstract handleRequestPayload( + payload: ControlRequestPayload, + signal: AbortSignal, + ): Promise>; + + /** + * Cleanup resources + */ + cleanup(): void {} +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/controllers/hookController.ts b/apps/airiscode-cli/src/nonInteractive/control/controllers/hookController.ts new file mode 100644 index 000000000..df6eb4c0e --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/controllers/hookController.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Hook Controller + * + * Handles hook-related control requests: + * - hook_callback: Process hook callbacks (placeholder for future) + */ + +import { BaseController } from './baseController.js'; +import type { + ControlRequestPayload, + CLIHookCallbackRequest, +} from '../../types.js'; + +export class HookController extends BaseController { + /** + * Handle hook control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'hook_callback': + return this.handleHookCallback(payload as CLIHookCallbackRequest); + + default: + throw new Error(`Unsupported request subtype in HookController`); + } + } + + /** + * Handle hook_callback request + * + * Processes hook callbacks (placeholder implementation) + */ + private async handleHookCallback( + payload: CLIHookCallbackRequest, + ): Promise> { + this.debugLogger.debug( + `[HookController] Hook callback: ${payload.callback_id}`, + ); + + // Hook callback processing not yet implemented + return { + result: 'Hook callback processing not yet implemented', + callback_id: payload.callback_id, + tool_use_id: payload.tool_use_id, + }; + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/controllers/permissionController.ts b/apps/airiscode-cli/src/nonInteractive/control/controllers/permissionController.ts new file mode 100644 index 000000000..4296d4ae3 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/controllers/permissionController.ts @@ -0,0 +1,495 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Permission Controller + * + * Handles permission-related control requests: + * - can_use_tool: Check if tool usage is allowed + * - set_permission_mode: Change permission mode at runtime + * + * Abstracts all permission logic from the session manager to keep it clean. + */ + +import type { + WaitingToolCall, + ToolExecuteConfirmationDetails, + ToolMcpConfirmationDetails, + ApprovalMode, +} from '@airiscode/core'; +import { + InputFormat, + ToolConfirmationOutcome, +} from '@airiscode/core'; +import type { + CLIControlPermissionRequest, + CLIControlSetPermissionModeRequest, + ControlRequestPayload, + PermissionMode, + PermissionSuggestion, +} from '../../types.js'; +import { BaseController } from './baseController.js'; + +// Import ToolCallConfirmationDetails types for type alignment +type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan'; + +export class PermissionController extends BaseController { + private pendingOutgoingRequests = new Set(); + + /** + * Handle permission control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + switch (payload.subtype) { + case 'can_use_tool': + return this.handleCanUseTool( + payload as CLIControlPermissionRequest, + signal, + ); + + case 'set_permission_mode': + return this.handleSetPermissionMode( + payload as CLIControlSetPermissionModeRequest, + signal, + ); + + default: + throw new Error(`Unsupported request subtype in PermissionController`); + } + } + + /** + * Handle can_use_tool request + * + * Comprehensive permission evaluation based on: + * - Permission mode (approval level) + * - Tool registry validation + * - Error handling with safe defaults + */ + private async handleCanUseTool( + payload: CLIControlPermissionRequest, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const toolName = payload.tool_name; + if ( + !toolName || + typeof toolName !== 'string' || + toolName.trim().length === 0 + ) { + return { + subtype: 'can_use_tool', + behavior: 'deny', + message: 'Missing or invalid tool_name in can_use_tool request', + }; + } + + let behavior: 'allow' | 'deny' = 'allow'; + let message: string | undefined; + + try { + // Check permission mode first + const permissionResult = this.checkPermissionMode(); + if (!permissionResult.allowed) { + behavior = 'deny'; + message = permissionResult.message; + } + + // Check tool registry if permission mode allows + if (behavior === 'allow') { + const registryResult = this.checkToolRegistry(toolName); + if (!registryResult.allowed) { + behavior = 'deny'; + message = registryResult.message; + } + } + } catch (error) { + behavior = 'deny'; + message = + error instanceof Error + ? `Failed to evaluate tool permission: ${error.message}` + : 'Failed to evaluate tool permission'; + } + + const response: Record = { + subtype: 'can_use_tool', + behavior, + }; + + if (message) { + response['message'] = message; + } + + return response; + } + + /** + * Check permission mode for tool execution + */ + private checkPermissionMode(): { allowed: boolean; message?: string } { + const mode = this.context.permissionMode; + + // Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES) + switch (mode) { + case 'yolo': // Allow all tools + case 'auto-edit': // Auto-approve edit operations + case 'plan': // Auto-approve planning operations + return { allowed: true }; + + case 'default': // TODO: allow all tools for test + default: + return { + allowed: false, + message: + 'Tool execution requires manual approval. Update permission mode or approve via host.', + }; + } + } + + /** + * Check if tool exists in registry + */ + private checkToolRegistry(toolName: string): { + allowed: boolean; + message?: string; + } { + try { + // Access tool registry through config + const config = this.context.config; + const registryProvider = config as unknown as { + getToolRegistry?: () => { + getTool?: (name: string) => unknown; + }; + }; + + if (typeof registryProvider.getToolRegistry === 'function') { + const registry = registryProvider.getToolRegistry(); + if ( + registry && + typeof registry.getTool === 'function' && + !registry.getTool(toolName) + ) { + return { + allowed: false, + message: `Tool "${toolName}" is not registered.`, + }; + } + } + + return { allowed: true }; + } catch (error) { + return { + allowed: false, + message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Handle set_permission_mode request + * + * Updates the permission mode in the context + */ + private async handleSetPermissionMode( + payload: CLIControlSetPermissionModeRequest, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const mode = payload.mode; + const validModes: PermissionMode[] = [ + 'default', + 'plan', + 'auto-edit', + 'yolo', + ]; + + if (!validModes.includes(mode)) { + throw new Error( + `Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`, + ); + } + + this.context.permissionMode = mode; + this.context.config.setApprovalMode(mode as ApprovalMode); + + this.debugLogger.info( + `[PermissionController] Permission mode updated to: ${mode}`, + ); + + return { status: 'updated', mode }; + } + + /** + * Build permission suggestions for tool confirmation UI + * + * This method creates UI suggestions based on tool confirmation details, + * helping the host application present appropriate permission options. + */ + buildPermissionSuggestions( + confirmationDetails: unknown, + ): PermissionSuggestion[] | null { + if ( + !confirmationDetails || + typeof confirmationDetails !== 'object' || + !('type' in confirmationDetails) + ) { + return null; + } + + const details = confirmationDetails as Record; + const type = String(details['type'] ?? ''); + const title = + typeof details['title'] === 'string' ? details['title'] : undefined; + + // Ensure type matches ToolCallConfirmationDetails union + const confirmationType = type as ToolConfirmationType; + + switch (confirmationType) { + case 'exec': // ToolExecuteConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Command', + description: `Execute: ${details['command']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this command execution', + }, + ]; + + case 'edit': // ToolEditConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Edit', + description: `Edit file: ${details['fileName']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this file edit', + }, + { + type: 'modify', + label: 'Review Changes', + description: 'Review the proposed changes before applying', + }, + ]; + + case 'plan': // ToolPlanConfirmationDetails + return [ + { + type: 'allow', + label: 'Approve Plan', + description: title || 'Execute the proposed plan', + }, + { + type: 'deny', + label: 'Reject Plan', + description: 'Do not execute this plan', + }, + ]; + + case 'mcp': // ToolMcpConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow MCP Call', + description: `${details['serverName']}: ${details['toolName']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this MCP server call', + }, + ]; + + case 'info': // ToolInfoConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Info Request', + description: title || 'Allow information request', + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this information request', + }, + ]; + + default: + // Fallback for unknown types + return [ + { + type: 'allow', + label: 'Allow', + description: title || `Allow ${type} operation`, + }, + { + type: 'deny', + label: 'Deny', + description: `Block ${type} operation`, + }, + ]; + } + } + + /** + * Get callback for monitoring tool calls and handling outgoing permission requests + * This is passed to executeToolCall to hook into CoreToolScheduler updates + */ + getToolCallUpdateCallback(): (toolCalls: unknown[]) => void { + return (toolCalls: unknown[]) => { + for (const call of toolCalls) { + if ( + call && + typeof call === 'object' && + (call as { status?: string }).status === 'awaiting_approval' + ) { + const awaiting = call as WaitingToolCall; + if ( + typeof awaiting.confirmationDetails?.onConfirm === 'function' && + !this.pendingOutgoingRequests.has(awaiting.request.callId) + ) { + this.pendingOutgoingRequests.add(awaiting.request.callId); + void this.handleOutgoingPermissionRequest(awaiting); + } + } + } + }; + } + + /** + * Handle outgoing permission request + * + * Behavior depends on input format: + * - stream-json mode: Send can_use_tool to SDK and await response + * - Other modes: Check local approval mode and decide immediately + */ + private async handleOutgoingPermissionRequest( + toolCall: WaitingToolCall, + ): Promise { + try { + // Check if already aborted + if (this.context.abortSignal?.aborted) { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + return; + } + + const inputFormat = this.context.config.getInputFormat?.(); + const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON; + + if (!isStreamJsonMode) { + // No SDK available - use local permission check + const modeCheck = this.checkPermissionMode(); + const outcome = modeCheck.allowed + ? ToolConfirmationOutcome.ProceedOnce + : ToolConfirmationOutcome.Cancel; + + await toolCall.confirmationDetails.onConfirm(outcome); + return; + } + + // Stream-json mode: ask SDK for permission + const permissionSuggestions = this.buildPermissionSuggestions( + toolCall.confirmationDetails, + ); + + const response = await this.sendControlRequest( + { + subtype: 'can_use_tool', + tool_name: toolCall.request.name, + tool_use_id: toolCall.request.callId, + input: toolCall.request.args, + permission_suggestions: permissionSuggestions, + blocked_path: null, + } as CLIControlPermissionRequest, + undefined, // use default timeout + this.context.abortSignal, + ); + + if (response.subtype !== 'success') { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + return; + } + + const payload = (response.response || {}) as Record; + const behavior = String(payload['behavior'] || '').toLowerCase(); + + if (behavior === 'allow') { + // Handle updated input if provided + const updatedInput = payload['updatedInput']; + if (updatedInput && typeof updatedInput === 'object') { + toolCall.request.args = updatedInput as Record; + } + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ); + } else { + // Extract cancel message from response if available + const cancelMessage = + typeof payload['message'] === 'string' + ? payload['message'] + : undefined; + + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + cancelMessage ? { cancelMessage } : undefined, + ); + } + } catch (error) { + this.debugLogger.error( + '[PermissionController] Outgoing permission failed:', + error, + ); + + // Extract error message + const errorMessage = + error instanceof Error ? error.message : String(error); + + // On error, pass error message as cancel message + // Only pass payload for exec and mcp types that support it + const confirmationType = toolCall.confirmationDetails.type; + if (['edit', 'exec', 'mcp'].includes(confirmationType)) { + const execOrMcpDetails = toolCall.confirmationDetails as + | ToolExecuteConfirmationDetails + | ToolMcpConfirmationDetails; + await execOrMcpDetails.onConfirm(ToolConfirmationOutcome.Cancel, { + cancelMessage: `Error: ${errorMessage}`, + }); + } else { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + { + cancelMessage: `Error: ${errorMessage}`, + }, + ); + } + } finally { + this.pendingOutgoingRequests.delete(toolCall.request.callId); + } + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/controllers/sdkMcpController.ts b/apps/airiscode-cli/src/nonInteractive/control/controllers/sdkMcpController.ts new file mode 100644 index 000000000..058226c09 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/controllers/sdkMcpController.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SDK MCP Controller + * + * Handles MCP communication between CLI MCP clients and SDK MCP servers: + * - Provides sendSdkMcpMessage callback for CLI → SDK MCP message routing + * - mcp_server_status: Returns status of SDK MCP servers + * + * Message Flow (CLI MCP Client → SDK MCP Server): + * CLI MCP Client → SdkControlClientTransport.send() → + * sendSdkMcpMessage callback → control_request (mcp_message) → SDK → + * SDK MCP Server processes → control_response → CLI MCP Client + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { BaseController } from './baseController.js'; +import type { + ControlRequestPayload, + CLIControlMcpMessageRequest, +} from '../../types.js'; + +const MCP_REQUEST_TIMEOUT = 30_000; // 30 seconds + +export class SdkMcpController extends BaseController { + /** + * Handle SDK MCP control requests from ControlDispatcher + * + * Note: mcp_message requests are NOT handled here. CLI MCP clients + * send messages via the sendSdkMcpMessage callback directly, not + * through the control dispatcher. + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + switch (payload.subtype) { + case 'mcp_server_status': + return this.handleMcpStatus(); + + default: + throw new Error(`Unsupported request subtype in SdkMcpController`); + } + } + + /** + * Handle mcp_server_status request + * + * Returns status of all registered SDK MCP servers. + * SDK servers are considered "connected" if they are registered. + */ + private async handleMcpStatus(): Promise> { + const status: Record = {}; + + for (const serverName of this.context.sdkMcpServers) { + // SDK MCP servers are "connected" once registered since they run in SDK process + status[serverName] = 'connected'; + } + + return { + subtype: 'mcp_server_status', + status, + }; + } + + /** + * Send MCP message to SDK server via control plane + * + * @param serverName - Name of the SDK MCP server + * @param message - MCP JSON-RPC message to send + * @returns MCP JSON-RPC response from SDK server + */ + private async sendMcpMessageToSdk( + serverName: string, + message: JSONRPCMessage, + ): Promise { + this.debugLogger.debug( + `[SdkMcpController] Sending MCP message to SDK server '${serverName}':`, + JSON.stringify(message), + ); + + // Send control request to SDK with the MCP message + const response = await this.sendControlRequest( + { + subtype: 'mcp_message', + server_name: serverName, + message: message as CLIControlMcpMessageRequest['message'], + }, + MCP_REQUEST_TIMEOUT, + this.context.abortSignal, + ); + + // Extract MCP response from control response + const responsePayload = response.response as Record; + const mcpResponse = responsePayload?.['mcp_response'] as JSONRPCMessage; + + if (!mcpResponse) { + throw new Error( + `Invalid MCP response from SDK for server '${serverName}'`, + ); + } + + this.debugLogger.debug( + `[SdkMcpController] Received MCP response from SDK server '${serverName}':`, + JSON.stringify(mcpResponse), + ); + + return mcpResponse; + } + + /** + * Create a callback function for sending MCP messages to SDK servers. + * + * This callback is used by McpClientManager/SdkControlClientTransport to send + * MCP messages from CLI MCP clients to SDK MCP servers via the control plane. + * + * @returns A function that sends MCP messages to SDK and returns the response + */ + createSendSdkMcpMessage(): ( + serverName: string, + message: JSONRPCMessage, + ) => Promise { + return (serverName: string, message: JSONRPCMessage) => + this.sendMcpMessageToSdk(serverName, message); + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/controllers/systemController.ts b/apps/airiscode-cli/src/nonInteractive/control/controllers/systemController.ts new file mode 100644 index 000000000..d9cde131e --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/controllers/systemController.ts @@ -0,0 +1,417 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * System Controller + * + * Handles system-level control requests: + * - initialize: Setup session and return system info + * - interrupt: Cancel current operations + * - set_model: Switch model (placeholder) + */ + +import { BaseController } from './baseController.js'; +import type { + ControlRequestPayload, + CLIControlInitializeRequest, + CLIControlSetModelRequest, + CLIMcpServerConfig, +} from '../../types.js'; +import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js'; +import { + createDebugLogger, + MCPServerConfig, + AuthProviderType, + type MCPOAuthConfig, +} from '@airiscode/core'; + +const debugLogger = createDebugLogger('SYSTEM_CONTROLLER'); + +export class SystemController extends BaseController { + /** + * Handle system control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + switch (payload.subtype) { + case 'initialize': + return this.handleInitialize( + payload as CLIControlInitializeRequest, + signal, + ); + + case 'interrupt': + return this.handleInterrupt(); + + case 'set_model': + return this.handleSetModel( + payload as CLIControlSetModelRequest, + signal, + ); + + case 'supported_commands': + return this.handleSupportedCommands(signal); + + default: + throw new Error(`Unsupported request subtype in SystemController`); + } + } + + /** + * Handle initialize request + * + * Processes SDK MCP servers config. + * SDK servers are registered in context.sdkMcpServers + * and added to config.mcpServers with the sdk type flag. + * External MCP servers are configured separately in settings. + */ + private async handleInitialize( + payload: CLIControlInitializeRequest, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + this.context.config.setSdkMode(true); + + // Process SDK MCP servers + if ( + payload.sdkMcpServers && + typeof payload.sdkMcpServers === 'object' && + payload.sdkMcpServers !== null + ) { + const sdkServers: Record = {}; + for (const [key, wireConfig] of Object.entries(payload.sdkMcpServers)) { + const name = + typeof wireConfig?.name === 'string' && wireConfig.name.trim().length + ? wireConfig.name + : key; + + this.context.sdkMcpServers.add(name); + sdkServers[name] = new MCPServerConfig( + undefined, // command + undefined, // args + undefined, // env + undefined, // cwd + undefined, // url + undefined, // httpUrl + undefined, // headers + undefined, // tcp + undefined, // timeout + true, // trust - SDK servers are trusted + undefined, // description + undefined, // includeTools + undefined, // excludeTools + undefined, // extensionName + undefined, // oauth + undefined, // authProviderType + undefined, // targetAudience + undefined, // targetServiceAccount + 'sdk', // type + ); + } + + const sdkServerCount = Object.keys(sdkServers).length; + if (sdkServerCount > 0) { + try { + this.context.config.addMcpServers(sdkServers); + debugLogger.debug( + `[SystemController] Added ${sdkServerCount} SDK MCP servers to config`, + ); + } catch (error) { + debugLogger.error( + '[SystemController] Failed to add SDK MCP servers:', + error, + ); + } + } + } + + if ( + payload.mcpServers && + typeof payload.mcpServers === 'object' && + payload.mcpServers !== null + ) { + const externalServers: Record = {}; + for (const [name, serverConfig] of Object.entries(payload.mcpServers)) { + const normalized = this.normalizeMcpServerConfig( + name, + serverConfig as CLIMcpServerConfig | undefined, + ); + if (normalized) { + externalServers[name] = normalized; + } + } + + const externalCount = Object.keys(externalServers).length; + if (externalCount > 0) { + try { + this.context.config.addMcpServers(externalServers); + debugLogger.debug( + `[SystemController] Added ${externalCount} external MCP servers to config`, + ); + } catch (error) { + debugLogger.error( + '[SystemController] Failed to add external MCP servers:', + error, + ); + } + } + } + + if (payload.agents && Array.isArray(payload.agents)) { + try { + this.context.config.setSessionSubagents(payload.agents); + + debugLogger.debug( + `[SystemController] Added ${payload.agents.length} session subagents to config`, + ); + } catch (error) { + debugLogger.error( + '[SystemController] Failed to add session subagents:', + error, + ); + } + } + + // Build capabilities for response + const capabilities = this.buildControlCapabilities(); + + debugLogger.debug( + `[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`, + ); + + return { + subtype: 'initialize', + session_id: this.context.config.getSessionId(), + capabilities, + }; + } + + /** + * Build control capabilities for initialize control response + * + * This method constructs the control capabilities object that indicates + * what control features are available. It is used exclusively in the + * initialize control response. + */ + buildControlCapabilities(): Record { + const capabilities: Record = { + can_handle_can_use_tool: true, + can_handle_hook_callback: false, + can_set_permission_mode: + typeof this.context.config.setApprovalMode === 'function', + can_set_model: typeof this.context.config.setModel === 'function', + // SDK MCP servers are supported - messages routed through control plane + can_handle_mcp_message: true, + }; + + return capabilities; + } + + private normalizeMcpServerConfig( + serverName: string, + config?: CLIMcpServerConfig, + ): MCPServerConfig | null { + if (!config || typeof config !== 'object') { + debugLogger.warn( + `[SystemController] Ignoring invalid MCP server config for '${serverName}'`, + ); + return null; + } + + const authProvider = this.normalizeAuthProviderType( + config.authProviderType, + ); + const oauthConfig = this.normalizeOAuthConfig(config.oauth); + + return new MCPServerConfig( + config.command, + config.args, + config.env, + config.cwd, + config.url, + config.httpUrl, + config.headers, + config.tcp, + config.timeout, + config.trust, + config.description, + config.includeTools, + config.excludeTools, + config.extensionName, + oauthConfig, + authProvider, + config.targetAudience, + config.targetServiceAccount, + ); + } + + private normalizeAuthProviderType( + value?: string, + ): AuthProviderType | undefined { + if (!value) { + return undefined; + } + + switch (value) { + case AuthProviderType.DYNAMIC_DISCOVERY: + return value as AuthProviderType; + default: + debugLogger.warn( + `[SystemController] Unsupported authProviderType '${value}', skipping`, + ); + return undefined; + } + } + + private normalizeOAuthConfig( + oauth?: CLIMcpServerConfig['oauth'], + ): MCPOAuthConfig | undefined { + if (!oauth) { + return undefined; + } + + return { + enabled: oauth.enabled, + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + authorizationUrl: oauth.authorizationUrl, + tokenUrl: oauth.tokenUrl, + scopes: oauth.scopes, + audiences: oauth.audiences, + redirectUri: oauth.redirectUri, + tokenParamName: oauth.tokenParamName, + registrationUrl: oauth.registrationUrl, + }; + } + + /** + * Handle interrupt request + * + * Triggers the interrupt callback to cancel current operations + */ + private async handleInterrupt(): Promise> { + // Trigger interrupt callback if available + if (this.context.onInterrupt) { + this.context.onInterrupt(); + } + + // Abort the main signal to cancel ongoing operations + if (this.context.abortSignal && !this.context.abortSignal.aborted) { + // Note: We can't directly abort the signal, but the onInterrupt callback should handle this + debugLogger.debug('[SystemController] Interrupt signal triggered'); + } + + debugLogger.debug('[SystemController] Interrupt handled'); + + return { subtype: 'interrupt' }; + } + + /** + * Handle set_model request + * + * Implements actual model switching with validation and error handling + */ + private async handleSetModel( + payload: CLIControlSetModelRequest, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const model = payload.model; + + // Validate model parameter + if (typeof model !== 'string' || model.trim() === '') { + throw new Error('Invalid model specified for set_model request'); + } + + try { + // Attempt to set the model using config + await this.context.config.setModel(model); + + debugLogger.info(`[SystemController] Model switched to: ${model}`); + + return { + subtype: 'set_model', + model, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to set model'; + + debugLogger.error( + `[SystemController] Failed to set model ${model}:`, + error, + ); + + throw new Error(errorMessage); + } + } + + /** + * Handle supported_commands request + * + * Returns list of supported slash commands loaded dynamically + */ + private async handleSupportedCommands( + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const slashCommands = await this.loadSlashCommandNames(signal); + + return { + subtype: 'supported_commands', + commands: slashCommands, + }; + } + + /** + * Load slash command names using getAvailableCommands + * + * @param signal - AbortSignal to respect for cancellation + * @returns Promise resolving to array of slash command names + */ + private async loadSlashCommandNames(signal: AbortSignal): Promise { + if (signal.aborted) { + return []; + } + + try { + const commands = await getAvailableCommands(this.context.config, signal); + + if (signal.aborted) { + return []; + } + + // Extract command names and sort + return commands.map((cmd) => cmd.name).sort(); + } catch (error) { + // Check if the error is due to abort + if (signal.aborted) { + return []; + } + + debugLogger.error( + '[SystemController] Failed to load slash commands:', + error, + ); + return []; + } + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/control/types/serviceAPIs.ts b/apps/airiscode-cli/src/nonInteractive/control/types/serviceAPIs.ts new file mode 100644 index 000000000..3e0b83084 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/control/types/serviceAPIs.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Service API Types + * + * These interfaces define the public API contract for the ControlService facade. + * They provide type-safe, domain-grouped access to control plane functionality + * for internal CLI code (nonInteractiveCli, session managers, etc.). + */ + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { MCPServerConfig } from '@airiscode/core'; +import type { PermissionSuggestion } from '../../types.js'; + +/** + * Permission Service API + * + * Provides permission-related operations including tool execution approval, + * permission suggestions, and tool call monitoring callbacks. + */ +export interface PermissionServiceAPI { + /** + * Build UI suggestions for tool confirmation dialogs + * + * Creates actionable permission suggestions based on tool confirmation details, + * helping host applications present appropriate approval/denial options. + * + * @param confirmationDetails - Tool confirmation details (type, title, metadata) + * @returns Array of permission suggestions or null if details are invalid + */ + buildPermissionSuggestions( + confirmationDetails: unknown, + ): PermissionSuggestion[] | null; + + /** + * Get callback for monitoring tool call status updates + * + * Returns a callback function that should be passed to executeToolCall + * to enable integration with CoreToolScheduler updates. This callback + * handles outgoing permission requests for tools awaiting approval. + * + * @returns Callback function that processes tool call updates + */ + getToolCallUpdateCallback(): (toolCalls: unknown[]) => void; +} + +/** + * System Service API + * + * Provides system-level operations for the control system. + * + * Note: System messages and slash commands are NOT part of the control system API. + * They are handled independently via buildSystemMessage() from nonInteractiveHelpers.ts, + * regardless of whether the control system is available. + */ +export interface SystemServiceAPI { + /** + * Get control capabilities + * + * Returns the control capabilities object indicating what control + * features are available. Used exclusively for the initialize control + * response. System messages do not include capabilities as they are + * independent of the control system. + * + * @returns Control capabilities object + */ + getControlCapabilities(): Record; +} + +/** + * MCP Service API + * + * Provides Model Context Protocol server interaction including + * lazy client initialization and server discovery. + */ +export interface McpServiceAPI { + /** + * Get or create MCP client for a server (lazy initialization) + * + * Returns an existing client from cache or creates a new connection + * if this is the first request for the server. Handles connection + * lifecycle and error recovery. + * + * @param serverName - Name of the MCP server to connect to + * @returns Promise resolving to client instance and server configuration + * @throws Error if server is not configured or connection fails + */ + getMcpClient(serverName: string): Promise<{ + client: Client; + config: MCPServerConfig; + }>; + + /** + * List all available MCP servers + * + * Returns names of both SDK-managed and CLI-managed MCP servers + * that are currently configured or connected. + * + * @returns Array of server names + */ + listServers(): string[]; +} + +/** + * Hook Service API + * + * Provides hook callback processing (placeholder for future expansion). + */ +export interface HookServiceAPI { + // Future: Hook-related methods will be added here + // For now, hook functionality is handled only via control requests + registerHookCallback(callback: unknown): void; +} diff --git a/apps/airiscode-cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/apps/airiscode-cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts new file mode 100644 index 000000000..57fb5212b --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -0,0 +1,1336 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { + Config, + ToolCallRequestInfo, + ToolCallResponseInfo, + SessionMetrics, + ServerGeminiStreamEvent, + AgentResultDisplay, + McpToolProgressData, +} from '@airiscode/core'; +import { + GeminiEventType, + ToolErrorType, + parseAndFormatApiError, +} from '@airiscode/core'; +import type { Part, GenerateContentResponseUsageMetadata } from '@airiscode/core'; +import type { + CLIAssistantMessage, + CLIMessage, + CLIPermissionDenial, + CLIResultMessage, + CLIResultMessageError, + CLIResultMessageSuccess, + CLIUserMessage, + ContentBlock, + ExtendedUsage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + Usage, +} from '../types.js'; +import { functionResponsePartsToString } from '../../utils/nonInteractiveHelpers.js'; + +/** + * Internal state for managing a single message context (main agent or subagent). + */ +export interface MessageState { + messageId: string | null; + blocks: ContentBlock[]; + openBlocks: Set; + usage: Usage; + messageStarted: boolean; + finalized: boolean; + currentBlockType: ContentBlock['type'] | null; +} + +/** + * Options for building result messages. + * Used by both streaming and non-streaming JSON output adapters. + */ +export interface ResultOptions { + readonly isError: boolean; + readonly errorMessage?: string; + readonly durationMs: number; + readonly apiDurationMs: number; + readonly numTurns: number; + readonly usage?: ExtendedUsage; + readonly stats?: SessionMetrics; + readonly summary?: string; + readonly subtype?: string; +} + +/** + * Interface for message emission strategies. + * Implementations decide whether to emit messages immediately (streaming) + * or collect them for batch emission (non-streaming). + * This interface defines the common message emission methods that + * all JSON output adapters should implement. + */ +export interface MessageEmitter { + emitMessage(message: CLIMessage): void; + emitUserMessage(parts: Part[], parentToolUseId?: string | null): void; + emitToolResult( + request: ToolCallRequestInfo, + response: ToolCallResponseInfo, + parentToolUseId?: string | null, + ): void; + emitSystemMessage(subtype: string, data?: unknown): void; + /** + * Emits a tool progress stream event. + * Only emits when the adapter supports partial messages (stream mode). + * In non-streaming mode, this is a no-op. + * + * @param request - Tool call request info + * @param progress - Structured MCP progress data + */ + emitToolProgress( + request: ToolCallRequestInfo, + progress: McpToolProgressData, + ): void; +} + +/** + * JSON-focused output adapter interface. + * Handles structured JSON output for both streaming and non-streaming modes. + * This interface defines the complete API that all JSON output adapters must implement. + */ +export interface JsonOutputAdapterInterface extends MessageEmitter { + startAssistantMessage(): void; + processEvent(event: ServerGeminiStreamEvent): void; + finalizeAssistantMessage(): CLIAssistantMessage; + emitResult(options: ResultOptions): void; + + startSubagentAssistantMessage?(parentToolUseId: string): void; + processSubagentToolCall?( + toolCall: NonNullable[number], + parentToolUseId: string, + ): void; + finalizeSubagentAssistantMessage?( + parentToolUseId: string, + ): CLIAssistantMessage; + emitSubagentErrorResult?( + errorMessage: string, + numTurns: number, + parentToolUseId: string, + ): void; + + getSessionId(): string; + getModel(): string; +} + +/** + * Abstract base class for JSON output adapters. + * Contains shared logic for message building, state management, and content block handling. + */ +export abstract class BaseJsonOutputAdapter { + protected readonly config: Config; + + // Main agent message state + protected mainAgentMessageState: MessageState; + + // Subagent message states keyed by parentToolUseId + protected subagentMessageStates = new Map(); + + // Last assistant message for result generation + protected lastAssistantMessage: CLIAssistantMessage | null = null; + + // Track permission denials (execution denied tool calls) + protected permissionDenials: CLIPermissionDenial[] = []; + + constructor(config: Config) { + this.config = config; + this.mainAgentMessageState = this.createMessageState(); + } + + /** + * Creates a new message state with default values. + */ + protected createMessageState(): MessageState { + return { + messageId: null, + blocks: [], + openBlocks: new Set(), + usage: this.createUsage(), + messageStarted: false, + finalized: false, + currentBlockType: null, + }; + } + + /** + * Gets or creates message state for a given context. + * + * @param parentToolUseId - null for main agent, string for subagent + * @returns MessageState for the context + */ + protected getMessageState(parentToolUseId: string | null): MessageState { + if (parentToolUseId === null) { + return this.mainAgentMessageState; + } + + let state = this.subagentMessageStates.get(parentToolUseId); + if (!state) { + state = this.createMessageState(); + this.subagentMessageStates.set(parentToolUseId, state); + } + return state; + } + + /** + * Creates a Usage object from metadata. + * + * @param metadata - Optional usage metadata from Gemini API + * @returns Usage object + */ + protected createUsage( + metadata?: GenerateContentResponseUsageMetadata | null, + ): Usage { + const usage: Usage = { + input_tokens: 0, + output_tokens: 0, + }; + + if (!metadata) { + return usage; + } + + if (typeof metadata.promptTokenCount === 'number') { + usage.input_tokens = metadata.promptTokenCount; + } + if (typeof metadata.candidatesTokenCount === 'number') { + usage.output_tokens = metadata.candidatesTokenCount; + } + if (typeof metadata.cachedContentTokenCount === 'number') { + usage.cache_read_input_tokens = metadata.cachedContentTokenCount; + } + if (typeof metadata.totalTokenCount === 'number') { + usage.total_tokens = metadata.totalTokenCount; + } + + return usage; + } + + /** + * Builds a CLIAssistantMessage from the current message state. + * + * @param parentToolUseId - null for main agent, string for subagent + * @returns CLIAssistantMessage + */ + protected buildMessage(parentToolUseId: string | null): CLIAssistantMessage { + const state = this.getMessageState(parentToolUseId); + + if (!state.messageId) { + throw new Error('Message not started'); + } + + // Enforce constraint: assistant message must contain only a single type of ContentBlock + if (state.blocks.length > 0) { + const blockTypes = new Set(state.blocks.map((block) => block.type)); + if (blockTypes.size > 1) { + throw new Error( + `Assistant message must contain only one type of ContentBlock, found: ${Array.from(blockTypes).join(', ')}`, + ); + } + } + + // Determine stop_reason based on content block types + // If the message contains only tool_use blocks, set stop_reason to 'tool_use' + const stopReason = + state.blocks.length > 0 && + state.blocks.every((block) => block.type === 'tool_use') + ? 'tool_use' + : null; + + return { + type: 'assistant', + uuid: state.messageId, + session_id: this.config.getSessionId(), + parent_tool_use_id: parentToolUseId, + message: { + id: state.messageId, + type: 'message', + role: 'assistant', + model: this.config.getModel(), + content: state.blocks, + stop_reason: stopReason, + usage: state.usage, + }, + }; + } + + /** + * Finalizes pending blocks (text or thinking) by closing them. + * + * @param state - Message state to finalize blocks for + * @param parentToolUseId - null for main agent, string for subagent (optional, defaults to null) + */ + protected finalizePendingBlocks( + state: MessageState, + parentToolUseId?: string | null, + ): void { + const actualParentToolUseId = parentToolUseId ?? null; + const lastBlock = state.blocks[state.blocks.length - 1]; + if (!lastBlock) { + return; + } + + const index = state.blocks.length - 1; + if (!state.openBlocks.has(index)) { + return; + } + + if (lastBlock.type === 'text' || lastBlock.type === 'thinking') { + this.onBlockClosed(state, index, actualParentToolUseId); + this.closeBlock(state, index); + } + } + + /** + * Opens a block (adds to openBlocks set). + * + * @param state - Message state + * @param index - Block index + * @param _block - Content block + */ + protected openBlock( + state: MessageState, + index: number, + _block: ContentBlock, + ): void { + state.openBlocks.add(index); + } + + /** + * Closes a block (removes from openBlocks set). + * + * @param state - Message state + * @param index - Block index + */ + protected closeBlock(state: MessageState, index: number): void { + if (!state.openBlocks.has(index)) { + return; + } + state.openBlocks.delete(index); + } + + /** + * Guarantees that a single assistant message aggregates only one + * content block category (text, thinking, or tool use). When a new + * block type is requested, the current message is finalized and a fresh + * assistant message is started to honour the single-type constraint. + * + * @param state - Message state + * @param targetType - Target block type + * @param parentToolUseId - null for main agent, string for subagent + */ + protected ensureBlockTypeConsistency( + state: MessageState, + targetType: ContentBlock['type'], + parentToolUseId: string | null, + ): void { + if (state.currentBlockType === targetType) { + return; + } + + if (state.currentBlockType === null) { + state.currentBlockType = targetType; + return; + } + + // Finalize current message and start new one + this.finalizeAssistantMessageInternal(state, parentToolUseId); + this.startAssistantMessageInternal(state); + state.currentBlockType = targetType; + } + + /** + * Starts a new assistant message, resetting state. + * + * @param state - Message state to reset + */ + protected startAssistantMessageInternal(state: MessageState): void { + state.messageId = randomUUID(); + state.blocks = []; + state.openBlocks = new Set(); + state.usage = this.createUsage(); + state.messageStarted = false; + state.finalized = false; + state.currentBlockType = null; + } + + /** + * Finalizes an assistant message. + * + * @param state - Message state to finalize + * @param parentToolUseId - null for main agent, string for subagent + * @returns CLIAssistantMessage + */ + protected finalizeAssistantMessageInternal( + state: MessageState, + parentToolUseId: string | null, + ): CLIAssistantMessage { + if (state.finalized) { + return this.buildMessage(parentToolUseId); + } + state.finalized = true; + + this.finalizePendingBlocks(state, parentToolUseId); + const orderedOpenBlocks = Array.from(state.openBlocks).sort( + (a, b) => a - b, + ); + for (const index of orderedOpenBlocks) { + this.onBlockClosed(state, index, parentToolUseId); + this.closeBlock(state, index); + } + + const message = this.buildMessage(parentToolUseId); + if (state.messageStarted) { + this.emitMessageImpl(message); + } + return message; + } + + /** + * Abstract method for emitting messages. Implementations decide whether + * to emit immediately (streaming) or collect for batch emission. + * Note: The message object already contains parent_tool_use_id field, + * so it doesn't need to be passed as a separate parameter. + * + * @param message - Message to emit (already contains parent_tool_use_id if applicable) + */ + protected abstract emitMessageImpl(message: CLIMessage): void; + + /** + * Abstract method to determine if stream events should be emitted. + * + * @returns true if stream events should be emitted + */ + protected abstract shouldEmitStreamEvents(): boolean; + + /** + * Hook method called when a text block is created. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param block - Text block that was created + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onTextBlockCreated( + _state: MessageState, + _index: number, + _block: TextBlock, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when text content is appended. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param fragment - Text fragment that was appended + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onTextAppended( + _state: MessageState, + _index: number, + _fragment: string, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when a thinking block is created. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param block - Thinking block that was created + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onThinkingBlockCreated( + _state: MessageState, + _index: number, + _block: ThinkingBlock, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when thinking content is appended. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param fragment - Thinking fragment that was appended + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onThinkingAppended( + _state: MessageState, + _index: number, + _fragment: string, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when a tool_use block is created. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param block - Tool use block that was created + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onToolUseBlockCreated( + _state: MessageState, + _index: number, + _block: ToolUseBlock, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when tool_use input is set. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param input - Tool use input that was set + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onToolUseInputSet( + _state: MessageState, + _index: number, + _input: unknown, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when a block is closed. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onBlockClosed( + _state: MessageState, + _index: number, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called to ensure message is started. + * Subclasses can override this to emit message_start events. + * + * @param state - Message state + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onEnsureMessageStarted( + _state: MessageState, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Gets the session ID from config. + * + * @returns Session ID + */ + getSessionId(): string { + return this.config.getSessionId(); + } + + /** + * Gets the model name from config. + * + * @returns Model name + */ + getModel(): string { + return this.config.getModel(); + } + + // ========== Main Agent APIs ========== + + /** + * Starts a new assistant message for the main agent. + * This is a shared implementation used by both streaming and non-streaming adapters. + */ + startAssistantMessage(): void { + this.startAssistantMessageInternal(this.mainAgentMessageState); + } + + /** + * Processes a stream event from the Gemini API. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param event - Stream event from Gemini API + */ + processEvent(event: ServerGeminiStreamEvent): void { + const state = this.mainAgentMessageState; + if (state.finalized) { + return; + } + + switch (event.type) { + case GeminiEventType.Content: + this.appendText(state, event.value, null); + break; + case GeminiEventType.Citation: + if (typeof event.value === 'string') { + this.appendText(state, `\n${event.value}`, null); + } + break; + case GeminiEventType.Thought: + this.appendThinking( + state, + event.value.subject, + event.value.description, + null, + ); + break; + case GeminiEventType.ToolCallRequest: + this.appendToolUse(state, event.value, null); + break; + case GeminiEventType.Finished: + if (event.value?.usageMetadata) { + state.usage = this.createUsage(event.value.usageMetadata); + } + this.finalizePendingBlocks(state, null); + break; + case GeminiEventType.Error: { + // Format the error message using parseAndFormatApiError for consistency + // with interactive mode error display + const errorText = parseAndFormatApiError( + event.value.error, + this.config.getContentGeneratorConfig()?.authType, + ); + this.appendText(state, errorText, null); + break; + } + default: + break; + } + } + + // ========== Subagent APIs ========== + + /** + * Starts a new assistant message for a subagent. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param parentToolUseId - Parent tool use ID + */ + startSubagentAssistantMessage(parentToolUseId: string): void { + const state = this.getMessageState(parentToolUseId); + this.startAssistantMessageInternal(state); + } + + /** + * Finalizes a subagent assistant message. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param parentToolUseId - Parent tool use ID + * @returns CLIAssistantMessage + */ + finalizeSubagentAssistantMessage( + parentToolUseId: string, + ): CLIAssistantMessage { + const state = this.getMessageState(parentToolUseId); + return this.finalizeAssistantMessageInternal(state, parentToolUseId); + } + + /** + * Emits a subagent error result message. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param errorMessage - Error message + * @param numTurns - Number of turns + * @param parentToolUseId - Parent tool use ID + */ + emitSubagentErrorResult( + errorMessage: string, + numTurns: number, + parentToolUseId: string, + ): void { + const state = this.getMessageState(parentToolUseId); + // Finalize any pending assistant message + if (state.messageStarted && !state.finalized) { + this.finalizeSubagentAssistantMessage(parentToolUseId); + } + + const errorResult = this.buildSubagentErrorResult(errorMessage, numTurns); + this.emitMessageImpl(errorResult); + } + + /** + * Processes a subagent tool call. + * This is a shared implementation used by both streaming and non-streaming adapters. + * Uses template method pattern with hooks for stream events. + * + * @param toolCall - Tool call information + * @param parentToolUseId - Parent tool use ID + */ + processSubagentToolCall( + toolCall: NonNullable[number], + parentToolUseId: string, + ): void { + const state = this.getMessageState(parentToolUseId); + + // Finalize any pending text message before starting tool_use + const hasText = + state.blocks.some((b) => b.type === 'text') || + (state.currentBlockType === 'text' && state.blocks.length > 0); + if (hasText) { + this.finalizeSubagentAssistantMessage(parentToolUseId); + this.startSubagentAssistantMessage(parentToolUseId); + } + + // Ensure message is started before appending tool_use + if (!state.messageId || !state.messageStarted) { + this.startAssistantMessageInternal(state); + } + + this.ensureBlockTypeConsistency(state, 'tool_use', parentToolUseId); + this.ensureMessageStarted(state, parentToolUseId); + this.finalizePendingBlocks(state, parentToolUseId); + + const { index } = this.createSubagentToolUseBlock( + state, + toolCall, + parentToolUseId, + ); + + // Process tool use block creation and closure + // Subclasses can override hook methods to emit stream events + this.processSubagentToolUseBlock(state, index, toolCall, parentToolUseId); + + // Finalize tool_use message immediately + this.finalizeSubagentAssistantMessage(parentToolUseId); + this.startSubagentAssistantMessage(parentToolUseId); + } + + /** + * Processes a tool use block for subagent. + * This method is called by processSubagentToolCall to handle tool use block creation, + * input setting, and closure. Subclasses can override this to customize behavior. + * + * @param state - Message state + * @param index - Block index + * @param toolCall - Tool call information + * @param parentToolUseId - Parent tool use ID + */ + protected processSubagentToolUseBlock( + state: MessageState, + index: number, + toolCall: NonNullable[number], + parentToolUseId: string, + ): void { + // Emit tool_use block creation event (with empty input) + const startBlock: ToolUseBlock = { + type: 'tool_use', + id: toolCall.callId, + name: toolCall.name, + input: {}, + }; + this.onToolUseBlockCreated(state, index, startBlock, parentToolUseId); + this.onToolUseInputSet(state, index, toolCall.args ?? {}, parentToolUseId); + this.onBlockClosed(state, index, parentToolUseId); + this.closeBlock(state, index); + } + + /** + * Updates the last assistant message. + * Subclasses can override this to customize tracking behavior. + * + * @param message - Assistant message to track + */ + protected updateLastAssistantMessage(message: CLIAssistantMessage): void { + this.lastAssistantMessage = message; + } + + // ========== Shared Content Block Methods ========== + + /** + * Appends text content to the current message. + * Uses template method pattern with hooks for stream events. + * + * @param state - Message state + * @param fragment - Text fragment to append + * @param parentToolUseId - null for main agent, string for subagent + */ + protected appendText( + state: MessageState, + fragment: string, + parentToolUseId: string | null, + ): void { + if (fragment.length === 0) { + return; + } + + this.ensureBlockTypeConsistency(state, 'text', parentToolUseId); + this.ensureMessageStarted(state, parentToolUseId); + + let current = state.blocks[state.blocks.length - 1] as + | TextBlock + | undefined; + const isNewBlock = !current || current.type !== 'text'; + if (isNewBlock) { + current = { type: 'text', text: '' } satisfies TextBlock; + const index = state.blocks.length; + state.blocks.push(current); + this.openBlock(state, index, current); + this.onTextBlockCreated(state, index, current, parentToolUseId); + } + + // current is guaranteed to be defined here (either existing or newly created) + current!.text += fragment; + const index = state.blocks.length - 1; + this.onTextAppended(state, index, fragment, parentToolUseId); + } + + /** + * Appends thinking content to the current message. + * Uses template method pattern with hooks for stream events. + * + * @param state - Message state + * @param subject - Thinking subject + * @param description - Thinking description + * @param parentToolUseId - null for main agent, string for subagent + */ + protected appendThinking( + state: MessageState, + subject?: string, + description?: string, + parentToolUseId?: string | null, + ): void { + const actualParentToolUseId = parentToolUseId ?? null; + + // Build fragment without trimming to preserve whitespace in streaming content + // Only filter out null/undefined/empty values + const parts: string[] = []; + if (subject && subject.length > 0) { + parts.push(subject); + } + if (description && description.length > 0) { + parts.push(description); + } + + const fragment = parts.join(': '); + if (!fragment) { + return; + } + + this.ensureBlockTypeConsistency(state, 'thinking', actualParentToolUseId); + this.ensureMessageStarted(state, actualParentToolUseId); + + let current = state.blocks[state.blocks.length - 1] as + | ThinkingBlock + | undefined; + const isNewBlock = !current || current.type !== 'thinking'; + if (isNewBlock) { + current = { + type: 'thinking', + thinking: '', + signature: subject, + } satisfies ThinkingBlock; + const index = state.blocks.length; + state.blocks.push(current); + this.openBlock(state, index, current); + this.onThinkingBlockCreated(state, index, current, actualParentToolUseId); + } + + // current is guaranteed to be defined here (either existing or newly created) + current!.thinking = `${current!.thinking ?? ''}${fragment}`; + const index = state.blocks.length - 1; + this.onThinkingAppended(state, index, fragment, actualParentToolUseId); + } + + /** + * Appends a tool_use block to the current message. + * Uses template method pattern with hooks for stream events. + * + * @param state - Message state + * @param request - Tool call request info + * @param parentToolUseId - null for main agent, string for subagent + */ + protected appendToolUse( + state: MessageState, + request: ToolCallRequestInfo, + parentToolUseId: string | null, + ): void { + this.ensureBlockTypeConsistency(state, 'tool_use', parentToolUseId); + this.ensureMessageStarted(state, parentToolUseId); + this.finalizePendingBlocks(state, parentToolUseId); + + const index = state.blocks.length; + const block: ToolUseBlock = { + type: 'tool_use', + id: request.callId, + name: request.name, + input: request.args, + }; + state.blocks.push(block); + this.openBlock(state, index, block); + + // Emit tool_use block creation event (with empty input) + const startBlock: ToolUseBlock = { + type: 'tool_use', + id: request.callId, + name: request.name, + input: {}, + }; + this.onToolUseBlockCreated(state, index, startBlock, parentToolUseId); + this.onToolUseInputSet(state, index, request.args ?? {}, parentToolUseId); + + this.onBlockClosed(state, index, parentToolUseId); + this.closeBlock(state, index); + } + + /** + * Ensures that a message has been started. + * Calls hook method for subclasses to emit message_start events. + * + * @param state - Message state + * @param parentToolUseId - null for main agent, string for subagent + */ + protected ensureMessageStarted( + state: MessageState, + parentToolUseId: string | null, + ): void { + if (state.messageStarted) { + return; + } + state.messageStarted = true; + this.onEnsureMessageStarted(state, parentToolUseId); + } + + /** + * Creates and adds a tool_use block to the state. + * This is a shared helper method used by processSubagentToolCall implementations. + * + * @param state - Message state + * @param toolCall - Tool call information + * @param parentToolUseId - Parent tool use ID + * @returns The created block and its index + */ + protected createSubagentToolUseBlock( + state: MessageState, + toolCall: NonNullable[number], + _parentToolUseId: string, + ): { block: ToolUseBlock; index: number } { + const index = state.blocks.length; + const block: ToolUseBlock = { + type: 'tool_use', + id: toolCall.callId, + name: toolCall.name, + input: toolCall.args || {}, + }; + state.blocks.push(block); + this.openBlock(state, index, block); + return { block, index }; + } + + /** + * Emits a user message. + * @param parts - Array of Part objects + * @param parentToolUseId - Optional parent tool use ID for subagent messages + */ + emitUserMessage(parts: Part[], parentToolUseId?: string | null): void { + const content = partsToContentBlock(parts); + const message: CLIUserMessage = { + type: 'user', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: parentToolUseId ?? null, + message: { + role: 'user', + content, + }, + }; + this.emitMessageImpl(message); + } + + /** + * Checks if responseParts contain any functionResponse with an error. + * This handles cancelled responses and other error cases where the error + * is embedded in responseParts rather than the top-level error field. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ + private checkResponsePartsForError( + responseParts: Part[] | undefined, + ): string | undefined { + // Use the shared helper function defined at file level + return checkResponsePartsForError(responseParts); + } + + /** + * Emits a tool result message. + * Collects execution denied tool calls for inclusion in result messages. + * Handles both explicit errors (response.error) and errors embedded in + * responseParts (e.g., cancelled responses). + * @param request - Tool call request info + * @param response - Tool call response info + * @param parentToolUseId - Parent tool use ID (null for main agent) + */ + emitToolResult( + request: ToolCallRequestInfo, + response: ToolCallResponseInfo, + parentToolUseId: string | null = null, + ): void { + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = this.checkResponsePartsForError( + response.responseParts, + ); + + // Determine if this is an error response + const hasError = Boolean(response.error) || Boolean(responsePartsError); + + // Track permission denials (execution denied errors) + if ( + response.error && + response.errorType === ToolErrorType.EXECUTION_DENIED + ) { + const denial: CLIPermissionDenial = { + tool_name: request.name, + tool_use_id: request.callId, + tool_input: request.args, + }; + this.permissionDenials.push(denial); + } + + const block: ToolResultBlock = { + type: 'tool_result', + tool_use_id: request.callId, + is_error: hasError, + }; + const content = toolResultContent(response); + if (content !== undefined) { + block.content = content; + } + + const message: CLIUserMessage = { + type: 'user', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: parentToolUseId, + message: { + role: 'user', + content: [block], + }, + }; + this.emitMessageImpl(message); + } + + /** + * Emits a system message. + * @param subtype - System message subtype + * @param data - Optional data payload + */ + emitSystemMessage(subtype: string, data?: unknown): void { + const systemMessage = { + type: 'system', + subtype, + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: null, + data, + } as const; + this.emitMessageImpl(systemMessage); + } + + /** + * Emits a tool progress stream event. + * Default implementation is a no-op. StreamJsonOutputAdapter overrides this + * to emit stream events when includePartialMessages is enabled. + * + * @param _request - Tool call request info + * @param _progress - Structured MCP progress data + */ + emitToolProgress( + _request: ToolCallRequestInfo, + _progress: McpToolProgressData, + ): void { + // No-op in base class. Only StreamJsonOutputAdapter emits tool progress + // as stream events when includePartialMessages is enabled. + } + + /** + * Builds a result message from options. + * Helper method used by both emitResult implementations. + * Includes permission denials collected from execution denied tool calls. + * @param options - Result options + * @param lastAssistantMessage - Last assistant message for text extraction + * @returns CLIResultMessage + */ + protected buildResultMessage( + options: ResultOptions, + lastAssistantMessage: CLIAssistantMessage | null, + ): CLIResultMessage { + const usage = options.usage ?? createExtendedUsage(); + const resultText = + options.summary ?? + (lastAssistantMessage + ? extractTextFromBlocks(lastAssistantMessage.message.content) + : ''); + + const baseUuid = randomUUID(); + const baseSessionId = this.getSessionId(); + + if (options.isError) { + const errorMessage = options.errorMessage ?? 'Unknown error'; + return { + type: 'result', + subtype: + (options.subtype as CLIResultMessageError['subtype']) ?? + 'error_during_execution', + uuid: baseUuid, + session_id: baseSessionId, + is_error: true, + duration_ms: options.durationMs, + duration_api_ms: options.apiDurationMs, + num_turns: options.numTurns, + usage, + permission_denials: [...this.permissionDenials], + error: { message: errorMessage }, + }; + } else { + const success: CLIResultMessageSuccess & { stats?: SessionMetrics } = { + type: 'result', + subtype: + (options.subtype as CLIResultMessageSuccess['subtype']) ?? 'success', + uuid: baseUuid, + session_id: baseSessionId, + is_error: false, + duration_ms: options.durationMs, + duration_api_ms: options.apiDurationMs, + num_turns: options.numTurns, + result: resultText, + usage, + permission_denials: [...this.permissionDenials], + }; + + if (options.stats) { + success.stats = options.stats; + } + + return success; + } + } + + /** + * Builds a subagent error result message. + * Helper method used by both emitSubagentErrorResult implementations. + * Note: Subagent permission denials are not included here as they are tracked + * separately and would be included in the main agent's result message. + * @param errorMessage - Error message + * @param numTurns - Number of turns + * @returns CLIResultMessageError + */ + protected buildSubagentErrorResult( + errorMessage: string, + numTurns: number, + ): CLIResultMessageError { + const usage: ExtendedUsage = { + input_tokens: 0, + output_tokens: 0, + }; + + return { + type: 'result', + subtype: 'error_during_execution', + uuid: randomUUID(), + session_id: this.getSessionId(), + is_error: true, + duration_ms: 0, + duration_api_ms: 0, + num_turns: numTurns, + usage, + permission_denials: [], + error: { message: errorMessage }, + }; + } +} + +/** + * Converts Part array to ContentBlock array. + * Handles various Part types including text, functionResponse, and other types. + * For functionResponse parts, extracts the output content. + * For other non-text parts, converts them to text representation. + * + * @param parts - Array of Part objects + * @returns Array of ContentBlock objects (primarily TextBlock) + */ +export function partsToContentBlock(parts: Part[]): ContentBlock[] { + const blocks: ContentBlock[] = []; + let currentTextBlock: TextBlock | null = null; + + for (const part of parts) { + let textContent: string | null = null; + + // Handle text parts + if ('text' in part && typeof part.text === 'string') { + textContent = part.text; + } + // Handle functionResponse parts - extract output content + else if ('functionResponse' in part && part.functionResponse) { + const output = + part.functionResponse.response?.['output'] ?? + part.functionResponse.response?.['content'] ?? + ''; + textContent = + typeof output === 'string' ? output : JSON.stringify(output); + } + // Handle other part types - convert to JSON string + else { + textContent = JSON.stringify(part); + } + + // If we have text content, add it to the current text block or create a new one + if (textContent !== null && textContent.length > 0) { + if (currentTextBlock === null) { + currentTextBlock = { + type: 'text', + text: textContent, + }; + blocks.push(currentTextBlock); + } else { + // Append to existing text block + currentTextBlock.text += textContent; + } + } + } + + // Return blocks array, or empty array if no content + return blocks; +} + +/** + * Converts Part array to string representation. + * This is a legacy function kept for backward compatibility. + * For new code, prefer using partsToContentBlock. + * + * @param parts - Array of Part objects + * @returns String representation + */ +export function partsToString(parts: Part[]): string { + return parts + .map((part) => { + if ('text' in part && typeof part.text === 'string') { + return part.text; + } + return JSON.stringify(part); + }) + .join(''); +} + +/** + * Checks if responseParts contain any functionResponse with an error. + * Helper function for extracting error messages from responseParts. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ +function checkResponsePartsForError( + responseParts: Part[] | undefined, +): string | undefined { + if (!responseParts || responseParts.length === 0) { + return undefined; + } + + for (const part of responseParts) { + if ( + 'functionResponse' in part && + part.functionResponse?.response && + typeof part.functionResponse.response === 'object' && + 'error' in part.functionResponse.response && + part.functionResponse.response['error'] + ) { + const error = part.functionResponse.response['error']; + return typeof error === 'string' ? error : String(error); + } + } + + return undefined; +} + +/** + * Extracts content from tool response. + * Uses functionResponsePartsToString to properly handle functionResponse parts, + * which correctly extracts output content from functionResponse objects rather + * than simply concatenating text or JSON.stringify. + * Also handles errors embedded in responseParts (e.g., cancelled responses). + * + * @param response - Tool call response + * @returns String content or undefined + */ +export function toolResultContent( + response: ToolCallResponseInfo, +): string | undefined { + if (response.error) { + return response.error.message; + } + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = checkResponsePartsForError(response.responseParts); + if (responsePartsError) { + return responsePartsError; + } + if ( + typeof response.resultDisplay === 'string' && + response.resultDisplay.trim().length > 0 + ) { + return response.resultDisplay; + } + if (response.responseParts && response.responseParts.length > 0) { + // Always use functionResponsePartsToString to properly handle + // functionResponse parts that contain output content + return functionResponsePartsToString(response.responseParts); + } + return undefined; +} + +/** + * Extracts text from content blocks. + * + * @param blocks - Array of content blocks + * @returns Extracted text + */ +export function extractTextFromBlocks(blocks: ContentBlock[]): string { + return blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(''); +} + +/** + * Creates an extended usage object with default values. + * + * @returns ExtendedUsage object + */ +export function createExtendedUsage(): ExtendedUsage { + return { + input_tokens: 0, + output_tokens: 0, + }; +} diff --git a/apps/airiscode-cli/src/nonInteractive/io/JsonOutputAdapter.ts b/apps/airiscode-cli/src/nonInteractive/io/JsonOutputAdapter.ts new file mode 100644 index 000000000..8ba836d74 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@airiscode/core'; +import type { CLIAssistantMessage, CLIMessage } from '../types.js'; +import { + BaseJsonOutputAdapter, + type JsonOutputAdapterInterface, + type ResultOptions, +} from './BaseJsonOutputAdapter.js'; + +/** + * JSON output adapter that collects all messages and emits them + * as a single JSON array at the end of the turn. + * Supports both main agent and subagent messages through distinct APIs. + */ +export class JsonOutputAdapter + extends BaseJsonOutputAdapter + implements JsonOutputAdapterInterface +{ + private readonly messages: CLIMessage[] = []; + + constructor(config: Config) { + super(config); + } + + /** + * Emits message to the messages array (batch mode). + * Tracks the last assistant message for efficient result text extraction. + */ + protected emitMessageImpl(message: CLIMessage): void { + this.messages.push(message); + // Track assistant messages for result generation + if ( + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'assistant' + ) { + this.updateLastAssistantMessage(message as CLIAssistantMessage); + } + } + + /** + * JSON mode does not emit stream events. + */ + protected shouldEmitStreamEvents(): boolean { + return false; + } + + finalizeAssistantMessage(): CLIAssistantMessage { + return this.finalizeAssistantMessageInternal( + this.mainAgentMessageState, + null, + ); + } + + emitResult(options: ResultOptions): void { + const resultMessage = this.buildResultMessage( + options, + this.lastAssistantMessage, + ); + this.messages.push(resultMessage); + + if (this.config.getOutputFormat() === 'text') { + if (resultMessage.is_error) { + process.stderr.write(`${resultMessage.error?.message || ''}`); + } else { + process.stdout.write(`${resultMessage.result}`); + } + } else { + // Emit the entire messages array as JSON (includes all main agent + subagent messages) + const json = JSON.stringify(this.messages); + process.stdout.write(`${json}\n`); + } + } + + emitMessage(message: CLIMessage): void { + // In JSON mode, messages are collected in the messages array + // This is called by the base class's finalizeAssistantMessageInternal + // but can also be called directly for user/tool/system messages + this.messages.push(message); + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/io/StreamJsonInputReader.ts b/apps/airiscode-cli/src/nonInteractive/io/StreamJsonInputReader.ts new file mode 100644 index 000000000..f297d7415 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/io/StreamJsonInputReader.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createInterface } from 'node:readline/promises'; +import type { Readable } from 'node:stream'; +import process from 'node:process'; +import type { + CLIControlRequest, + CLIControlResponse, + CLIMessage, + ControlCancelRequest, +} from '../types.js'; + +export type StreamJsonInputMessage = + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +export class StreamJsonParseError extends Error {} + +export class StreamJsonInputReader { + private readonly input: Readable; + + constructor(input: Readable = process.stdin) { + this.input = input; + } + + async *read(): AsyncGenerator { + const rl = createInterface({ + input: this.input, + crlfDelay: Number.POSITIVE_INFINITY, + terminal: false, + }); + + try { + for await (const rawLine of rl) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + yield this.parse(line); + } + } finally { + rl.close(); + } + } + + private parse(line: string): StreamJsonInputMessage { + try { + const parsed = JSON.parse(line) as StreamJsonInputMessage; + if (!parsed || typeof parsed !== 'object') { + throw new StreamJsonParseError('Parsed value is not an object'); + } + if (!('type' in parsed) || typeof parsed.type !== 'string') { + throw new StreamJsonParseError('Missing required "type" field'); + } + return parsed; + } catch (error) { + if (error instanceof StreamJsonParseError) { + throw error; + } + const reason = error instanceof Error ? error.message : String(error); + throw new StreamJsonParseError( + `Failed to parse stream-json line: ${reason}`, + ); + } + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/apps/airiscode-cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts new file mode 100644 index 000000000..b06e8e0df --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { + Config, + ToolCallRequestInfo, + McpToolProgressData, +} from '@airiscode/core'; +import type { + CLIAssistantMessage, + CLIMessage, + CLIPartialAssistantMessage, + ControlMessage, + StreamEvent, + TextBlock, + ThinkingBlock, + ToolUseBlock, +} from '../types.js'; +import { + BaseJsonOutputAdapter, + type MessageState, + type ResultOptions, + type JsonOutputAdapterInterface, +} from './BaseJsonOutputAdapter.js'; + +/** + * Stream JSON output adapter that emits messages immediately + * as they are completed during the streaming process. + * Supports both main agent and subagent messages through distinct APIs. + */ +export class StreamJsonOutputAdapter + extends BaseJsonOutputAdapter + implements JsonOutputAdapterInterface +{ + private mainTurnMessageStartEmitted = false; + + constructor( + config: Config, + private readonly includePartialMessages: boolean, + ) { + super(config); + } + + /** + * Emits message immediately to stdout (stream mode). + */ + protected emitMessageImpl(message: CLIMessage | ControlMessage): void { + // Track assistant messages for result generation + if ( + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'assistant' + ) { + this.updateLastAssistantMessage(message as CLIAssistantMessage); + } + + // Emit messages immediately in stream mode + process.stdout.write(`${JSON.stringify(message)}\n`); + } + + /** + * Stream mode emits stream events when includePartialMessages is enabled. + */ + protected shouldEmitStreamEvents(): boolean { + return this.includePartialMessages; + } + + override startAssistantMessage(): void { + this.mainTurnMessageStartEmitted = false; + super.startAssistantMessage(); + } + + finalizeAssistantMessage(): CLIAssistantMessage { + const message = this.finalizeAssistantMessageInternal( + this.mainAgentMessageState, + null, + ); + if (this.mainTurnMessageStartEmitted && this.includePartialMessages) { + const partial: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: null, + event: { type: 'message_stop' }, + }; + this.emitMessageImpl(partial); + } + this.mainTurnMessageStartEmitted = false; + return message; + } + + emitResult(options: ResultOptions): void { + const resultMessage = this.buildResultMessage( + options, + this.lastAssistantMessage, + ); + this.emitMessageImpl(resultMessage); + } + + emitMessage(message: CLIMessage | ControlMessage): void { + // In stream mode, emit immediately + this.emitMessageImpl(message); + } + + send(message: CLIMessage | ControlMessage): void { + this.emitMessage(message); + } + + /** + * Overrides base class hook to emit stream event when text block is created. + */ + protected override onTextBlockCreated( + state: MessageState, + index: number, + block: TextBlock, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_start', + index, + content_block: block, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when text is appended. + */ + protected override onTextAppended( + state: MessageState, + index: number, + fragment: string, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text: fragment }, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when thinking block is created. + */ + protected override onThinkingBlockCreated( + state: MessageState, + index: number, + block: ThinkingBlock, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_start', + index, + content_block: block, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when thinking is appended. + */ + protected override onThinkingAppended( + state: MessageState, + index: number, + fragment: string, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking: fragment }, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when tool_use block is created. + */ + protected override onToolUseBlockCreated( + state: MessageState, + index: number, + block: ToolUseBlock, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_start', + index, + content_block: block, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when tool_use input is set. + */ + protected override onToolUseInputSet( + state: MessageState, + index: number, + input: unknown, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_delta', + index, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(input), + }, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when block is closed. + */ + protected override onBlockClosed( + state: MessageState, + index: number, + parentToolUseId: string | null, + ): void { + if (this.includePartialMessages) { + this.emitStreamEventIfEnabled( + { + type: 'content_block_stop', + index, + }, + parentToolUseId, + ); + } + } + + /** + * Overrides base class hook to emit message_start event when message is started. + * Only emits once per turn for the main agent (guarded by mainTurnMessageStartEmitted), + * so block-type transitions inside a single turn do not produce spurious message_start events. + */ + protected override onEnsureMessageStarted( + state: MessageState, + parentToolUseId: string | null, + ): void { + if (parentToolUseId === null && !this.mainTurnMessageStartEmitted) { + this.mainTurnMessageStartEmitted = true; + this.emitStreamEventIfEnabled( + { + type: 'message_start', + message: { + id: state.messageId!, + role: 'assistant', + model: this.config.getModel(), + content: [], + }, + }, + null, + ); + } + } + + /** + * Emits a tool progress stream event when partial messages are enabled. + * This overrides the no-op in BaseJsonOutputAdapter. + */ + override emitToolProgress( + request: ToolCallRequestInfo, + progress: McpToolProgressData, + ): void { + if (!this.includePartialMessages) { + return; + } + + const partial: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: null, + event: { + type: 'tool_progress', + tool_use_id: request.callId, + content: progress, + }, + }; + this.emitMessageImpl(partial); + } + + /** + * Emits stream events when partial messages are enabled. + * This is a private method specific to StreamJsonOutputAdapter. + * @param event - Stream event to emit + * @param parentToolUseId - null for main agent, string for subagent + */ + private emitStreamEventIfEnabled( + event: StreamEvent, + parentToolUseId: string | null, + ): void { + if (!this.includePartialMessages) { + return; + } + + const partial: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: parentToolUseId, + event, + }; + this.emitMessageImpl(partial); + } +} diff --git a/apps/airiscode-cli/src/nonInteractive/session.ts b/apps/airiscode-cli/src/nonInteractive/session.ts new file mode 100644 index 000000000..837ef2f1a --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/session.ts @@ -0,0 +1,626 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Config, + ConfigInitializeOptions, +} from '@airiscode/core'; +import { createDebugLogger } from '@airiscode/core'; +import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; +import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; +import { ControlContext } from './control/ControlContext.js'; +import { ControlDispatcher } from './control/ControlDispatcher.js'; +import { ControlService } from './control/ControlService.js'; +import type { + CLIMessage, + CLIUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from './types.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from './types.js'; +import { createMinimalSettings } from '../config/settings.js'; +import { runNonInteractive } from '../nonInteractiveCli.js'; + +const debugLogger = createDebugLogger('NON_INTERACTIVE_SESSION'); + +class Session { + private userMessageQueue: CLIUserMessage[] = []; + private abortController: AbortController; + private config: Config; + private sessionId: string; + private promptIdCounter: number = 0; + private inputReader: StreamJsonInputReader; + private outputAdapter: StreamJsonOutputAdapter; + private controlContext: ControlContext | null = null; + private dispatcher: ControlDispatcher | null = null; + private controlService: ControlService | null = null; + private controlSystemEnabled: boolean | null = null; + private shutdownHandler: (() => void) | null = null; + private initialPrompt: CLIUserMessage | null = null; + private processingPromise: Promise | null = null; + private isShuttingDown: boolean = false; + private configInitialized: boolean = false; + + // Single initialization promise that resolves when session is ready for user messages. + // Created lazily once initialization actually starts. + private initializationPromise: Promise | null = null; + private initializationResolve: (() => void) | null = null; + private initializationReject: ((error: Error) => void) | null = null; + + constructor(config: Config, initialPrompt?: CLIUserMessage) { + this.config = config; + this.sessionId = config.getSessionId(); + this.abortController = new AbortController(); + this.initialPrompt = initialPrompt ?? null; + + this.inputReader = new StreamJsonInputReader(); + this.outputAdapter = new StreamJsonOutputAdapter( + config, + config.getIncludePartialMessages(), + ); + + this.setupSignalHandlers(); + } + + private ensureInitializationPromise(): void { + if (this.initializationPromise) { + return; + } + this.initializationPromise = new Promise((resolve, reject) => { + this.initializationResolve = () => { + resolve(); + this.initializationResolve = null; + this.initializationReject = null; + }; + this.initializationReject = (error: Error) => { + reject(error); + this.initializationResolve = null; + this.initializationReject = null; + }; + }); + } + + private getNextPromptId(): string { + this.promptIdCounter++; + return `${this.sessionId}########${this.promptIdCounter}`; + } + + private async ensureConfigInitialized( + options?: ConfigInitializeOptions, + ): Promise { + if (this.configInitialized) { + return; + } + + debugLogger.debug('[Session] Initializing config'); + + try { + await this.config.initialize(options); + this.configInitialized = true; + } catch (error) { + debugLogger.error('[Session] Failed to initialize config:', error); + throw error; + } + } + + /** + * Mark initialization as complete + */ + private completeInitialization(): void { + if (this.initializationResolve) { + debugLogger.debug('[Session] Initialization complete'); + this.initializationResolve(); + this.initializationResolve = null; + this.initializationReject = null; + } + } + + /** + * Mark initialization as failed + */ + private failInitialization(error: Error): void { + if (this.initializationReject) { + debugLogger.error('[Session] Initialization failed:', error); + this.initializationReject(error); + this.initializationResolve = null; + this.initializationReject = null; + } + } + + /** + * Wait for session to be ready for user messages + */ + private async waitForInitialization(): Promise { + if (!this.initializationPromise) { + return; + } + await this.initializationPromise; + } + + private ensureControlSystem(): void { + if (this.controlContext && this.dispatcher && this.controlService) { + return; + } + this.controlContext = new ControlContext({ + config: this.config, + streamJson: this.outputAdapter, + sessionId: this.sessionId, + abortSignal: this.abortController.signal, + permissionMode: this.config.getApprovalMode(), + onInterrupt: () => this.handleInterrupt(), + }); + this.dispatcher = new ControlDispatcher(this.controlContext); + this.controlService = new ControlService( + this.controlContext, + this.dispatcher, + ); + } + + private getDispatcher(): ControlDispatcher | null { + if (this.controlSystemEnabled !== true) { + return null; + } + if (!this.dispatcher) { + this.ensureControlSystem(); + } + return this.dispatcher; + } + + /** + * Handle the first message to determine session mode (SDK vs direct). + * This is synchronous from the message loop's perspective - it starts + * async work but does not return a promise that the loop awaits. + * + * The initialization completes asynchronously and resolves initializationPromise + * when ready for user messages. + */ + private handleFirstMessage( + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest, + ): void { + if (isControlRequest(message)) { + const request = message as CLIControlRequest; + this.controlSystemEnabled = true; + this.ensureControlSystem(); + + if (request.request.subtype === 'initialize') { + // Start SDK mode initialization (fire-and-forget from loop perspective) + void this.initializeSdkMode(request); + return; + } + + debugLogger.debug( + '[Session] Ignoring non-initialize control request during initialization', + ); + return; + } + + if (isCLIUserMessage(message)) { + this.controlSystemEnabled = false; + // Start direct mode initialization (fire-and-forget from loop perspective) + void this.initializeDirectMode(message as CLIUserMessage); + return; + } + + this.controlSystemEnabled = false; + } + + /** + * SDK mode initialization flow + * Dispatches initialize request and initializes config with MCP support + */ + private async initializeSdkMode(request: CLIControlRequest): Promise { + this.ensureInitializationPromise(); + try { + // Dispatch the initialize request first + // This registers SDK MCP servers in the control context + await this.dispatcher?.dispatch(request); + + // Get sendSdkMcpMessage callback from SdkMcpController + // This callback is used by McpClientManager to send MCP messages + // from CLI MCP clients to SDK MCP servers via the control plane + const sendSdkMcpMessage = + this.dispatcher?.sdkMcpController.createSendSdkMcpMessage(); + + // Initialize config with SDK MCP message support + await this.ensureConfigInitialized({ sendSdkMcpMessage }); + + // Initialization complete! + this.completeInitialization(); + } catch (error) { + debugLogger.error('[Session] SDK mode initialization failed:', error); + this.failInitialization( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + + /** + * Direct mode initialization flow + * Initializes config and enqueues the first user message + */ + private async initializeDirectMode( + userMessage: CLIUserMessage, + ): Promise { + this.ensureInitializationPromise(); + try { + // Initialize config + await this.ensureConfigInitialized(); + + // Initialization complete! + this.completeInitialization(); + + // Enqueue the first user message for processing + this.enqueueUserMessage(userMessage); + } catch (error) { + debugLogger.error('[Session] Direct mode initialization failed:', error); + this.failInitialization( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + + /** + * Handle control request asynchronously (fire-and-forget from main loop). + * Errors are handled internally and responses sent by dispatcher. + */ + private handleControlRequestAsync(request: CLIControlRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + debugLogger.warn('[Session] Control system not enabled'); + return; + } + + // Fire-and-forget: dispatch runs concurrently + // The dispatcher's pendingIncomingRequests tracks completion + void dispatcher.dispatch(request).catch((error) => { + debugLogger.error('[Session] Control request dispatch error:', error); + // Error response is already sent by dispatcher.dispatch() + }); + } + + /** + * Handle control response - MUST be synchronous + * This resolves pending outgoing requests, breaking the deadlock cycle. + */ + private handleControlResponse(response: CLIControlResponse): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleControlResponse(response); + } + + private handleControlCancel(cancelRequest: ControlCancelRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleCancel(cancelRequest.request_id); + } + + private async processUserMessage(userMessage: CLIUserMessage): Promise { + const input = extractUserMessageText(userMessage); + if (!input) { + debugLogger.debug('[Session] No text content in user message'); + return; + } + + // Wait for initialization to complete before processing user messages + await this.waitForInitialization(); + + const promptId = this.getNextPromptId(); + + try { + await runNonInteractive( + this.config, + createMinimalSettings(), + input, + promptId, + { + abortController: this.abortController, + adapter: this.outputAdapter, + controlService: this.controlService ?? undefined, + }, + ); + } catch (error) { + debugLogger.error('[Session] Query execution error:', error); + } + } + + private async processUserMessageQueue(): Promise { + if (this.isShuttingDown || this.abortController.signal.aborted) { + return; + } + + while ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + const userMessage = this.userMessageQueue.shift()!; + try { + await this.processUserMessage(userMessage); + } catch (error) { + debugLogger.error('[Session] Error processing user message:', error); + this.emitErrorResult(error); + } + } + } + + private enqueueUserMessage(userMessage: CLIUserMessage): void { + this.userMessageQueue.push(userMessage); + this.ensureProcessingStarted(); + } + + private ensureProcessingStarted(): void { + if (this.processingPromise) { + return; + } + + this.processingPromise = this.processUserMessageQueue().finally(() => { + this.processingPromise = null; + if ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + this.ensureProcessingStarted(); + } + }); + } + + private emitErrorResult( + error: unknown, + numTurns: number = 0, + durationMs: number = 0, + apiDurationMs: number = 0, + ): void { + const message = error instanceof Error ? error.message : String(error); + this.outputAdapter.emitResult({ + isError: true, + errorMessage: message, + durationMs, + apiDurationMs, + numTurns, + usage: undefined, + }); + } + + private handleInterrupt(): void { + debugLogger.info('[Session] Interrupt requested'); + this.abortController.abort(); + // Do not create a new AbortController to prevent listener leaks. + // Subsequent queries will check signal.aborted and fail immediately. + } + + private setupSignalHandlers(): void { + this.shutdownHandler = () => { + debugLogger.info('[Session] Shutdown signal received'); + this.isShuttingDown = true; + this.abortController.abort(); + }; + + process.on('SIGINT', this.shutdownHandler); + process.on('SIGTERM', this.shutdownHandler); + } + + /** + * Wait for all pending work to complete before shutdown + */ + private async waitForAllPendingWork(): Promise { + // 1. Wait for initialization to complete (or fail) + try { + await this.waitForInitialization(); + } catch (error) { + debugLogger.error( + '[Session] Initialization error during shutdown:', + error, + ); + } + + // 2. Wait for all control request handlers using dispatcher's tracking + if (this.dispatcher) { + const pendingCount = this.dispatcher.getPendingIncomingRequestCount(); + if (pendingCount > 0) { + debugLogger.debug( + `[Session] Waiting for ${pendingCount} pending control request handlers`, + ); + } + await this.dispatcher.waitForPendingIncomingRequests(); + } + + // 3. Wait for user message processing queue + while (this.processingPromise) { + debugLogger.debug('[Session] Waiting for user message processing'); + try { + await this.processingPromise; + } catch (error) { + debugLogger.error('[Session] Error in user message processing:', error); + } + } + } + + private async shutdown(): Promise { + debugLogger.debug('[Session] Shutting down'); + + this.isShuttingDown = true; + + // Wait for all pending work + await this.waitForAllPendingWork(); + + this.dispatcher?.shutdown(); + this.cleanupSignalHandlers(); + } + + private cleanupSignalHandlers(): void { + if (this.shutdownHandler) { + process.removeListener('SIGINT', this.shutdownHandler); + process.removeListener('SIGTERM', this.shutdownHandler); + this.shutdownHandler = null; + } + } + + /** + * Main message processing loop + * + * CRITICAL: This loop must NEVER await handlers that might need to + * send control requests and wait for responses. Such handlers must + * be started in fire-and-forget mode, allowing the loop to continue + * reading responses that resolve pending requests. + * + * Message handling order: + * 1. control_response - FIRST, synchronously resolves pending requests + * 2. First message - determines mode, starts async initialization + * 3. control_request - fire-and-forget, tracked by dispatcher + * 4. control_cancel - synchronous + * 5. user_message - enqueued for processing + */ + async run(): Promise { + try { + debugLogger.info('[Session] Starting session', this.sessionId); + + // Handle initial prompt if provided (fire-and-forget) + if (this.initialPrompt !== null) { + this.handleFirstMessage(this.initialPrompt); + } + + try { + for await (const message of this.inputReader.read()) { + if (this.abortController.signal.aborted) { + break; + } + + // ============================================================ + // CRITICAL: Handle control_response FIRST and SYNCHRONOUSLY + // This resolves pending outgoing requests, breaking deadlock. + // ============================================================ + if (isControlResponse(message)) { + this.handleControlResponse(message as CLIControlResponse); + continue; + } + + // Handle first message to determine session mode + if (this.controlSystemEnabled === null) { + this.handleFirstMessage(message); + continue; + } + + // ============================================================ + // CRITICAL: Handle control_request in FIRE-AND-FORGET mode + // DON'T await - let handler run concurrently while loop continues + // Dispatcher's pendingIncomingRequests tracks completion + // ============================================================ + if (isControlRequest(message)) { + this.handleControlRequestAsync(message as CLIControlRequest); + } else if (isControlCancel(message)) { + // Cancel is synchronous - OK to handle inline + this.handleControlCancel(message as ControlCancelRequest); + } else if (isCLIUserMessage(message)) { + // User messages are enqueued, processing runs separately + this.enqueueUserMessage(message as CLIUserMessage); + } else if ( + !isCLIAssistantMessage(message) && + !isCLISystemMessage(message) && + !isCLIResultMessage(message) && + !isCLIPartialAssistantMessage(message) + ) { + debugLogger.warn( + '[Session] Unknown message type:', + JSON.stringify(message, null, 2), + ); + } + + if (this.isShuttingDown) { + break; + } + } + } catch (streamError) { + debugLogger.error('[Session] Stream reading error:', streamError); + throw streamError; + } + + // Stdin closed - mark input as closed in dispatcher + // This will reject all current pending outgoing requests AND any future requests + // that might be registered by async message handlers still running + if (this.dispatcher) { + this.dispatcher.markInputClosed(); + } + + // Wait for all pending work before shutdown + await this.waitForAllPendingWork(); + await this.shutdown(); + } catch (error) { + debugLogger.error('[Session] Error:', error); + await this.shutdown(); + throw error; + } finally { + this.cleanupSignalHandlers(); + } + } +} + +function extractUserMessageText(message: CLIUserMessage): string | null { + const content = message.message.content; + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + const parts = content + .map((block) => { + if (!block || typeof block !== 'object') { + return ''; + } + if ('type' in block && block.type === 'text' && 'text' in block) { + return typeof block.text === 'string' ? block.text : ''; + } + return JSON.stringify(block); + }) + .filter((part) => part.length > 0); + + return parts.length > 0 ? parts.join('\n') : null; + } + + return null; +} + +export async function runNonInteractiveStreamJson( + config: Config, + input: string, +): Promise { + let initialPrompt: CLIUserMessage | undefined = undefined; + if (input && input.trim().length > 0) { + const sessionId = config.getSessionId(); + initialPrompt = { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: input.trim(), + }, + parent_tool_use_id: null, + }; + } + + const manager = new Session(config, initialPrompt); + await manager.run(); +} diff --git a/apps/airiscode-cli/src/nonInteractive/types.ts b/apps/airiscode-cli/src/nonInteractive/types.ts new file mode 100644 index 000000000..abaaae898 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractive/types.ts @@ -0,0 +1,579 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + SubagentConfig, + McpToolProgressData, +} from '@airiscode/core'; + +/** + * Annotation for attaching metadata to content blocks + */ +export interface Annotation { + type: string; + value: string; +} + +/** + * Usage information types + */ +export interface Usage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + total_tokens?: number; +} + +export interface ExtendedUsage extends Usage { + server_tool_use?: { + web_search_requests: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + contextWindow: number; +} + +/** + * Permission denial information + */ +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: unknown; +} + +/** + * Content block types from Anthropic SDK + */ +export interface TextBlock { + type: 'text'; + text: string; + annotations?: Annotation[]; +} + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature?: string; + annotations?: Annotation[]; +} + +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + annotations?: Annotation[]; +} + +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + annotations?: Annotation[]; +} + +export type ContentBlock = + | TextBlock + | ThinkingBlock + | ToolUseBlock + | ToolResultBlock; + +/** + * Anthropic SDK Message types + */ +export interface APIUserMessage { + role: 'user'; + content: string | ContentBlock[]; +} + +export interface APIAssistantMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: ContentBlock[]; + stop_reason?: string | null; + usage: Usage; +} + +/** + * CLI Message wrapper types + */ +export interface CLIUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; + options?: Record; +} + +export interface CLIAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface CLISystemMessage { + type: 'system'; + subtype: string; + uuid: string; + session_id: string; + data?: unknown; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permission_mode?: string; + slash_commands?: string[]; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface CLIResultMessageSuccess { + type: 'result'; + subtype: 'success'; + uuid: string; + session_id: string; + is_error: false; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + result: string; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + [key: string]: unknown; +} + +export interface CLIResultMessageError { + type: 'result'; + subtype: 'error_max_turns' | 'error_during_execution'; + uuid: string; + session_id: string; + is_error: true; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + error?: { + type?: string; + message: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError; + +/** + * Stream event types for real-time message updates + */ +export interface MessageStartStreamEvent { + type: 'message_start'; + message: { + id: string; + role: 'assistant'; + model: string; + content: []; + }; +} + +export interface ContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: ContentBlock; +} + +export type ContentBlockDelta = + | { + type: 'text_delta'; + text: string; + } + | { + type: 'thinking_delta'; + thinking: string; + } + | { + type: 'input_json_delta'; + partial_json: string; + }; + +export interface ContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: ContentBlockDelta; +} + +export interface ContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export interface MessageStopStreamEvent { + type: 'message_stop'; +} + +export interface ToolProgressStreamEvent { + type: 'tool_progress'; + tool_use_id: string; + content: McpToolProgressData; +} + +export type StreamEvent = + | MessageStartStreamEvent + | ContentBlockStartEvent + | ContentBlockDeltaEvent + | ContentBlockStopEvent + | MessageStopStreamEvent + | ToolProgressStreamEvent; + +export interface CLIPartialAssistantMessage { + type: 'stream_event'; + uuid: string; + session_id: string; + event: StreamEvent; + parent_tool_use_id: string | null; +} + +export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo'; + +/** + * Permission suggestion for tool use requests + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: unknown; +} + +/** + * Hook callback placeholder for future implementation + */ +export interface HookRegistration { + event: string; + callback_id: string; +} + +/** + * Hook callback result placeholder for future implementation + */ +export interface HookCallbackResult { + shouldSkip?: boolean; + shouldInterrupt?: boolean; + suppressOutput?: boolean; + message?: string; +} + +export interface CLIControlInterruptRequest { + subtype: 'interrupt'; +} + +export interface CLIControlPermissionRequest { + subtype: 'can_use_tool'; + tool_name: string; + tool_use_id: string; + input: unknown; + permission_suggestions: PermissionSuggestion[] | null; + blocked_path: string | null; +} + +/** + * Wire format for SDK MCP server config in initialization request. + * The actual Server instance stays in the SDK process. + */ +export interface SDKMcpServerConfig { + type: 'sdk'; + name: string; +} + +/** + * Wire format for external MCP server config in initialization request. + * Represents stdio/SSE/HTTP/TCP transports that must run in the CLI process. + */ +export interface CLIMcpServerConfig { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + tcp?: string; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + oauth?: { + enabled?: boolean; + clientId?: string; + clientSecret?: string; + authorizationUrl?: string; + tokenUrl?: string; + scopes?: string[]; + audiences?: string[]; + redirectUri?: string; + tokenParamName?: string; + registrationUrl?: string; + }; + authProviderType?: + | 'dynamic_discovery' + | 'google_credentials' + | 'service_account_impersonation'; + targetAudience?: string; + targetServiceAccount?: string; +} + +export interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + /** + * SDK MCP servers config + * These are MCP servers running in the SDK process, connected via control plane. + * External MCP servers are configured separately in settings, not via initialization. + */ + sdkMcpServers?: Record>; + /** + * External MCP servers that the SDK wants the CLI to manage. + * These run outside the SDK process and require CLI-side transport setup. + */ + mcpServers?: Record; + agents?: SubagentConfig[]; +} + +export interface CLIControlSetPermissionModeRequest { + subtype: 'set_permission_mode'; + mode: PermissionMode; +} + +export interface CLIHookCallbackRequest { + subtype: 'hook_callback'; + callback_id: string; + input: unknown; + tool_use_id: string | null; +} + +export interface CLIControlMcpMessageRequest { + subtype: 'mcp_message'; + server_name: string; + message: { + jsonrpc?: string; + method: string; + params?: Record; + id?: string | number | null; + }; +} + +export interface CLIControlSetModelRequest { + subtype: 'set_model'; + model: string; +} + +export interface CLIControlMcpStatusRequest { + subtype: 'mcp_server_status'; +} + +export interface CLIControlSupportedCommandsRequest { + subtype: 'supported_commands'; +} + +export type ControlRequestPayload = + | CLIControlInterruptRequest + | CLIControlPermissionRequest + | CLIControlInitializeRequest + | CLIControlSetPermissionModeRequest + | CLIHookCallbackRequest + | CLIControlMcpMessageRequest + | CLIControlSetModelRequest + | CLIControlMcpStatusRequest + | CLIControlSupportedCommandsRequest; + +export interface CLIControlRequest { + type: 'control_request'; + request_id: string; + request: ControlRequestPayload; +} + +/** + * Permission approval result + */ +export interface PermissionApproval { + allowed: boolean; + reason?: string; + modifiedInput?: unknown; +} + +export interface ControlResponse { + subtype: 'success'; + request_id: string; + response: unknown; +} + +export interface ControlErrorResponse { + subtype: 'error'; + request_id: string; + error: string | { message: string; [key: string]: unknown }; +} + +export interface CLIControlResponse { + type: 'control_response'; + response: ControlResponse | ControlErrorResponse; +} + +export interface ControlCancelRequest { + type: 'control_cancel_request'; + request_id?: string; +} + +export type ControlMessage = + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +/** + * Union of all CLI message types + */ +export type CLIMessage = + | CLIUserMessage + | CLIAssistantMessage + | CLISystemMessage + | CLIResultMessage + | CLIPartialAssistantMessage; + +/** + * Type guard functions for message discrimination + */ + +export function isCLIUserMessage(msg: any): msg is CLIUserMessage { + return ( + msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg + ); +} + +export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'assistant' && + 'uuid' in msg && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isCLISystemMessage(msg: any): msg is CLISystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIResultMessage(msg: any): msg is CLIResultMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'result' && + 'subtype' in msg && + 'duration_ms' in msg && + 'is_error' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIPartialAssistantMessage( + msg: any, +): msg is CLIPartialAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'stream_event' && + 'uuid' in msg && + 'session_id' in msg && + 'event' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isControlRequest(msg: any): msg is CLIControlRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_request' && + 'request_id' in msg && + 'request' in msg + ); +} + +export function isControlResponse(msg: any): msg is CLIControlResponse { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_response' && + 'response' in msg + ); +} + +export function isControlCancel(msg: any): msg is ControlCancelRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_cancel_request' && + 'request_id' in msg + ); +} + +/** + * Content block type guards + */ + +export function isTextBlock(block: any): block is TextBlock { + return block && typeof block === 'object' && block.type === 'text'; +} + +export function isThinkingBlock(block: any): block is ThinkingBlock { + return block && typeof block === 'object' && block.type === 'thinking'; +} + +export function isToolUseBlock(block: any): block is ToolUseBlock { + return block && typeof block === 'object' && block.type === 'tool_use'; +} + +export function isToolResultBlock(block: any): block is ToolResultBlock { + return block && typeof block === 'object' && block.type === 'tool_result'; +} diff --git a/apps/airiscode-cli/src/nonInteractiveCli.ts b/apps/airiscode-cli/src/nonInteractiveCli.ts new file mode 100644 index 000000000..f8ebe03ea --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractiveCli.ts @@ -0,0 +1,564 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config, ToolCallRequestInfo } from '@airiscode/core'; +import { isSlashCommand } from './ui/utils/commandUtils.js'; +import type { LoadedSettings } from './config/settings.js'; +import { + executeToolCall, + shutdownTelemetry, + isTelemetrySdkInitialized, + GeminiEventType, + FatalInputError, + promptIdContext, + OutputFormat, + InputFormat, + uiTelemetryService, + parseAndFormatApiError, + createDebugLogger, + SendMessageType, +} from '@airiscode/core'; +import type { Content, Part, PartListUnion } from '@airiscode/core'; +import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; +import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js'; +import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js'; +import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js'; +import type { ControlService } from './nonInteractive/control/ControlService.js'; + +import { handleSlashCommand } from './nonInteractiveCliCommands.js'; +import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; +import { + handleError, + handleToolError, + handleCancellationError, + handleMaxTurnsExceededError, +} from './utils/errors.js'; + +const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); +import { + normalizePartList, + extractPartsFromUserMessage, + buildSystemMessage, + createToolProgressHandler, + createAgentToolProgressHandler, + computeUsageFromMetrics, +} from './utils/nonInteractiveHelpers.js'; + +/** + * Emits a final message for slash command results. + * Note: systemMessage should already be emitted before calling this function. + */ +async function emitNonInteractiveFinalMessage(params: { + message: string; + isError: boolean; + adapter: JsonOutputAdapterInterface; + config: Config; + startTimeMs: number; +}): Promise { + const { message, isError, adapter, config } = params; + + // JSON output mode: emit assistant message and result + // (systemMessage should already be emitted by caller) + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: message, + } as unknown as Parameters[0]); + adapter.finalizeAssistantMessage(); + + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + const outputFormat = config.getOutputFormat(); + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + + adapter.emitResult({ + isError, + durationMs: Date.now() - params.startTimeMs, + apiDurationMs: 0, + numTurns: 0, + errorMessage: isError ? message : undefined, + usage, + stats, + summary: message, + }); +} + +/** + * Provides optional overrides for `runNonInteractive` execution. + * + * @param abortController - Optional abort controller for cancellation. + * @param adapter - Optional JSON output adapter for structured output formats. + * @param userMessage - Optional CLI user message payload for preformatted input. + * @param controlService - Optional control service for future permission handling. + */ +export interface RunNonInteractiveOptions { + abortController?: AbortController; + adapter?: JsonOutputAdapterInterface; + userMessage?: CLIUserMessage; + controlService?: ControlService; +} + +/** + * Executes the non-interactive CLI flow for a single request. + */ +export async function runNonInteractive( + config: Config, + settings: LoadedSettings, + input: string, + prompt_id: string, + options: RunNonInteractiveOptions = {}, +): Promise { + return promptIdContext.run(prompt_id, async () => { + // Create output adapter based on format + let adapter: JsonOutputAdapterInterface; + const outputFormat = config.getOutputFormat(); + + if (options.adapter) { + adapter = options.adapter; + } else if (outputFormat === OutputFormat.STREAM_JSON) { + adapter = new StreamJsonOutputAdapter( + config, + config.getIncludePartialMessages(), + ); + } else { + adapter = new JsonOutputAdapter(config); + } + + // Get readonly values once at the start + const sessionId = config.getSessionId(); + const permissionMode = config.getApprovalMode() as PermissionMode; + + let turnCount = 0; + let totalApiDurationMs = 0; + const startTime = Date.now(); + + const stdoutErrorHandler = (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') { + process.stdout.removeListener('error', stdoutErrorHandler); + process.exit(0); + } + }; + + const geminiClient = config.getGeminiClient(); + const abortController = options.abortController ?? new AbortController(); + + // Setup signal handlers for graceful shutdown + const shutdownHandler = () => { + debugLogger.debug('[runNonInteractive] Shutdown signal received'); + abortController.abort(); + }; + + try { + process.stdout.on('error', stdoutErrorHandler); + + process.on('SIGINT', shutdownHandler); + process.on('SIGTERM', shutdownHandler); + + // Emit systemMessage first (always the first message in JSON mode) + const systemMessage = await buildSystemMessage( + config, + sessionId, + permissionMode, + ); + adapter.emitMessage(systemMessage); + + let initialPartList: PartListUnion | null = extractPartsFromUserMessage( + options.userMessage, + ); + + if (!initialPartList) { + let slashHandled = false; + if (isSlashCommand(input)) { + const slashCommandResult = await handleSlashCommand( + input, + abortController, + config, + settings, + ); + switch (slashCommandResult.type) { + case 'submit_prompt': + // A slash command can replace the prompt entirely; fall back to @-command processing otherwise. + initialPartList = slashCommandResult.content; + slashHandled = true; + break; + case 'message': { + // systemMessage already emitted above + await emitNonInteractiveFinalMessage({ + message: slashCommandResult.content, + isError: slashCommandResult.messageType === 'error', + adapter, + config, + startTimeMs: startTime, + }); + return; + } + case 'stream_messages': + throw new FatalInputError( + 'Stream messages mode is not supported in non-interactive CLI', + ); + case 'unsupported': { + await emitNonInteractiveFinalMessage({ + message: slashCommandResult.reason, + isError: true, + adapter, + config, + startTimeMs: startTime, + }); + return; + } + case 'no_command': + break; + default: { + const _exhaustive: never = slashCommandResult; + throw new FatalInputError( + `Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`, + ); + } + } + } + + if (!slashHandled) { + const { processedQuery, shouldProceed } = await handleAtCommand({ + query: input, + config, + onDebugMessage: () => {}, + messageId: Date.now(), + signal: abortController.signal, + }); + + if (!shouldProceed || !processedQuery) { + // An error occurred during @include processing (e.g., file not found). + // The error message is already logged by handleAtCommand. + throw new FatalInputError( + 'Exiting due to an error processing the @ command.', + ); + } + initialPartList = processedQuery as PartListUnion; + } + } + + if (!initialPartList) { + initialPartList = [{ text: input }]; + } + + const initialParts = normalizePartList(initialPartList); + let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; + + let isFirstTurn = true; + while (true) { + turnCount++; + if ( + config.getMaxSessionTurns() >= 0 && + turnCount > config.getMaxSessionTurns() + ) { + handleMaxTurnsExceededError(config); + } + + const toolCallRequests: ToolCallRequestInfo[] = []; + const apiStartTime = Date.now(); + const responseStream = geminiClient.sendMessageStream( + currentMessages[0]?.parts || [], + abortController.signal, + prompt_id, + { + type: isFirstTurn + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, + ); + isFirstTurn = false; + + // Start assistant message for this turn + adapter.startAssistantMessage(); + + for await (const event of responseStream) { + if (abortController.signal.aborted) { + handleCancellationError(config); + } + // Use adapter for all event processing + adapter.processEvent(event); + if (event.type === GeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + } + if ( + outputFormat === OutputFormat.TEXT && + event.type === GeminiEventType.Error + ) { + const errorText = parseAndFormatApiError( + event.value.error, + config.getContentGeneratorConfig()?.authType, + ); + process.stderr.write(`${errorText}\n`); + // Throw error to exit with non-zero code + throw new Error(errorText); + } + } + + // Finalize assistant message + adapter.finalizeAssistantMessage(); + totalApiDurationMs += Date.now() - apiStartTime; + + if (toolCallRequests.length > 0) { + const toolResponseParts: Part[] = []; + + for (const requestInfo of toolCallRequests) { + const finalRequestInfo = requestInfo; + + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + const toolCallUpdateCallback = + inputFormat === InputFormat.STREAM_JSON && options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; + + // Build outputUpdateHandler for this tool call. + // Agent tool has its own complex handler (subagent messages). + // All other tools with canUpdateOutput=true (e.g., MCP tools) + // get a generic handler that emits progress via the adapter. + const isAgentTool = finalRequestInfo.name === 'agent'; + const { handler: outputUpdateHandler } = isAgentTool + ? createAgentToolProgressHandler( + config, + finalRequestInfo.callId, + adapter, + ) + : createToolProgressHandler(finalRequestInfo, adapter); + + const toolResponse = await executeToolCall( + config, + finalRequestInfo, + abortController.signal, + { + outputUpdateHandler, + ...(toolCallUpdateCallback && { + onToolCallsUpdate: toolCallUpdateCallback, + }), + }, + ); + + // Note: In JSON mode, subagent messages are automatically added to the main + // adapter's messages array and will be output together on emitResult() + + if (toolResponse.error) { + // In JSON/STREAM_JSON mode, tool errors are tolerated and formatted + // as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode + // from config and allow the session to continue so the LLM can decide what to do next. + // In text mode, we still log the error. + handleToolError( + finalRequestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); + } + + adapter.emitToolResult(finalRequestInfo, toolResponse); + + if (toolResponse.responseParts) { + toolResponseParts.push(...toolResponse.responseParts); + } + } + currentMessages = [{ role: 'user', parts: toolResponseParts }]; + } else { + // No more tool calls — check if cron jobs are keeping us alive + const scheduler = !config.isCronEnabled() + ? null + : config.getCronScheduler(); + if (scheduler && scheduler.size > 0) { + // Start the scheduler and wait for all jobs to complete or be deleted. + // Each fired prompt is processed as a new turn through the same loop. + await new Promise((resolve) => { + const cronQueue: string[] = []; + let processing = false; + + const checkDone = () => { + if (scheduler.size === 0 && !processing) { + scheduler.stop(); + resolve(); + } + }; + + const drainQueue = async () => { + if (processing) return; + processing = true; + try { + while (cronQueue.length > 0) { + const cronPrompt = cronQueue.shift()!; + turnCount++; + let cronMessages: Content[] = [ + { role: 'user', parts: [{ text: cronPrompt }] }, + ]; + let cronIsFirstTurn = true; + + while (true) { + const cronToolCallRequests: ToolCallRequestInfo[] = []; + const cronApiStartTime = Date.now(); + const cronStream = geminiClient.sendMessageStream( + cronMessages[0]?.parts || [], + abortController.signal, + prompt_id, + { + type: cronIsFirstTurn + ? SendMessageType.Cron + : SendMessageType.ToolResult, + }, + ); + cronIsFirstTurn = false; + + adapter.startAssistantMessage(); + + for await (const event of cronStream) { + if (abortController.signal.aborted) { + const summary = scheduler.getExitSummary(); + scheduler.stop(); + if (summary) { + process.stderr.write(summary + '\n'); + } + resolve(); + return; + } + adapter.processEvent(event); + if (event.type === GeminiEventType.ToolCallRequest) { + cronToolCallRequests.push(event.value); + } + } + + adapter.finalizeAssistantMessage(); + totalApiDurationMs += Date.now() - cronApiStartTime; + + if (cronToolCallRequests.length > 0) { + const cronToolResponseParts: Part[] = []; + + for (const requestInfo of cronToolCallRequests) { + const isAgentTool = requestInfo.name === 'agent'; + const { handler: outputUpdateHandler } = isAgentTool + ? createAgentToolProgressHandler( + config, + requestInfo.callId, + adapter, + ) + : createToolProgressHandler(requestInfo, adapter); + + const toolResponse = await executeToolCall( + config, + requestInfo, + abortController.signal, + { outputUpdateHandler }, + ); + + if (toolResponse.error) { + handleToolError( + requestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); + } + + adapter.emitToolResult(requestInfo, toolResponse); + + if (toolResponse.responseParts) { + cronToolResponseParts.push( + ...toolResponse.responseParts, + ); + } + } + cronMessages = [ + { role: 'user', parts: cronToolResponseParts }, + ]; + } else { + break; + } + } + } + } catch (error) { + debugLogger.error('Error processing cron prompt:', error); + } finally { + processing = false; + checkDone(); + } + }; + + scheduler.start((job: { prompt: string }) => { + cronQueue.push(job.prompt); + void drainQueue(); + }); + + // Also check immediately in case jobs were already deleted + checkDone(); + }); + } + + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + // Get stats for JSON format output + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + adapter.emitResult({ + isError: false, + durationMs: Date.now() - startTime, + apiDurationMs: totalApiDurationMs, + numTurns: turnCount, + usage, + stats, + }); + return; + } + } + } catch (error) { + // Ensure message_start / message_stop (and content_block events) are + // properly paired even when an error aborts the turn mid-stream. + // The call is safe when no message was started (throws → caught) or + // when already finalized (idempotent guard inside the adapter). + try { + adapter.finalizeAssistantMessage(); + } catch { + // Expected when no message was started or already finalized + } + + // For JSON and STREAM_JSON modes, compute usage from metrics + const message = error instanceof Error ? error.message : String(error); + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + // Get stats for JSON format output + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + adapter.emitResult({ + isError: true, + durationMs: Date.now() - startTime, + apiDurationMs: totalApiDurationMs, + numTurns: turnCount, + errorMessage: message, + usage, + stats, + }); + handleError(error, config); + } finally { + process.stdout.removeListener('error', stdoutErrorHandler); + // Cleanup signal handlers + process.removeListener('SIGINT', shutdownHandler); + process.removeListener('SIGTERM', shutdownHandler); + if (isTelemetrySdkInitialized()) { + await shutdownTelemetry(); + } + } + }); +} diff --git a/apps/airiscode-cli/src/nonInteractiveCliCommands.ts b/apps/airiscode-cli/src/nonInteractiveCliCommands.ts new file mode 100644 index 000000000..4215cec92 --- /dev/null +++ b/apps/airiscode-cli/src/nonInteractiveCliCommands.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { PartListUnion } from '@airiscode/core'; +import { parseSlashCommand } from './utils/commands.js'; +import { + Logger, + uiTelemetryService, + type Config, + createDebugLogger, +} from '@airiscode/core'; +import { CommandService } from './services/CommandService.js'; +import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; +import { BundledSkillLoader } from './services/BundledSkillLoader.js'; +import { FileCommandLoader } from './services/FileCommandLoader.js'; +import { + CommandKind, + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, +} from './ui/commands/types.js'; +import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; +import type { LoadedSettings } from './config/settings.js'; +import type { SessionStatsState } from './ui/contexts/SessionContext.js'; +import { t } from './i18n/index.js'; + +const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS'); + +/** + * Built-in commands that are allowed in non-interactive modes (CLI and ACP). + * Only safe, read-only commands that don't require interactive UI. + * + * These commands are: + * - init: Initialize project configuration + * - summary: Generate session summary + * - compress: Compress conversation history + */ +export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [ + 'init', + 'summary', + 'compress', + 'btw', + 'bug', +] as const; + +/** + * Result of handling a slash command in non-interactive mode. + * + * Supported types: + * - 'submit_prompt': Submits content to the model (supports all modes) + * - 'message': Returns a single message (supports non-interactive JSON/text only) + * - 'stream_messages': Streams multiple messages (supports ACP only) + * - 'unsupported': Command cannot be executed in this mode + * - 'no_command': No command was found or executed + */ +export type NonInteractiveSlashCommandResult = + | { + type: 'submit_prompt'; + content: PartListUnion; + } + | { + type: 'message'; + messageType: 'info' | 'error'; + content: string; + } + | { + type: 'stream_messages'; + messages: AsyncGenerator< + { messageType: 'info' | 'error'; content: string }, + void, + unknown + >; + } + | { + type: 'unsupported'; + reason: string; + originalType: string; + } + | { + type: 'no_command'; + }; + +/** + * Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult. + * + * Only the following result types are supported in non-interactive mode: + * - submit_prompt: Submits content to the model (all modes) + * - message: Returns a single message (non-interactive JSON/text only) + * - stream_messages: Streams multiple messages (ACP only) + * + * All other result types are converted to 'unsupported'. + * + * @param result The result from executing a slash command action + * @returns A NonInteractiveSlashCommandResult describing the outcome + */ +function handleCommandResult( + result: SlashCommandActionReturn, +): NonInteractiveSlashCommandResult { + switch (result.type) { + case 'submit_prompt': + return { + type: 'submit_prompt', + content: result.content, + }; + + case 'message': + return { + type: 'message', + messageType: result.messageType, + content: result.content, + }; + + case 'stream_messages': + return { + type: 'stream_messages', + messages: result.messages, + }; + + /** + * Currently return types below are never generated due to the + * whitelist of allowed slash commands in ACP and non-interactive mode. + * We'll try to add more supported return types in the future. + */ + case 'tool': + return { + type: 'unsupported', + reason: + 'Tool execution from slash commands is not supported in non-interactive mode.', + originalType: 'tool', + }; + + case 'quit': + return { + type: 'unsupported', + reason: + 'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.', + originalType: 'quit', + }; + + case 'dialog': + return { + type: 'unsupported', + reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`, + originalType: 'dialog', + }; + + case 'load_history': + return { + type: 'unsupported', + reason: + 'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.', + originalType: 'load_history', + }; + + case 'confirm_shell_commands': + return { + type: 'unsupported', + reason: + 'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.', + originalType: 'confirm_shell_commands', + }; + + case 'confirm_action': + return { + type: 'unsupported', + reason: + 'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.', + originalType: 'confirm_action', + }; + + default: { + // Exhaustiveness check + const _exhaustive: never = result; + return { + type: 'unsupported', + reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`, + originalType: 'unknown', + }; + } + } +} + +/** + * Filters commands based on the allowed built-in command names. + * + * - Always includes FILE commands + * - Only includes BUILT_IN commands if their name is in the allowed set + * - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode + * + * @param commands All loaded commands + * @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed) + * @returns Filtered commands + */ +function filterCommandsForNonInteractive( + commands: readonly SlashCommand[], + allowedBuiltinCommandNames: Set, +): SlashCommand[] { + return commands.filter((cmd) => { + if (cmd.kind === CommandKind.FILE || cmd.kind === CommandKind.SKILL) { + return true; + } + + // Built-in commands: only include if in the allowed list + if (cmd.kind === CommandKind.BUILT_IN) { + return allowedBuiltinCommandNames.has(cmd.name); + } + + // Exclude other types (e.g., MCP_PROMPT) in non-interactive mode + return false; + }); +} + +/** + * Processes a slash command in a non-interactive environment. + * + * @param rawQuery The raw query string (should start with '/') + * @param abortController Controller to cancel the operation + * @param config The configuration object + * @param settings The loaded settings + * @param allowedBuiltinCommandNames Optional array of built-in command names that are + * allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress). + * Pass an empty array to only allow file commands. + * @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing + * the outcome of the command execution. + */ +export const handleSlashCommand = async ( + rawQuery: string, + abortController: AbortController, + config: Config, + settings: LoadedSettings, + allowedBuiltinCommandNames: string[] = [ + ...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE, + ], +): Promise => { + const trimmed = rawQuery.trim(); + if (!trimmed.startsWith('/')) { + return { type: 'no_command' }; + } + + const isAcpMode = config.getExperimentalZedIntegration(); + const isInteractive = config.isInteractive(); + + const executionMode = isAcpMode + ? 'acp' + : isInteractive + ? 'interactive' + : 'non_interactive'; + + const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); + + // Load all commands to check if the command exists but is not allowed + const allLoaders = [ + new BuiltinCommandLoader(config), + new BundledSkillLoader(config), + new FileCommandLoader(config), + ]; + + const commandService = await CommandService.create( + allLoaders, + abortController.signal, + ); + const allCommands = commandService.getCommands(); + const filteredCommands = filterCommandsForNonInteractive( + allCommands, + allowedBuiltinSet, + ); + + // First, try to parse with filtered commands + const { commandToExecute, args } = parseSlashCommand( + rawQuery, + filteredCommands, + ); + + if (!commandToExecute) { + // Check if this is a known command that's just not allowed + const { commandToExecute: knownCommand } = parseSlashCommand( + rawQuery, + allCommands, + ); + + if (knownCommand) { + // Command exists but is not allowed in non-interactive mode + return { + type: 'unsupported', + reason: t( + 'The command "/{{command}}" is not supported in non-interactive mode.', + { command: knownCommand.name }, + ), + originalType: 'filtered_command', + }; + } + + return { type: 'no_command' }; + } + + if (!commandToExecute.action) { + return { type: 'no_command' }; + } + + // Not used by custom commands but may be in the future. + const sessionStats: SessionStatsState = { + sessionId: config?.getSessionId(), + sessionStartTime: new Date(), + metrics: uiTelemetryService.getMetrics(), + lastPromptTokenCount: 0, + promptCount: 1, + }; + + const logger = new Logger(config?.getSessionId() || '', config?.storage); + + const context: CommandContext = { + executionMode, + services: { + config, + settings, + git: undefined, + logger, + }, + ui: createNonInteractiveUI(), + session: { + stats: sessionStats, + sessionShellAllowlist: new Set(), + }, + invocation: { + raw: trimmed, + name: commandToExecute.name, + args, + }, + }; + + const result = await commandToExecute.action(context, args); + + if (!result) { + // Command executed but returned no result (e.g., void return) + return { + type: 'message', + messageType: 'info', + content: 'Command executed successfully.', + }; + } + + // Handle different result types + return handleCommandResult(result); +}; + +/** + * Retrieves all available slash commands for the current configuration. + * + * @param config The configuration object + * @param abortSignal Signal to cancel the loading process + * @param allowedBuiltinCommandNames Optional array of built-in command names that are + * allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress). + * Pass an empty array to only include file commands. + * @returns A Promise that resolves to an array of SlashCommand objects + */ +export const getAvailableCommands = async ( + config: Config, + abortSignal: AbortSignal, + allowedBuiltinCommandNames: string[] = [ + ...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE, + ], +): Promise => { + try { + const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); + + // Only load BuiltinCommandLoader if there are allowed built-in commands + const loaders = + allowedBuiltinSet.size > 0 + ? [ + new BuiltinCommandLoader(config), + new BundledSkillLoader(config), + new FileCommandLoader(config), + ] + : [new BundledSkillLoader(config), new FileCommandLoader(config)]; + + const commandService = await CommandService.create(loaders, abortSignal); + const commands = commandService.getCommands(); + const filteredCommands = filterCommandsForNonInteractive( + commands, + allowedBuiltinSet, + ); + + // Filter out hidden commands + return filteredCommands.filter((cmd) => !cmd.hidden); + } catch (error) { + // Handle errors gracefully - log and return empty array + debugLogger.error('Error loading available commands:', error); + return []; + } +}; diff --git a/apps/airiscode-cli/src/gemini-base/patches/is-in-ci.ts b/apps/airiscode-cli/src/patches/is-in-ci.ts similarity index 100% rename from apps/airiscode-cli/src/gemini-base/patches/is-in-ci.ts rename to apps/airiscode-cli/src/patches/is-in-ci.ts diff --git a/apps/airiscode-cli/src/providerFactory.ts b/apps/airiscode-cli/src/providerFactory.ts deleted file mode 100644 index 973aadfe6..000000000 --- a/apps/airiscode-cli/src/providerFactory.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import TOML from "@iarna/toml"; -import type { ContentGenerator } from "@airiscode/core-gemini"; - -export interface ProviderConfig { - provider: { - name: string; - }; - openai?: { - api_key?: string; - model: string; - base_url?: string; - }; - anthropic?: { - api_key?: string; - model: string; - base_url?: string; - }; - ollama?: { - model: string; - base_url?: string; - }; - mlx?: { - model: string; - base_url?: string; - }; -} - -function expandEnvVar(value: string | undefined): string | undefined { - if (!value) return value; - if (value.startsWith("$")) { - const envVar = value.slice(1); - return process.env[envVar]; - } - return value; -} - -export async function createContentGenerator(): Promise { - const configPath = path.join(os.homedir(), ".airiscode", "config.toml"); - - if (!fs.existsSync(configPath)) { - throw new Error( - `Config file not found: ${configPath}\n\nPlease create ~/.airiscode/config.toml with your provider settings.` - ); - } - - const configContent = fs.readFileSync(configPath, "utf8"); - const cfg = TOML.parse(configContent) as Partial; - const providerName = cfg.provider?.name ?? "openai"; - - switch (providerName) { - case "openai": { - const { OpenAIContentGenerator } = await import("@airiscode/driver-openai"); - const apiKey = expandEnvVar(cfg.openai?.api_key) ?? process.env.OPENAI_API_KEY; - if (!apiKey) { - throw new Error("OpenAI API key not found. Set OPENAI_API_KEY or configure in config.toml"); - } - return new OpenAIContentGenerator({ - apiKey, - model: cfg.openai?.model ?? "gpt-4o-mini", - baseUrl: cfg.openai?.base_url, - }); - } - - case "anthropic": { - const { AnthropicContentGenerator } = await import("@airiscode/driver-anthropic"); - const apiKey = expandEnvVar(cfg.anthropic?.api_key) ?? process.env.ANTHROPIC_API_KEY; - if (!apiKey) { - throw new Error("Anthropic API key not found. Set ANTHROPIC_API_KEY or configure in config.toml"); - } - return new AnthropicContentGenerator({ - apiKey, - model: cfg.anthropic?.model ?? "claude-3-5-sonnet-latest", - baseUrl: cfg.anthropic?.base_url, - }); - } - - case "ollama": { - const { OllamaContentGenerator } = await import("@airiscode/driver-ollama"); - return new OllamaContentGenerator({ - model: cfg.ollama?.model ?? "qwen2.5:7b", - baseUrl: cfg.ollama?.base_url, - }); - } - - case "mlx": { - // MLX driver placeholder - similar to Ollama but with MLX-specific endpoint - const { OllamaContentGenerator } = await import("@airiscode/driver-ollama"); - return new OllamaContentGenerator({ - model: cfg.mlx?.model ?? "mlx-community/Qwen2.5-7B-Instruct-4bit", - baseUrl: cfg.mlx?.base_url ?? "http://127.0.0.1:8080", - }); - } - - default: - throw new Error(`Unknown provider: ${providerName}. Supported: openai, anthropic, ollama, mlx`); - } -} diff --git a/apps/airiscode-cli/src/services/BuiltinCommandLoader.ts b/apps/airiscode-cli/src/services/BuiltinCommandLoader.ts new file mode 100644 index 000000000..78c143ef9 --- /dev/null +++ b/apps/airiscode-cli/src/services/BuiltinCommandLoader.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ICommandLoader } from './types.js'; +import type { SlashCommand } from '../ui/commands/types.js'; +import type { Config } from '@airiscode/core'; +import { aboutCommand } from '../ui/commands/aboutCommand.js'; +import { agentsCommand } from '../ui/commands/agentsCommand.js'; +import { arenaCommand } from '../ui/commands/arenaCommand.js'; +import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; +import { authCommand } from '../ui/commands/authCommand.js'; +import { btwCommand } from '../ui/commands/btwCommand.js'; +import { bugCommand } from '../ui/commands/bugCommand.js'; +import { clearCommand } from '../ui/commands/clearCommand.js'; +import { compressCommand } from '../ui/commands/compressCommand.js'; +import { contextCommand } from '../ui/commands/contextCommand.js'; +import { copyCommand } from '../ui/commands/copyCommand.js'; +import { docsCommand } from '../ui/commands/docsCommand.js'; +import { directoryCommand } from '../ui/commands/directoryCommand.js'; +import { editorCommand } from '../ui/commands/editorCommand.js'; +import { exportCommand } from '../ui/commands/exportCommand.js'; +import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { helpCommand } from '../ui/commands/helpCommand.js'; +import { hooksCommand } from '../ui/commands/hooksCommand.js'; +import { ideCommand } from '../ui/commands/ideCommand.js'; +import { createDebugLogger } from '@airiscode/core'; +import { initCommand } from '../ui/commands/initCommand.js'; +import { languageCommand } from '../ui/commands/languageCommand.js'; +import { mcpCommand } from '../ui/commands/mcpCommand.js'; +import { memoryCommand } from '../ui/commands/memoryCommand.js'; +import { modelCommand } from '../ui/commands/modelCommand.js'; +import { planCommand } from '../ui/commands/planCommand.js'; +import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; +import { trustCommand } from '../ui/commands/trustCommand.js'; +import { quitCommand } from '../ui/commands/quitCommand.js'; +import { restoreCommand } from '../ui/commands/restoreCommand.js'; +import { resumeCommand } from '../ui/commands/resumeCommand.js'; +import { settingsCommand } from '../ui/commands/settingsCommand.js'; +import { skillsCommand } from '../ui/commands/skillsCommand.js'; +import { statsCommand } from '../ui/commands/statsCommand.js'; +import { summaryCommand } from '../ui/commands/summaryCommand.js'; +import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; +import { themeCommand } from '../ui/commands/themeCommand.js'; +import { toolsCommand } from '../ui/commands/toolsCommand.js'; +import { vimCommand } from '../ui/commands/vimCommand.js'; +import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { insightCommand } from '../ui/commands/insightCommand.js'; +import { statuslineCommand } from '../ui/commands/statuslineCommand.js'; + +const builtinDebugLogger = createDebugLogger('BUILTIN_COMMAND_LOADER'); + +/** + * Loads the core, hard-coded slash commands that are an integral part + * of the AIRIS Code application. + */ +export class BuiltinCommandLoader implements ICommandLoader { + constructor(private config: Config | null) {} + + /** + * Gathers all raw built-in command definitions, injects dependencies where + * needed (e.g., config) and filters out any that are not available. + * + * @param _signal An AbortSignal (unused for this synchronous loader). + * @returns A promise that resolves to an array of `SlashCommand` objects. + */ + async loadCommands(_signal: AbortSignal): Promise { + // Load ideCommand separately with error handling so that a failure + // (e.g., platform-specific process detection on Windows) does not + // prevent ALL built-in commands from loading. + let resolvedIdeCommand: SlashCommand | null = null; + try { + resolvedIdeCommand = await ideCommand(); + } catch (error) { + builtinDebugLogger.warn( + 'Failed to load IDE command:', + error instanceof Error ? error.message : String(error), + ); + } + + const allDefinitions: Array = [ + aboutCommand, + agentsCommand, + arenaCommand, + approvalModeCommand, + authCommand, + btwCommand, + bugCommand, + clearCommand, + compressCommand, + contextCommand, + copyCommand, + docsCommand, + directoryCommand, + editorCommand, + exportCommand, + extensionsCommand, + helpCommand, + hooksCommand, + resolvedIdeCommand, + initCommand, + languageCommand, + mcpCommand, + memoryCommand, + modelCommand, + planCommand, + permissionsCommand, + ...(this.config?.getFolderTrust() ? [trustCommand] : []), + quitCommand, + restoreCommand(this.config), + resumeCommand, + skillsCommand, + statsCommand, + summaryCommand, + themeCommand, + toolsCommand, + settingsCommand, + vimCommand, + setupGithubCommand, + terminalSetupCommand, + insightCommand, + statuslineCommand, + ]; + + return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); + } +} diff --git a/apps/airiscode-cli/src/services/BundledSkillLoader.ts b/apps/airiscode-cli/src/services/BundledSkillLoader.ts new file mode 100644 index 000000000..c0673f032 --- /dev/null +++ b/apps/airiscode-cli/src/services/BundledSkillLoader.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@airiscode/core'; +import { + createDebugLogger, + appendToLastTextPart, +} from '@airiscode/core'; +import type { ICommandLoader } from './types.js'; +import type { + SlashCommand, + SlashCommandActionReturn, +} from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; + +const debugLogger = createDebugLogger('BUNDLED_SKILL_LOADER'); + +/** + * Loads bundled skills as slash commands, making them directly invocable + * via / (e.g., /review). + */ +export class BundledSkillLoader implements ICommandLoader { + constructor(private readonly config: Config | null) {} + + async loadCommands(_signal: AbortSignal): Promise { + const skillManager = this.config?.getSkillManager(); + if (!skillManager) { + debugLogger.debug('SkillManager not available, skipping bundled skills'); + return []; + } + + try { + const allSkills = await skillManager.listSkills({ level: 'bundled' }); + + // Hide skills whose allowedTools require cron when cron is disabled + const cronEnabled = this.config?.isCronEnabled() ?? false; + const skills = allSkills.filter((skill) => { + if ( + !cronEnabled && + skill.allowedTools?.some((t) => t.startsWith('cron_')) + ) { + debugLogger.debug( + `Hiding skill "${skill.name}" because cron is not enabled`, + ); + return false; + } + return true; + }); + + debugLogger.debug( + `Loaded ${skills.length} bundled skill(s) as slash commands`, + ); + + return skills.map((skill) => ({ + name: skill.name, + description: skill.description, + kind: CommandKind.SKILL, + action: async (context, _args): Promise => { + // Resolve template variables in skill body + let body = skill.body; + const modelId = this.config?.getModel()?.trim() || ''; + if (body.includes('{{model}}') || body.includes('YOUR_MODEL_ID')) { + body = body.replaceAll('{{model}}', modelId); + body = body.replaceAll('YOUR_MODEL_ID', modelId); + // Prepend model identity as a top-level declaration so the LLM + // cannot miss it even if it doesn't copy the template exactly. + if (modelId) { + body = `YOUR_MODEL_ID="${modelId}"\n\n${body}`; + } + } + + const content = context.invocation?.args + ? appendToLastTextPart([{ text: body }], context.invocation.raw) + : [{ text: body }]; + + return { + type: 'submit_prompt', + content, + }; + }, + })); + } catch (error) { + debugLogger.error('Failed to load bundled skills:', error); + return []; + } + } +} diff --git a/apps/airiscode-cli/src/gemini-base/services/CommandService.ts b/apps/airiscode-cli/src/services/CommandService.ts similarity index 97% rename from apps/airiscode-cli/src/gemini-base/services/CommandService.ts rename to apps/airiscode-cli/src/services/CommandService.ts index 40ed81d18..349e63b9f 100644 --- a/apps/airiscode-cli/src/gemini-base/services/CommandService.ts +++ b/apps/airiscode-cli/src/services/CommandService.ts @@ -4,9 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { debugLogger } from '@airiscode/gemini-cli-core'; import type { SlashCommand } from '../ui/commands/types.js'; import type { ICommandLoader } from './types.js'; +import { createDebugLogger } from '@airiscode/core'; + +const debugLogger = createDebugLogger('CLI_COMMANDS'); /** * Orchestrates the discovery and loading of all slash commands for the CLI. diff --git a/apps/airiscode-cli/src/services/FileCommandLoader.ts b/apps/airiscode-cli/src/services/FileCommandLoader.ts new file mode 100644 index 000000000..5bcd82717 --- /dev/null +++ b/apps/airiscode-cli/src/services/FileCommandLoader.ts @@ -0,0 +1,363 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import * as fsSync from 'node:fs'; +import path from 'node:path'; +import toml from '@iarna/toml'; +import { glob } from 'glob'; +import { z } from 'zod'; +import type { Config } from '@airiscode/core'; +import { + createDebugLogger, + EXTENSIONS_CONFIG_FILENAME, + Storage, +} from '@airiscode/core'; +import type { ICommandLoader } from './types.js'; +import { + parseMarkdownCommand, + MarkdownCommandDefSchema, +} from './markdown-command-parser.js'; +import { + createSlashCommandFromDefinition, + type CommandDefinition, +} from './command-factory.js'; +import type { SlashCommand } from '../ui/commands/types.js'; + +interface CommandDirectory { + path: string; + extensionName?: string; +} + +const debugLogger = createDebugLogger('FILE_COMMAND_LOADER'); + +/** + * Defines the Zod schema for a command definition file. This serves as the + * single source of truth for both validation and type inference. + */ +const TomlCommandDefSchema = z.object({ + prompt: z.string({ + required_error: "The 'prompt' field is required.", + invalid_type_error: "The 'prompt' field must be a string.", + }), + description: z.string().optional(), +}); + +/** + * Discovers and loads custom slash commands from .toml files in both the + * user's global config directory and the current project's directory. + * + * This loader is responsible for: + * - Recursively scanning command directories. + * - Parsing and validating TOML files. + * - Adapting valid definitions into executable SlashCommand objects. + * - Handling file system errors and malformed files gracefully. + */ +export class FileCommandLoader implements ICommandLoader { + private readonly projectRoot: string; + private readonly folderTrustEnabled: boolean; + private readonly folderTrust: boolean; + + constructor(private readonly config: Config | null) { + this.folderTrustEnabled = !!config?.getFolderTrustFeature(); + this.folderTrust = !!config?.getFolderTrust(); + this.projectRoot = config?.getProjectRoot() || process.cwd(); + } + + /** + * Loads all commands from user, project, and extension directories. + * Returns commands in order: user → project → extensions (alphabetically). + * + * Order is important for conflict resolution in CommandService: + * - User/project commands (without extensionName) use "last wins" strategy + * - Extension commands (with extensionName) get renamed if conflicts exist + * + * @param signal An AbortSignal to cancel the loading process. + * @returns A promise that resolves to an array of all loaded SlashCommands. + */ + async loadCommands(signal: AbortSignal): Promise { + const allCommands: SlashCommand[] = []; + const globOptions = { + nodir: true, + dot: true, + signal, + follow: true, + }; + + // Load commands from each directory + const commandDirs = this.getCommandDirectories(); + for (const dirInfo of commandDirs) { + try { + // Scan both .toml and .md files + const tomlFiles = await glob('**/*.toml', { + ...globOptions, + cwd: dirInfo.path, + }); + const mdFiles = await glob('**/*.md', { + ...globOptions, + cwd: dirInfo.path, + }); + + if (this.folderTrustEnabled && !this.folderTrust) { + return []; + } + + // Process TOML files + const tomlCommandPromises = tomlFiles.map((file) => + this.parseAndAdaptTomlFile( + path.join(dirInfo.path, file), + dirInfo.path, + dirInfo.extensionName, + ), + ); + + // Process Markdown files + const mdCommandPromises = mdFiles.map((file) => + this.parseAndAdaptMarkdownFile( + path.join(dirInfo.path, file), + dirInfo.path, + dirInfo.extensionName, + ), + ); + + const commands = ( + await Promise.all([...tomlCommandPromises, ...mdCommandPromises]) + ).filter((cmd): cmd is SlashCommand => cmd !== null); + + // Add all commands without deduplication + allCommands.push(...commands); + } catch (error) { + // Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled) + const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT'; + const isAbortError = + error instanceof Error && error.name === 'AbortError'; + if (!isEnoent && !isAbortError) { + debugLogger.error( + `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, + error, + ); + } + } + } + + return allCommands; + } + + /** + * Get all command directories in order for loading. + * User commands → Project commands → Extension commands + * This order ensures extension commands can detect all conflicts. + */ + private getCommandDirectories(): CommandDirectory[] { + const dirs: CommandDirectory[] = []; + + const storage = this.config?.storage ?? new Storage(this.projectRoot); + + // 1. User commands + dirs.push({ path: Storage.getUserCommandsDir() }); + + // 2. Project commands (override user commands) + dirs.push({ path: storage.getProjectCommandsDir() }); + + // 3. Extension commands (processed last to detect all conflicts) + if (this.config) { + const activeExtensions = this.config + .getExtensions() + .filter((ext) => ext.isActive) + .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading + + // Collect command directories from each extension + for (const ext of activeExtensions) { + // Get commands paths from extension config + const commandsPaths = this.getExtensionCommandsPaths(ext); + + for (const cmdPath of commandsPaths) { + dirs.push({ + path: cmdPath, + extensionName: ext.name, + }); + } + } + } + + return dirs; + } + + /** + * Get commands paths from an extension. + * Returns paths from config.commands if specified, otherwise defaults to 'commands' directory. + */ + private getExtensionCommandsPaths(ext: { + path: string; + name: string; + }): string[] { + // Try to get extension config + try { + const configPath = path.join(ext.path, EXTENSIONS_CONFIG_FILENAME); + if (fsSync.existsSync(configPath)) { + const configContent = fsSync.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(configContent); + + if (config.commands) { + const commandsArray = Array.isArray(config.commands) + ? config.commands + : [config.commands]; + + return commandsArray + .map((cmdPath: string) => + path.isAbsolute(cmdPath) ? cmdPath : path.join(ext.path, cmdPath), + ) + .filter((cmdPath: string) => { + try { + return fsSync.existsSync(cmdPath); + } catch { + return false; + } + }); + } + } + } catch (error) { + debugLogger.warn( + `Failed to read extension config for ${ext.name}:`, + error, + ); + } + + // Default fallback: use 'commands' directory + const defaultPath = path.join(ext.path, 'commands'); + try { + if (fsSync.existsSync(defaultPath)) { + return [defaultPath]; + } + } catch { + // Ignore + } + + return []; + } + + /** + * Parses a single .toml file and transforms it into a SlashCommand object. + * @param filePath The absolute path to the .toml file. + * @param baseDir The root command directory for name calculation. + * @param extensionName Optional extension name to prefix commands with. + * @returns A promise resolving to a SlashCommand, or null if the file is invalid. + */ + private async parseAndAdaptTomlFile( + filePath: string, + baseDir: string, + extensionName?: string, + ): Promise { + let fileContent: string; + try { + fileContent = await fs.readFile(filePath, 'utf-8'); + } catch (error: unknown) { + debugLogger.error( + `[FileCommandLoader] Failed to read file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + let parsed: unknown; + try { + parsed = toml.parse(fileContent); + } catch (error: unknown) { + debugLogger.error( + `[FileCommandLoader] Failed to parse TOML file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + const validationResult = TomlCommandDefSchema.safeParse(parsed); + + if (!validationResult.success) { + debugLogger.error( + `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, + validationResult.error.flatten(), + ); + return null; + } + + const validDef = validationResult.data; + + // Use factory to create command + return createSlashCommandFromDefinition( + filePath, + baseDir, + validDef as any, + extensionName, + '.toml', + ); + } + + /** + * Parses a single .md file and transforms it into a SlashCommand object. + * @param filePath The absolute path to the .md file. + * @param baseDir The root command directory for name calculation. + * @param extensionName Optional extension name to prefix commands with. + * @returns A promise resolving to a SlashCommand, or null if the file is invalid. + */ + private async parseAndAdaptMarkdownFile( + filePath: string, + baseDir: string, + extensionName?: string, + ): Promise { + let fileContent: string; + try { + fileContent = await fs.readFile(filePath, 'utf-8'); + } catch (error: unknown) { + debugLogger.error( + `[FileCommandLoader] Failed to read file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + let parsed: ReturnType; + try { + parsed = parseMarkdownCommand(fileContent); + } catch (error: unknown) { + debugLogger.error( + `[FileCommandLoader] Failed to parse Markdown file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + const validationResult = MarkdownCommandDefSchema.safeParse(parsed); + + if (!validationResult.success) { + debugLogger.error( + `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, + validationResult.error.flatten(), + ); + return null; + } + + const validDef = validationResult.data; + + // Convert to CommandDefinition format + const definition: CommandDefinition = { + prompt: validDef.prompt, + description: + validDef.frontmatter?.description && + typeof validDef.frontmatter.description === 'string' + ? validDef.frontmatter.description + : undefined, + }; + + // Use factory to create command + return createSlashCommandFromDefinition( + filePath, + baseDir, + definition, + extensionName, + '.md', + ); + } +} diff --git a/apps/airiscode-cli/src/gemini-base/services/McpPromptLoader.ts b/apps/airiscode-cli/src/services/McpPromptLoader.ts similarity index 95% rename from apps/airiscode-cli/src/gemini-base/services/McpPromptLoader.ts rename to apps/airiscode-cli/src/services/McpPromptLoader.ts index 08010ed09..71f10737e 100644 --- a/apps/airiscode-cli/src/gemini-base/services/McpPromptLoader.ts +++ b/apps/airiscode-cli/src/services/McpPromptLoader.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@airiscode/gemini-cli-core'; -import { getErrorMessage, getMCPServerPrompts } from '@airiscode/gemini-cli-core'; +import type { Config } from '@airiscode/core'; +import { + getErrorMessage, + getMCPServerPrompts, +} from '@airiscode/core'; import type { CommandContext, SlashCommand, @@ -34,7 +37,7 @@ export class McpPromptLoader implements ICommandLoader { if (!this.config) { return Promise.resolve([]); } - const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {}; + const mcpServers = this.config.getMcpServers() || {}; for (const serverName in mcpServers) { const prompts = getMCPServerPrompts(this.config, serverName) || []; for (const prompt of prompts) { @@ -101,8 +104,7 @@ export class McpPromptLoader implements ICommandLoader { } try { - const mcpServers = - this.config.getMcpClientManager()?.getMcpServers() || {}; + const mcpServers = this.config.getMcpServers() || {}; const mcpServerConfig = mcpServers[serverName]; if (!mcpServerConfig) { return { @@ -121,7 +123,10 @@ export class McpPromptLoader implements ICommandLoader { }; } - if (!result.messages?.[0]?.content?.['text']) { + const firstMessage = result.messages?.[0]; + const content = firstMessage?.content; + + if (content?.type !== 'text') { return { type: 'message', messageType: 'error', @@ -132,7 +137,7 @@ export class McpPromptLoader implements ICommandLoader { return { type: 'submit_prompt', - content: JSON.stringify(result.messages[0].content.text), + content: JSON.stringify(content.text), }; } catch (error) { return { diff --git a/apps/airiscode-cli/src/services/command-factory.ts b/apps/airiscode-cli/src/services/command-factory.ts new file mode 100644 index 000000000..70c52650a --- /dev/null +++ b/apps/airiscode-cli/src/services/command-factory.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * This file contains helper functions for FileCommandLoader to create SlashCommand + * objects from parsed command definitions (TOML or Markdown). + */ + +import path from 'node:path'; +import { createDebugLogger } from '@airiscode/core'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; +import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; +import type { + IPromptProcessor, + PromptPipelineContent, +} from './prompt-processors/types.js'; +import { + SHORTHAND_ARGS_PLACEHOLDER, + SHELL_INJECTION_TRIGGER, + AT_FILE_INJECTION_TRIGGER, +} from './prompt-processors/types.js'; +import { + ConfirmationRequiredError, + ShellProcessor, +} from './prompt-processors/shellProcessor.js'; +import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; + +export interface CommandDefinition { + prompt: string; + description?: string; +} + +const debugLogger = createDebugLogger('COMMAND_FACTORY'); + +/** + * Creates a SlashCommand from a parsed command definition. + * This function is used by both TOML and Markdown command loaders. + * + * @param filePath The absolute path to the command file + * @param baseDir The root command directory for name calculation + * @param definition The parsed command definition (prompt and optional description) + * @param extensionName Optional extension name to prefix commands with + * @param fileExtension The file extension (e.g., '.toml' or '.md') + * @returns A SlashCommand object + */ +export function createSlashCommandFromDefinition( + filePath: string, + baseDir: string, + definition: CommandDefinition, + extensionName: string | undefined, + fileExtension: string, +): SlashCommand { + const relativePathWithExt = path.relative(baseDir, filePath); + const relativePath = relativePathWithExt.substring( + 0, + relativePathWithExt.length - fileExtension.length, + ); + const baseCommandName = relativePath + .split(path.sep) + // Sanitize each path segment to prevent ambiguity. Since ':' is our + // namespace separator, we replace any literal colons in filenames + // with underscores to avoid naming conflicts. + .map((segment) => segment.replaceAll(':', '_')) + .join(':'); + + // Add extension name tag for extension commands + const defaultDescription = `Custom command from ${path.basename(filePath)}`; + let description = definition.description || defaultDescription; + if (extensionName) { + description = `[${extensionName}] ${description}`; + } + + const processors: IPromptProcessor[] = []; + const usesArgs = definition.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); + const usesShellInjection = definition.prompt.includes( + SHELL_INJECTION_TRIGGER, + ); + const usesAtFileInjection = definition.prompt.includes( + AT_FILE_INJECTION_TRIGGER, + ); + + // 1. @-File Injection (Security First). + // This runs first to ensure we're not executing shell commands that + // could dynamically generate malicious @-paths. + if (usesAtFileInjection) { + processors.push(new AtFileProcessor(baseCommandName)); + } + + // 2. Argument and Shell Injection. + // This runs after file content has been safely injected. + if (usesShellInjection || usesArgs) { + processors.push(new ShellProcessor(baseCommandName)); + } + + // 3. Default Argument Handling. + // Appends the raw invocation if no explicit {{args}} are used. + if (!usesArgs) { + processors.push(new DefaultArgumentProcessor()); + } + + return { + name: baseCommandName, + description, + kind: CommandKind.FILE, + extensionName, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + if (!context.invocation) { + debugLogger.error( + `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, + ); + return { + type: 'submit_prompt', + content: [{ text: definition.prompt }], // Fallback to unprocessed prompt + }; + } + + try { + let processedContent: PromptPipelineContent = [ + { text: definition.prompt }, + ]; + for (const processor of processors) { + processedContent = await processor.process(processedContent, context); + } + + return { + type: 'submit_prompt', + content: processedContent, + }; + } catch (e) { + // Check if it's our specific error type + if (e instanceof ConfirmationRequiredError) { + // Halt and request confirmation from the UI layer. + return { + type: 'confirm_shell_commands', + commandsToConfirm: e.commandsToConfirm, + originalInvocation: { + raw: context.invocation.raw, + }, + }; + } + // Re-throw other errors to be handled by the global error handler. + throw e; + } + }, + }; +} diff --git a/apps/airiscode-cli/src/services/command-migration-tool.ts b/apps/airiscode-cli/src/services/command-migration-tool.ts new file mode 100644 index 000000000..706b9575e --- /dev/null +++ b/apps/airiscode-cli/src/services/command-migration-tool.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tool for migrating TOML commands to Markdown format. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { glob } from 'glob'; +import { convertTomlToMarkdown } from '@airiscode/core'; +import { t } from '../i18n/index.js'; + +export interface MigrationResult { + success: boolean; + convertedFiles: string[]; + failedFiles: Array<{ file: string; error: string }>; +} + +export interface MigrationOptions { + /** Directory containing command files */ + commandDir: string; + /** Whether to create backups (default: true) */ + createBackup?: boolean; + /** Whether to delete original TOML files after migration (default: false) */ + deleteOriginal?: boolean; +} + +/** + * Scans a directory for TOML command files. + * @param commandDir Directory to scan + * @returns Array of TOML file paths (relative to commandDir) + */ +export async function detectTomlCommands( + commandDir: string, +): Promise { + try { + await fs.access(commandDir); + } catch { + // Directory doesn't exist + return []; + } + + const tomlFiles = await glob('**/*.toml', { + cwd: commandDir, + nodir: true, + dot: false, + }); + + return tomlFiles; +} + +/** + * Migrates TOML command files to Markdown format. + * @param options Migration options + * @returns Migration result with details + */ +export async function migrateTomlCommands( + options: MigrationOptions, +): Promise { + const { commandDir, createBackup = true, deleteOriginal = false } = options; + + const result: MigrationResult = { + success: true, + convertedFiles: [], + failedFiles: [], + }; + + // Detect TOML files + const tomlFiles = await detectTomlCommands(commandDir); + + if (tomlFiles.length === 0) { + return result; + } + + // Process each TOML file + for (const relativeFile of tomlFiles) { + const tomlPath = path.join(commandDir, relativeFile); + + try { + // Read TOML file + const tomlContent = await fs.readFile(tomlPath, 'utf-8'); + + // Convert to Markdown + const markdownContent = convertTomlToMarkdown(tomlContent); + + // Generate Markdown file path (same location, .md extension) + const markdownPath = tomlPath.replace(/\.toml$/, '.md'); + + // Check if Markdown file already exists + try { + await fs.access(markdownPath); + throw new Error( + t('Markdown file already exists: {{filename}}', { + filename: path.basename(markdownPath), + }), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + // File doesn't exist, continue + } + + // Write Markdown file + await fs.writeFile(markdownPath, markdownContent, 'utf-8'); + + // Backup original if requested (rename to .toml.backup) + if (createBackup) { + const backupPath = `${tomlPath}.backup`; + await fs.rename(tomlPath, backupPath); + } else if (deleteOriginal) { + // Delete original if requested and no backup + await fs.unlink(tomlPath); + } + + result.convertedFiles.push(relativeFile); + } catch (error) { + result.success = false; + result.failedFiles.push({ + file: relativeFile, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return result; +} + +/** + * Generates a migration report message. + * @param tomlFiles List of TOML files found + * @returns Human-readable migration prompt message + */ +export function generateMigrationPrompt(tomlFiles: string[]): string { + if (tomlFiles.length === 0) { + return ''; + } + + const count = tomlFiles.length; + const moreCount = tomlFiles.length - 3; + const fileList = + tomlFiles.length <= 5 + ? tomlFiles.map((f) => ` - ${f}`).join('\n') + : ` - ${tomlFiles.slice(0, 3).join('\n - ')}\n - ${t('... and {{count}} more', { count: String(moreCount) })}`; + + return ` +⚠️ ${t('TOML Command Format Deprecation Notice')} + +${t('Found {{count}} command file(s) in TOML format:', { count: String(count) })} +${fileList} + +${t('The TOML format for commands is being deprecated in favor of Markdown format.')} +${t('Markdown format is more readable and easier to edit.')} + +${t('You can migrate these files automatically using:')} + airiscode migrate-commands + +${t('Or manually convert each file:')} + - ${t('TOML: prompt = "..." / description = "..."')} + - ${t('Markdown: YAML frontmatter + content')} + +${t('The migration tool will:')} + ✓ ${t('Convert TOML files to Markdown')} + ✓ ${t('Create backups of original files')} + ✓ ${t('Preserve all command functionality')} + +${t('TOML format will continue to work for now, but migration is recommended.')} +`.trim(); +} diff --git a/apps/airiscode-cli/src/services/insight/generators/DataProcessor.ts b/apps/airiscode-cli/src/services/insight/generators/DataProcessor.ts new file mode 100644 index 000000000..6cd5db1b3 --- /dev/null +++ b/apps/airiscode-cli/src/services/insight/generators/DataProcessor.ts @@ -0,0 +1,1131 @@ +/** + * @license + * Copyright 2025 AIRIS Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { + read as readJsonlFile, + createDebugLogger, +} from '@airiscode/core'; +import pLimit from 'p-limit'; +import type { + InsightData, + HeatMapData, + StreakData, + SessionFacets, + InsightProgressCallback, +} from '../types/StaticInsightTypes.js'; +import type { + QualitativeInsights, + InsightImpressiveWorkflows, + InsightProjectAreas, + InsightFutureOpportunities, + InsightFrictionPoints, + InsightMemorableMoment, + InsightImprovements, + InsightInteractionStyle, + InsightAtAGlance, +} from '../types/QualitativeInsightTypes.js'; +import { + getInsightPrompt, + type Config, + type ChatRecord, +} from '@airiscode/core'; + +const logger = createDebugLogger('DataProcessor'); + +const CONCURRENCY_LIMIT = 4; + +export class DataProcessor { + constructor(private config: Config) {} + + // Helper function to format date as YYYY-MM-DD + private formatDate(date: Date): string { + return date.toISOString().split('T')[0]; + } + + // Format chat records for LLM analysis + private formatRecordsForAnalysis(records: ChatRecord[]): string { + let output = ''; + const sessionStart = + records.length > 0 ? new Date(records[0].timestamp) : new Date(); + + output += `Session: ${records[0]?.sessionId || 'unknown'}\n`; + output += `Date: ${sessionStart.toISOString()}\n`; + output += `Duration: ${records.length} turns\n\n`; + + for (const record of records) { + if (record.type === 'user') { + const text = + record.message?.parts + ?.map((p) => ('text' in p ? p.text : '')) + .join('') || ''; + output += `[User]: ${text}\n`; + } else if (record.type === 'assistant') { + if (record.message?.parts) { + for (const part of record.message.parts) { + if ('text' in part && part.text) { + output += `[Assistant]: ${part.text}\n`; + } else if ('functionCall' in part) { + const call = part.functionCall; + if (call) { + output += `[Tool: ${call.name}]\n`; + } + } + } + } + } + } + return output; + } + + // Only analyze conversational sessions for facets (skip system-only logs). + private hasUserAndAssistantRecords(records: ChatRecord[]): boolean { + let hasUser = false; + let hasAssistant = false; + + for (const record of records) { + if (record.type === 'user') { + hasUser = true; + } else if (record.type === 'assistant') { + hasAssistant = true; + } + + if (hasUser && hasAssistant) { + return true; + } + } + + return false; + } + + // Analyze a single session using LLM + private async analyzeSession( + records: ChatRecord[], + ): Promise { + if (records.length === 0) return null; + + const INSIGHT_SCHEMA = { + type: 'object', + properties: { + underlying_goal: { + type: 'string', + description: 'What the user fundamentally wanted to achieve', + }, + goal_categories: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + outcome: { + type: 'string', + enum: [ + 'fully_achieved', + 'mostly_achieved', + 'partially_achieved', + 'not_achieved', + 'unclear_from_transcript', + ], + }, + user_satisfaction_counts: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + Qwen_helpfulness: { + type: 'string', + enum: [ + 'unhelpful', + 'slightly_helpful', + 'moderately_helpful', + 'very_helpful', + 'essential', + ], + }, + session_type: { + type: 'string', + enum: [ + 'single_task', + 'multi_task', + 'iterative_refinement', + 'exploration', + 'quick_question', + ], + }, + friction_counts: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + friction_detail: { + type: 'string', + description: 'One sentence describing friction or empty', + }, + primary_success: { + type: 'string', + enum: [ + 'none', + 'fast_accurate_search', + 'correct_code_edits', + 'good_explanations', + 'proactive_help', + 'multi_file_changes', + 'good_debugging', + ], + }, + brief_summary: { + type: 'string', + description: 'One sentence: what user wanted and whether they got it', + }, + }, + required: [ + 'underlying_goal', + 'goal_categories', + 'outcome', + 'user_satisfaction_counts', + 'Qwen_helpfulness', + 'session_type', + 'friction_counts', + 'friction_detail', + 'primary_success', + 'brief_summary', + ], + }; + + const sessionText = this.formatRecordsForAnalysis(records); + const prompt = `${getInsightPrompt('analysis')}\n\nSESSION:\n${sessionText}`; + + try { + const result = await this.config.getBaseLlmClient().generateJson({ + // Use the configured model + model: this.config.getModel(), + contents: [{ role: 'user', parts: [{ text: prompt }] }], + schema: INSIGHT_SCHEMA, + abortSignal: AbortSignal.timeout(600000), // 10 minute timeout per session + }); + + if (!result || Object.keys(result).length === 0) { + return null; + } + + return { + ...(result as unknown as SessionFacets), + session_id: records[0].sessionId, + }; + } catch (error) { + logger.error( + `Failed to analyze session ${records[0]?.sessionId}:`, + error, + ); + return null; + } + } + + // Calculate streaks from activity dates + private calculateStreaks(dates: string[]): StreakData { + if (dates.length === 0) { + return { currentStreak: 0, longestStreak: 0, dates: [] }; + } + + // Convert string dates to Date objects and sort them + const dateObjects = dates.map((dateStr) => new Date(dateStr)); + dateObjects.sort((a, b) => a.getTime() - b.getTime()); + + let currentStreak = 1; + let maxStreak = 1; + let currentDate = new Date(dateObjects[0]); + currentDate.setHours(0, 0, 0, 0); // Normalize to start of day + + for (let i = 1; i < dateObjects.length; i++) { + const nextDate = new Date(dateObjects[i]); + nextDate.setHours(0, 0, 0, 0); // Normalize to start of day + + // Calculate difference in days + const diffDays = Math.floor( + (nextDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24), + ); + + if (diffDays === 1) { + // Consecutive day + currentStreak++; + maxStreak = Math.max(maxStreak, currentStreak); + } else if (diffDays > 1) { + // Gap in streak + currentStreak = 1; + } + // If diffDays === 0, same day, so streak continues + + currentDate = nextDate; + } + + // Check if the streak is still ongoing (if last activity was yesterday or today) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if ( + currentDate.getTime() === today.getTime() || + currentDate.getTime() === yesterday.getTime() + ) { + // The streak might still be active, so we don't reset it + } + + return { + currentStreak, + longestStreak: maxStreak, + dates, + }; + } + + // Process chat files from all projects in the base directory and generate insights + async generateInsights( + baseDir: string, + facetsOutputDir?: string, + onProgress?: InsightProgressCallback, + ): Promise { + if (onProgress) onProgress('Scanning chat history...', 0); + const allChatFiles = await this.scanChatFiles(baseDir); + + if (onProgress) onProgress('Crunching the numbers', 10); + const metrics = await this.generateMetrics(allChatFiles, onProgress); + + if (onProgress) onProgress('Preparing sessions...', 20); + const facets = await this.generateFacets( + allChatFiles, + facetsOutputDir, + onProgress, + ); + + if (onProgress) onProgress('Generating personalized insights...', 80); + const qualitative = await this.generateQualitativeInsights(metrics, facets); + + // Aggregate satisfaction, friction, success and outcome data from facets + const { + satisfactionAgg, + frictionAgg, + primarySuccessAgg, + outcomesAgg, + goalsAgg, + } = this.aggregateFacetsData(facets); + + if (onProgress) onProgress('Assembling report...', 100); + + return { + ...metrics, + qualitative, + satisfaction: satisfactionAgg, + friction: frictionAgg, + primarySuccess: primarySuccessAgg, + outcomes: outcomesAgg, + topGoals: goalsAgg, + }; + } + + // Aggregate satisfaction and friction data from facets + private aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + frictionAgg: Record; + primarySuccessAgg: Record; + outcomesAgg: Record; + goalsAgg: Record; + } { + const satisfactionAgg: Record = {}; + const frictionAgg: Record = {}; + const primarySuccessAgg: Record = {}; + const outcomesAgg: Record = {}; + const goalsAgg: Record = {}; + + facets.forEach((facet) => { + // Aggregate satisfaction + Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }); + + // Aggregate friction + Object.entries(facet.friction_counts).forEach(([fric, count]) => { + frictionAgg[fric] = (frictionAgg[fric] || 0) + count; + }); + + // Aggregate primary success + if (facet.primary_success && facet.primary_success !== 'none') { + primarySuccessAgg[facet.primary_success] = + (primarySuccessAgg[facet.primary_success] || 0) + 1; + } + + // Aggregate outcomes + if (facet.outcome) { + outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; + } + + // Aggregate goals + Object.entries(facet.goal_categories).forEach(([goal, count]) => { + goalsAgg[goal] = (goalsAgg[goal] || 0) + count; + }); + }); + + return { + satisfactionAgg, + frictionAgg, + primarySuccessAgg, + outcomesAgg, + goalsAgg, + }; + } + + private async generateQualitativeInsights( + metrics: Omit, + facets: SessionFacets[], + ): Promise { + if (facets.length === 0) { + return undefined; + } + + logger.info('Generating qualitative insights...'); + + const commonData = this.prepareCommonPromptData(metrics, facets); + + const generate = async ( + promptTemplate: string, + schema: Record, + ): Promise => { + const prompt = `${promptTemplate}\n\n${commonData}`; + try { + const result = await this.config.getBaseLlmClient().generateJson({ + model: this.config.getModel(), + contents: [{ role: 'user', parts: [{ text: prompt }] }], + schema, + abortSignal: AbortSignal.timeout(600000), + }); + return result as T; + } catch (error) { + logger.error('Failed to generate insight:', error); + return undefined; + } + }; + + // Schemas for each insight type + // We define simplified schemas here to guide the LLM. + // The types are already defined in QualitativeInsightTypes.ts + + // 1. Impressive Workflows + const schemaImpressiveWorkflows = { + type: 'object', + properties: { + intro: { type: 'string' }, + impressive_workflows: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + }, + required: ['title', 'description'], + }, + }, + }, + required: ['intro', 'impressive_workflows'], + }; + + // 2. Project Areas + const schemaProjectAreas = { + type: 'object', + properties: { + areas: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + session_count: { type: 'number' }, + description: { type: 'string' }, + }, + required: ['name', 'session_count', 'description'], + }, + }, + }, + required: ['areas'], + }; + + // 3. Future Opportunities + const schemaFutureOpportunities = { + type: 'object', + properties: { + intro: { type: 'string' }, + opportunities: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + whats_possible: { type: 'string' }, + how_to_try: { type: 'string' }, + copyable_prompt: { type: 'string' }, + }, + required: [ + 'title', + 'whats_possible', + 'how_to_try', + 'copyable_prompt', + ], + }, + }, + }, + required: ['intro', 'opportunities'], + }; + + // 4. Friction Points + const schemaFrictionPoints = { + type: 'object', + properties: { + intro: { type: 'string' }, + categories: { + type: 'array', + items: { + type: 'object', + properties: { + category: { type: 'string' }, + description: { type: 'string' }, + examples: { type: 'array', items: { type: 'string' } }, + }, + required: ['category', 'description', 'examples'], + }, + }, + }, + required: ['intro', 'categories'], + }; + + // 5. Memorable Moment + const schemaMemorableMoment = { + type: 'object', + properties: { + headline: { type: 'string' }, + detail: { type: 'string' }, + }, + required: ['headline', 'detail'], + }; + + // 6. Improvements + const schemaImprovements = { + type: 'object', + properties: { + Qwen_md_additions: { + type: 'array', + items: { + type: 'object', + properties: { + addition: { type: 'string' }, + why: { type: 'string' }, + prompt_scaffold: { type: 'string' }, + }, + required: ['addition', 'why', 'prompt_scaffold'], + }, + }, + features_to_try: { + type: 'array', + items: { + type: 'object', + properties: { + feature: { type: 'string' }, + one_liner: { type: 'string' }, + why_for_you: { type: 'string' }, + example_code: { type: 'string' }, + }, + required: ['feature', 'one_liner', 'why_for_you', 'example_code'], + }, + }, + usage_patterns: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + suggestion: { type: 'string' }, + detail: { type: 'string' }, + copyable_prompt: { type: 'string' }, + }, + required: ['title', 'suggestion', 'detail', 'copyable_prompt'], + }, + }, + }, + required: ['Qwen_md_additions', 'features_to_try', 'usage_patterns'], + }; + + // 7. Interaction Style + const schemaInteractionStyle = { + type: 'object', + properties: { + narrative: { type: 'string' }, + key_pattern: { type: 'string' }, + }, + required: ['narrative', 'key_pattern'], + }; + + // 8. At A Glance + const schemaAtAGlance = { + type: 'object', + properties: { + whats_working: { type: 'string' }, + whats_hindering: { type: 'string' }, + quick_wins: { type: 'string' }, + ambitious_workflows: { type: 'string' }, + }, + required: [ + 'whats_working', + 'whats_hindering', + 'quick_wins', + 'ambitious_workflows', + ], + }; + + const limit = pLimit(CONCURRENCY_LIMIT); + + try { + const [ + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + ] = await Promise.all([ + limit(() => + generate( + getInsightPrompt('impressive_workflows'), + schemaImpressiveWorkflows, + ), + ), + limit(() => + generate( + getInsightPrompt('project_areas'), + schemaProjectAreas, + ), + ), + limit(() => + generate( + getInsightPrompt('future_opportunities'), + schemaFutureOpportunities, + ), + ), + limit(() => + generate( + getInsightPrompt('friction_points'), + schemaFrictionPoints, + ), + ), + limit(() => + generate( + getInsightPrompt('memorable_moment'), + schemaMemorableMoment, + ), + ), + limit(() => + generate( + getInsightPrompt('improvements'), + schemaImprovements, + ), + ), + limit(() => + generate( + getInsightPrompt('interaction_style'), + schemaInteractionStyle, + ), + ), + limit(() => + generate( + getInsightPrompt('at_a_glance'), + schemaAtAGlance, + ), + ), + ]); + + logger.debug( + JSON.stringify( + { + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + }, + null, + 2, + ), + ); + + return { + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + }; + } catch (e) { + logger.error('Error generating qualitative insights:', e); + return undefined; + } + } + + private prepareCommonPromptData( + metrics: Omit, + facets: SessionFacets[], + ): string { + // 1. DATA section + const goalsAgg: Record = {}; + const outcomesAgg: Record = {}; + const satisfactionAgg: Record = {}; + const frictionAgg: Record = {}; + const successAgg: Record = {}; + + facets.forEach((facet) => { + // Aggregate goals + Object.entries(facet.goal_categories).forEach(([goal, count]) => { + goalsAgg[goal] = (goalsAgg[goal] || 0) + count; + }); + + // Aggregate outcomes + outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; + + // Aggregate satisfaction + Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }); + + // Aggregate friction + Object.entries(facet.friction_counts).forEach(([fric, count]) => { + frictionAgg[fric] = (frictionAgg[fric] || 0) + count; + }); + + // Aggregate success (primary_success) + if (facet.primary_success && facet.primary_success !== 'none') { + successAgg[facet.primary_success] = + (successAgg[facet.primary_success] || 0) + 1; + } + }); + + const topGoals = Object.entries(goalsAgg) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + + const dataObj = { + sessions: metrics.totalSessions || facets.length, + analyzed: facets.length, + date_range: { + start: Object.keys(metrics.heatmap).sort()[0] || 'N/A', + end: Object.keys(metrics.heatmap).sort().pop() || 'N/A', + }, + messages: metrics.totalMessages || 0, + hours: metrics.totalHours || 0, + commits: 0, // Not tracked yet + top_tools: metrics.topTools || [], + top_goals: topGoals, + outcomes: outcomesAgg, + satisfaction: satisfactionAgg, + friction: frictionAgg, + success: successAgg, + }; + + // 2. SESSION SUMMARIES section + const sessionSummaries = facets + .map((f) => `- ${f.brief_summary}`) + .join('\n'); + + // 3. FRICTION DETAILS section + const frictionDetails = facets + .filter((f) => f.friction_detail && f.friction_detail.trim().length > 0) + .map((f) => `- ${f.friction_detail}`) + .join('\n'); + + return `DATA: +${JSON.stringify(dataObj, null, 2)} + +SESSION SUMMARIES: +${sessionSummaries} + +FRICTION DETAILS: +${frictionDetails} + +USER INSTRUCTIONS TO Qwen: +None captured`; + } + + private async scanChatFiles( + baseDir: string, + ): Promise> { + const allChatFiles: Array<{ path: string; mtime: number }> = []; + + try { + // Get all project directories in the base directory + const projectDirs = await fs.readdir(baseDir); + + // Process each project directory + for (const projectDir of projectDirs) { + const projectPath = path.join(baseDir, projectDir); + const stats = await fs.stat(projectPath); + + // Only process if it's a directory + if (stats.isDirectory()) { + const chatsDir = path.join(projectPath, 'chats'); + + try { + // Get all chat files in the chats directory + const files = await fs.readdir(chatsDir); + const chatFiles = files.filter((file) => file.endsWith('.jsonl')); + + for (const file of chatFiles) { + const filePath = path.join(chatsDir, file); + + // Get file stats for sorting by recency + try { + const fileStats = await fs.stat(filePath); + allChatFiles.push({ path: filePath, mtime: fileStats.mtimeMs }); + } catch (e) { + logger.error(`Failed to stat file ${filePath}:`, e); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error( + `Error reading chats directory for project ${projectDir}: ${error}`, + ); + } + // Continue to next project if chats directory doesn't exist + continue; + } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Base directory doesn't exist, return empty + logger.info(`Base directory does not exist: ${baseDir}`); + } else { + logger.error(`Error reading base directory: ${error}`); + } + } + + return allChatFiles; + } + + private async generateMetrics( + files: Array<{ path: string; mtime: number }>, + onProgress?: InsightProgressCallback, + ): Promise> { + // Initialize data structures + const heatmap: HeatMapData = {}; + const activeHours: { [hour: number]: number } = {}; + const sessionStartTimes: { [sessionId: string]: Date } = {}; + const sessionEndTimes: { [sessionId: string]: Date } = {}; + let totalMessages = 0; + let totalLinesAdded = 0; + let totalLinesRemoved = 0; + const uniqueFiles = new Set(); + const toolUsage: Record = {}; + + // Process files in batches to avoid OOM and blocking the event loop + const BATCH_SIZE = 50; + const totalFiles = files.length; + + for (let i = 0; i < totalFiles; i += BATCH_SIZE) { + const batchEnd = Math.min(i + BATCH_SIZE, totalFiles); + const batch = files.slice(i, batchEnd); + + // Process batch sequentially to minimize memory usage + for (const fileInfo of batch) { + try { + const records = await readJsonlFile(fileInfo.path); + + // Process each record + for (const record of records) { + const timestamp = new Date(record.timestamp); + const dateKey = this.formatDate(timestamp); + const hour = timestamp.getHours(); + + // Count user messages and slash commands (actual user interactions) + const isUserMessage = record.type === 'user'; + const isSlashCommand = + record.type === 'system' && record.subtype === 'slash_command'; + if (isUserMessage || isSlashCommand) { + totalMessages++; + + // Update heatmap (count of user interactions per day) + heatmap[dateKey] = (heatmap[dateKey] || 0) + 1; + + // Update active hours + activeHours[hour] = (activeHours[hour] || 0) + 1; + } + + // Track session times + if (!sessionStartTimes[record.sessionId]) { + sessionStartTimes[record.sessionId] = timestamp; + } + sessionEndTimes[record.sessionId] = timestamp; + + // Track tool usage + if (record.type === 'assistant' && record.message?.parts) { + for (const part of record.message.parts) { + if ('functionCall' in part) { + const name = part.functionCall!.name!; + toolUsage[name] = (toolUsage[name] || 0) + 1; + } + } + } + + // Track lines and files from tool results + if ( + record.type === 'tool_result' && + record.toolCallResult?.resultDisplay + ) { + const display = record.toolCallResult.resultDisplay; + // Check if it matches FileDiff shape + if ( + typeof display === 'object' && + display !== null && + 'fileName' in display + ) { + // Cast to any to avoid importing FileDiff type which might not be available here + const diff = display as { + fileName: unknown; + diffStat?: { + model_added_lines?: number; + model_removed_lines?: number; + }; + }; + if (typeof diff.fileName === 'string') { + uniqueFiles.add(diff.fileName); + } + + if (diff.diffStat) { + totalLinesAdded += diff.diffStat.model_added_lines || 0; + totalLinesRemoved += diff.diffStat.model_removed_lines || 0; + } + } + } + } + } catch (error) { + logger.error( + `Failed to process metrics for file ${fileInfo.path}:`, + error, + ); + // Continue to next file + } + } + + // Update progress (mapped to 10-20% range of total progress) + if (onProgress) { + const percentComplete = batchEnd / totalFiles; + const overallProgress = 10 + Math.round(percentComplete * 10); + onProgress( + `Crunching the numbers (${batchEnd}/${totalFiles})`, + overallProgress, + ); + } + + // Yield to event loop to allow GC and UI updates + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + // Calculate streak data + const streakData = this.calculateStreaks(Object.keys(heatmap)); + + // Calculate longest work session and total hours + let longestWorkDuration = 0; + let longestWorkDate: string | null = null; + let totalDurationMs = 0; + + const sessionIds = Object.keys(sessionStartTimes); + const totalSessions = sessionIds.length; + + for (const sessionId of sessionIds) { + const start = sessionStartTimes[sessionId]; + const end = sessionEndTimes[sessionId]; + const durationMs = end.getTime() - start.getTime(); + const durationMinutes = Math.round(durationMs / (1000 * 60)); + + totalDurationMs += durationMs; + + if (durationMinutes > longestWorkDuration) { + longestWorkDuration = durationMinutes; + longestWorkDate = this.formatDate(start); + } + } + + const totalHours = Math.round(totalDurationMs / (1000 * 60 * 60)); + + // Calculate latest active time + let latestActiveTime: string | null = null; + let latestTimestamp = new Date(0); + for (const dateStr in heatmap) { + const date = new Date(dateStr); + if (date > latestTimestamp) { + latestTimestamp = date; + latestActiveTime = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } + } + + // Calculate top tools + const topTools = Object.entries(toolUsage) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + return { + heatmap, + currentStreak: streakData.currentStreak, + longestStreak: streakData.longestStreak, + longestWorkDate, + longestWorkDuration, + activeHours, + latestActiveTime, + totalSessions, + totalMessages, + totalHours, + topTools, + totalLinesAdded, + totalLinesRemoved, + totalFiles: uniqueFiles.size, + }; + } + + private async generateFacets( + allFiles: Array<{ path: string; mtime: number }>, + facetsOutputDir?: string, + onProgress?: InsightProgressCallback, + ): Promise { + const MAX_ELIGIBLE_SESSIONS = 50; + + // Sort files by recency (descending), then select up to 50 conversational + // sessions (must contain both user and assistant records). + const sortedFiles = [...allFiles].sort((a, b) => b.mtime - a.mtime); + const eligibleSessions: Array<{ + fileInfo: { path: string; mtime: number }; + records: ChatRecord[]; + }> = []; + + for (const fileInfo of sortedFiles) { + if (eligibleSessions.length >= MAX_ELIGIBLE_SESSIONS) { + break; + } + + try { + const records = await readJsonlFile(fileInfo.path); + if (!this.hasUserAndAssistantRecords(records)) { + continue; + } + eligibleSessions.push({ fileInfo, records }); + } catch (e) { + logger.error( + `Error reading session file ${fileInfo.path} for facet eligibility:`, + e, + ); + } + } + + logger.info( + `Analyzing ${eligibleSessions.length} eligible recent sessions with LLM...`, + ); + + // Create a limit function with concurrency of 4 to avoid 429 errors + const limit = pLimit(CONCURRENCY_LIMIT); + + let completed = 0; + const total = eligibleSessions.length; + + // Analyze sessions concurrently with limit + const analysisPromises = eligibleSessions.map(({ fileInfo, records }) => + limit(async () => { + try { + // Check if we already have this session analyzed + if (records.length > 0 && facetsOutputDir) { + const sessionId = records[0].sessionId; + if (sessionId) { + const existingFacetPath = path.join( + facetsOutputDir, + `${sessionId}.json`, + ); + try { + // Check if file exists and is readable + const existingData = await fs.readFile( + existingFacetPath, + 'utf-8', + ); + const existingFacet = JSON.parse(existingData); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress( + 'Analyzing sessions', + percent, + `${completed}/${total}`, + ); + } + return existingFacet; + } catch (readError) { + // File doesn't exist or is invalid, proceed to analyze + if ((readError as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn( + `Failed to read existing facet for ${sessionId}, regenerating:`, + readError, + ); + } + } + } + } + + const facet = await this.analyzeSession(records); + + if (facet && facetsOutputDir) { + try { + const facetPath = path.join( + facetsOutputDir, + `${facet.session_id}.json`, + ); + await fs.writeFile( + facetPath, + JSON.stringify(facet, null, 2), + 'utf-8', + ); + } catch (writeError) { + logger.error( + `Failed to write facet file for session ${facet.session_id}:`, + writeError, + ); + } + } + + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } + + return facet; + } catch (e) { + logger.error(`Error analyzing session file ${fileInfo.path}:`, e); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } + return null; + } + }), + ); + + const sessionFacetsWithNulls = await Promise.all(analysisPromises); + const facets = sessionFacetsWithNulls.filter( + (f): f is SessionFacets => f !== null, + ); + return facets; + } +} diff --git a/apps/airiscode-cli/src/services/insight/generators/StaticInsightGenerator.ts b/apps/airiscode-cli/src/services/insight/generators/StaticInsightGenerator.ts new file mode 100644 index 000000000..371c72369 --- /dev/null +++ b/apps/airiscode-cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 AIRIS Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { DataProcessor } from './DataProcessor.js'; +import { TemplateRenderer } from './TemplateRenderer.js'; +import type { + InsightData, + InsightProgressCallback, +} from '../types/StaticInsightTypes.js'; + +import { updateSymlink, Storage, type Config } from '@airiscode/core'; + +export class StaticInsightGenerator { + private dataProcessor: DataProcessor; + private templateRenderer: TemplateRenderer; + + constructor(config: Config) { + this.dataProcessor = new DataProcessor(config); + this.templateRenderer = new TemplateRenderer(); + } + + // Ensure the output directory exists + private async ensureOutputDirectory(): Promise { + const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights'); + await fs.mkdir(outputDir, { recursive: true }); + return outputDir; + } + + // Generate timestamped filename with collision detection + private async generateOutputPath(outputDir: string): Promise { + const now = new Date(); + const date = now.toISOString().split('T')[0]; // YYYY-MM-DD + const time = now.toTimeString().slice(0, 8).replace(/:/g, ''); // HHMMSS + + let outputPath = path.join(outputDir, `insight-${date}.html`); + + // Check if date-only file exists, if so, add timestamp + try { + await fs.access(outputPath); + // File exists, use timestamped version + outputPath = path.join(outputDir, `insight-${date}-${time}.html`); + } catch { + // File doesn't exist, use date-only name + } + + return outputPath; + } + + private async updateInsightSymlink( + outputDir: string, + targetPath: string, + ): Promise { + const latestPath = path.join(outputDir, 'insight.html'); + await updateSymlink(latestPath, targetPath); + } + + // Generate the static insight HTML file + async generateStaticInsight( + baseDir: string, + onProgress?: InsightProgressCallback, + ): Promise { + // Ensure output directory exists + const outputDir = await this.ensureOutputDirectory(); + const facetsDir = path.join(outputDir, 'facets'); + await fs.mkdir(facetsDir, { recursive: true }); + + // Process data + const insights: InsightData = await this.dataProcessor.generateInsights( + baseDir, + facetsDir, + onProgress, + ); + + // Render HTML + const html = await this.templateRenderer.renderInsightHTML(insights); + + // Generate timestamped output path + const outputPath = await this.generateOutputPath(outputDir); + + // Write the HTML file + await fs.writeFile(outputPath, html, 'utf-8'); + + await this.updateInsightSymlink(outputDir, outputPath); + + return outputPath; + } +} diff --git a/apps/airiscode-cli/src/services/insight/generators/TemplateRenderer.ts b/apps/airiscode-cli/src/services/insight/generators/TemplateRenderer.ts new file mode 100644 index 000000000..d516d19d7 --- /dev/null +++ b/apps/airiscode-cli/src/services/insight/generators/TemplateRenderer.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 AIRIS Code + * SPDX-License-Identifier: Apache-2.0 + */ + +const INSIGHT_JS = ''; +const INSIGHT_CSS = ''; +import type { InsightData } from '../types/StaticInsightTypes.js'; + +export class TemplateRenderer { + // Render the complete HTML file + async renderInsightHTML(insights: InsightData): Promise { + const html = ` + + + + + AIRIS Code Insights + + + +
+
+
+
+
+ + + + + + + + + + + + + + + +`; + + return html; + } +} diff --git a/apps/airiscode-cli/src/services/insight/types/QualitativeInsightTypes.ts b/apps/airiscode-cli/src/services/insight/types/QualitativeInsightTypes.ts new file mode 100644 index 000000000..aa9bea169 --- /dev/null +++ b/apps/airiscode-cli/src/services/insight/types/QualitativeInsightTypes.ts @@ -0,0 +1,82 @@ +export interface InsightImpressiveWorkflows { + intro: string; + impressive_workflows: Array<{ + title: string; + description: string; + }>; +} + +export interface InsightProjectAreas { + areas: Array<{ + name: string; + session_count: number; + description: string; + }>; +} + +export interface InsightFutureOpportunities { + intro: string; + opportunities: Array<{ + title: string; + whats_possible: string; + how_to_try: string; + copyable_prompt: string; + }>; +} + +export interface InsightFrictionPoints { + intro: string; + categories: Array<{ + category: string; + description: string; + examples: string[]; + }>; +} + +export interface InsightMemorableMoment { + headline: string; + detail: string; +} + +export interface InsightImprovements { + Qwen_md_additions: Array<{ + addition: string; + why: string; + prompt_scaffold: string; + }>; + features_to_try: Array<{ + feature: string; + one_liner: string; + why_for_you: string; + example_code: string; + }>; + usage_patterns: Array<{ + title: string; + suggestion: string; + detail: string; + copyable_prompt: string; + }>; +} + +export interface InsightInteractionStyle { + narrative: string; + key_pattern: string; +} + +export interface InsightAtAGlance { + whats_working: string; + whats_hindering: string; + quick_wins: string; + ambitious_workflows: string; +} + +export interface QualitativeInsights { + impressiveWorkflows?: InsightImpressiveWorkflows; + projectAreas?: InsightProjectAreas; + futureOpportunities?: InsightFutureOpportunities; + frictionPoints?: InsightFrictionPoints; + memorableMoment?: InsightMemorableMoment; + improvements?: InsightImprovements; + interactionStyle?: InsightInteractionStyle; + atAGlance?: InsightAtAGlance; +} diff --git a/apps/airiscode-cli/src/services/insight/types/StaticInsightTypes.ts b/apps/airiscode-cli/src/services/insight/types/StaticInsightTypes.ts new file mode 100644 index 000000000..f1cee1b36 --- /dev/null +++ b/apps/airiscode-cli/src/services/insight/types/StaticInsightTypes.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 AIRIS Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QualitativeInsights } from './QualitativeInsightTypes.js'; + +export interface HeatMapData { + [date: string]: number; +} + +export interface InsightData { + heatmap: HeatMapData; + currentStreak: number; + longestStreak: number; + longestWorkDate: string | null; + longestWorkDuration: number; // in minutes + activeHours: { [hour: number]: number }; + latestActiveTime: string | null; + totalSessions?: number; + totalMessages?: number; + totalHours?: number; + totalLinesAdded?: number; + totalLinesRemoved?: number; + totalFiles?: number; + topTools?: Array<[string, number]>; + qualitative?: QualitativeInsights; + satisfaction?: Record; + friction?: Record; + primarySuccess?: Record; + outcomes?: Record; + topGoals?: Record; +} + +export interface StreakData { + currentStreak: number; + longestStreak: number; + dates: string[]; +} + +export interface SessionFacets { + session_id: string; + underlying_goal: string; + goal_categories: Record; + outcome: + | 'fully_achieved' + | 'mostly_achieved' + | 'partially_achieved' + | 'not_achieved' + | 'unclear_from_transcript'; + user_satisfaction_counts: Record; + Qwen_helpfulness: + | 'unhelpful' + | 'slightly_helpful' + | 'moderately_helpful' + | 'very_helpful' + | 'essential'; + session_type: + | 'single_task' + | 'multi_task' + | 'iterative_refinement' + | 'exploration' + | 'quick_question'; + friction_counts: Record; + friction_detail: string; + primary_success: + | 'none' + | 'fast_accurate_search' + | 'correct_code_edits' + | 'good_explanations' + | 'proactive_help' + | 'multi_file_changes' + | 'good_debugging'; + brief_summary: string; +} + +export interface StaticInsightTemplateData { + styles: string; + content: string; + data: InsightData; + scripts: string; + generatedTime: string; +} + +export type InsightProgressCallback = ( + stage: string, + progress: number, + detail?: string, +) => void; diff --git a/apps/airiscode-cli/src/services/markdown-command-parser.ts b/apps/airiscode-cli/src/services/markdown-command-parser.ts new file mode 100644 index 000000000..5fe1a3c04 --- /dev/null +++ b/apps/airiscode-cli/src/services/markdown-command-parser.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { + parse as parseYaml, + normalizeContent, +} from '@airiscode/core'; + +/** + * Defines the Zod schema for a Markdown command definition file. + * The frontmatter contains optional metadata, and the body is the prompt. + */ +export const MarkdownCommandDefSchema = z.object({ + frontmatter: z + .object({ + description: z.string().optional(), + }) + .optional(), + prompt: z.string({ + required_error: 'The prompt content is required.', + invalid_type_error: 'The prompt content must be a string.', + }), +}); + +export type MarkdownCommandDef = z.infer; + +/** + * Parses a Markdown command file with optional YAML frontmatter. + * @param content The file content + * @returns Parsed command definition with frontmatter and prompt + */ +export function parseMarkdownCommand(content: string): MarkdownCommandDef { + const normalizedContent = normalizeContent(content); + + // Match YAML frontmatter pattern: ---\n...\n---\n + // Allow empty frontmatter: ---\n---\n + const frontmatterRegex = /^---\n(?:([\s\S]*?)\n)?---(?:\n|$)([\s\S]*)$/; + const match = normalizedContent.match(frontmatterRegex); + + if (!match) { + // No frontmatter, entire content is the prompt + return { + prompt: normalizedContent.trim(), + }; + } + + const [, frontmatterYaml = '', body] = match; + + // Parse YAML frontmatter if not empty + let frontmatter: Record | undefined; + if (frontmatterYaml.trim()) { + try { + frontmatter = parseYaml(frontmatterYaml) as Record; + } catch (error) { + throw new Error( + `Failed to parse YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return { + frontmatter, + prompt: body.trim(), + }; +} diff --git a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/argumentProcessor.ts b/apps/airiscode-cli/src/services/prompt-processors/argumentProcessor.ts similarity index 92% rename from apps/airiscode-cli/src/gemini-base/services/prompt-processors/argumentProcessor.ts rename to apps/airiscode-cli/src/services/prompt-processors/argumentProcessor.ts index b1ea2df62..fa848ccce 100644 --- a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/argumentProcessor.ts +++ b/apps/airiscode-cli/src/services/prompt-processors/argumentProcessor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { appendToLastTextPart } from '@airiscode/gemini-cli-core'; +import { appendToLastTextPart } from '@airiscode/core'; import type { IPromptProcessor, PromptPipelineContent } from './types.js'; import type { CommandContext } from '../../ui/commands/types.js'; diff --git a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/atFileProcessor.ts b/apps/airiscode-cli/src/services/prompt-processors/atFileProcessor.ts similarity index 88% rename from apps/airiscode-cli/src/gemini-base/services/prompt-processors/atFileProcessor.ts rename to apps/airiscode-cli/src/services/prompt-processors/atFileProcessor.ts index 9172d7168..d77221a77 100644 --- a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/atFileProcessor.ts +++ b/apps/airiscode-cli/src/services/prompt-processors/atFileProcessor.ts @@ -5,10 +5,10 @@ */ import { - debugLogger, flatMapTextParts, readPathFromWorkspace, -} from '@airiscode/gemini-cli-core'; + createDebugLogger, +} from '@airiscode/core'; import type { CommandContext } from '../../ui/commands/types.js'; import { MessageType } from '../../ui/types.js'; import { @@ -18,6 +18,8 @@ import { } from './types.js'; import { extractInjections } from './injectionParser.js'; +const debugLogger = createDebugLogger('AT_FILE_PROCESSOR'); + export class AtFileProcessor implements IPromptProcessor { constructor(private readonly commandName?: string) {} @@ -57,7 +59,7 @@ export class AtFileProcessor implements IPromptProcessor { try { const fileContentParts = await readPathFromWorkspace(pathStr, config); if (fileContentParts.length === 0) { - const uiMessage = `File '@{${pathStr}}' was ignored by .gitignore or .geminiignore and was not included in the prompt.`; + const uiMessage = `File '@{${pathStr}}' was ignored by .gitignore or .airiscodeigenore and was not included in the prompt.`; context.ui.addItem( { type: MessageType.INFO, text: uiMessage }, Date.now(), @@ -69,9 +71,8 @@ export class AtFileProcessor implements IPromptProcessor { error instanceof Error ? error.message : String(error); const uiMessage = `Failed to inject content for '@{${pathStr}}': ${message}`; - // `context.invocation` should always be present at this point. debugLogger.error( - `Error while loading custom command (${context.invocation!.name}) ${uiMessage}. Leaving placeholder in prompt.`, + `[AtFileProcessor] ${uiMessage}. Leaving placeholder in prompt.`, ); context.ui.addItem( { type: MessageType.ERROR, text: uiMessage }, diff --git a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/injectionParser.ts b/apps/airiscode-cli/src/services/prompt-processors/injectionParser.ts similarity index 100% rename from apps/airiscode-cli/src/gemini-base/services/prompt-processors/injectionParser.ts rename to apps/airiscode-cli/src/services/prompt-processors/injectionParser.ts diff --git a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/shellProcessor.ts b/apps/airiscode-cli/src/services/prompt-processors/shellProcessor.ts similarity index 83% rename from apps/airiscode-cli/src/gemini-base/services/prompt-processors/shellProcessor.ts rename to apps/airiscode-cli/src/services/prompt-processors/shellProcessor.ts index dc533a17a..e7788e991 100644 --- a/apps/airiscode-cli/src/gemini-base/services/prompt-processors/shellProcessor.ts +++ b/apps/airiscode-cli/src/services/prompt-processors/shellProcessor.ts @@ -11,7 +11,8 @@ import { getShellConfiguration, ShellExecutionService, flatMapTextParts, -} from '@airiscode/gemini-cli-core'; + checkArgumentSafety, +} from '@airiscode/core'; import type { CommandContext } from '../../ui/commands/types.js'; import type { IPromptProcessor, PromptPipelineContent } from './types.js'; @@ -99,6 +100,16 @@ export class ShellProcessor implements IPromptProcessor { const { shell } = getShellConfiguration(); const userArgsEscaped = escapeShellArg(userArgsRaw, shell); + // Check safety of the value that will be used for $ARGUMENTS (after removing outer quotes) + let userArgsForArgumentsPlaceholder = userArgsRaw.replace( + /^'([\s\S]*?)'$/, + '$1', + ); + const argumentSafety = checkArgumentSafety(userArgsForArgumentsPlaceholder); + if (!argumentSafety.isSafe) { + userArgsForArgumentsPlaceholder = userArgsEscaped; + } + const resolvedInjections: ResolvedShellInjection[] = injections.map( (injection) => { const command = injection.content; @@ -107,10 +118,9 @@ export class ShellProcessor implements IPromptProcessor { return { ...injection, resolvedCommand: undefined }; } - const resolvedCommand = command.replaceAll( - SHORTHAND_ARGS_PLACEHOLDER, - userArgsEscaped, - ); + const resolvedCommand = command + .replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}} + .replaceAll('$ARGUMENTS', userArgsForArgumentsPlaceholder); return { ...injection, resolvedCommand }; }, ); @@ -123,7 +133,13 @@ export class ShellProcessor implements IPromptProcessor { // Security check on the final, escaped command string. const { allAllowed, disallowedCommands, blockReason, isHardDenial } = - checkCommandPermissions(command, config, sessionShellAllowlist); + await checkCommandPermissions(command, config, sessionShellAllowlist); + + // Determine if this command is explicitly auto-approved via PermissionManager + const pm = config.getPermissionManager?.(); + const isAllowedBySettings = pm + ? (await pm.isCommandAllowed(command)) === 'allow' + : false; if (!allAllowed) { if (isHardDenial) { @@ -132,10 +148,17 @@ export class ShellProcessor implements IPromptProcessor { ); } + // If the command is allowed by settings, skip confirmation. + if (isAllowedBySettings) { + continue; + } + // If not a hard denial, respect YOLO mode and auto-approve. - if (config.getApprovalMode() !== ApprovalMode.YOLO) { - disallowedCommands.forEach((uc) => commandsToConfirm.add(uc)); + if (config.getApprovalMode() === ApprovalMode.YOLO) { + continue; } + + disallowedCommands.forEach((uc) => commandsToConfirm.add(uc)); } } @@ -171,7 +194,7 @@ export class ShellProcessor implements IPromptProcessor { config.getTargetDir(), () => {}, new AbortController().signal, - config.getEnableInteractiveShell(), + config.getShouldUseNodePtyShell(), shellExecutionConfig, ); diff --git a/apps/airiscode-cli/src/services/prompt-processors/types.ts b/apps/airiscode-cli/src/services/prompt-processors/types.ts new file mode 100644 index 000000000..9e67396ff --- /dev/null +++ b/apps/airiscode-cli/src/services/prompt-processors/types.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandContext } from '../../ui/commands/types.js'; +import type { PartUnion } from '@airiscode/core'; + +/** + * Defines the input/output type for prompt processors. + */ +export type PromptPipelineContent = PartUnion[]; + +/** + * Defines the interface for a prompt processor, a module that can transform + * a prompt string before it is sent to the model. Processors are chained + * together to create a processing pipeline. + */ +export interface IPromptProcessor { + /** + * Processes a prompt input (which may contain text and multi-modal parts), + * applying a specific transformation as part of a pipeline. + * + * @param prompt The current state of the prompt string. This may have been + * modified by previous processors in the pipeline. + * @param context The full command context, providing access to invocation + * details (like `context.invocation.raw` and `context.invocation.args`), + * application services, and UI handlers. + * @returns A promise that resolves to the transformed prompt string, which + * will be passed to the next processor or, if it's the last one, sent to the model. + */ + process( + prompt: PromptPipelineContent, + context: CommandContext, + ): Promise; +} + +/** + * The placeholder string for shorthand argument injection in custom commands. + * When used outside of !{...}, arguments are injected raw. + * When used inside !{...}, arguments are shell-escaped. + */ +export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}'; + +/** + * The trigger string for shell command injection in custom commands. + */ +export const SHELL_INJECTION_TRIGGER = '!{'; + +/** + * The trigger string for at file injection in custom commands. + */ +export const AT_FILE_INJECTION_TRIGGER = '@{'; diff --git a/apps/airiscode-cli/src/services/test-commands/example.md b/apps/airiscode-cli/src/services/test-commands/example.md new file mode 100644 index 000000000..c876a6700 --- /dev/null +++ b/apps/airiscode-cli/src/services/test-commands/example.md @@ -0,0 +1,5 @@ +--- +description: Example markdown command +--- + +This is an example prompt from a markdown file. diff --git a/apps/airiscode-cli/src/gemini-base/services/types.ts b/apps/airiscode-cli/src/services/types.ts similarity index 100% rename from apps/airiscode-cli/src/gemini-base/services/types.ts rename to apps/airiscode-cli/src/services/types.ts diff --git a/apps/airiscode-cli/src/session/session-manager.ts b/apps/airiscode-cli/src/session/session-manager.ts deleted file mode 100644 index 71e7ad627..000000000 --- a/apps/airiscode-cli/src/session/session-manager.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Session management - */ - -// @ts-ignore -import { randomUUID } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs'; -import { join } from 'path'; -import type { SessionInfo, TaskLogEntry } from '../types.js'; -import type { PolicyProfile } from '@airiscode/policies'; -import { config } from '../utils/config.js'; - -/** - * Session manager - */ -export class SessionManager { - private sessionDir: string; - private currentSession?: SessionInfo; - private taskLogs: TaskLogEntry[] = []; - - constructor() { - this.sessionDir = config.get('sessionDir'); - this.ensureSessionDir(); - } - - /** - * Create new session - */ - createSession(options: { - name?: string; - workingDir: string; - driver: string; - adapter: string; - policy: PolicyProfile; - }): SessionInfo { - const session: SessionInfo = { - id: randomUUID(), - name: options.name, - workingDir: options.workingDir, - driver: options.driver, - adapter: options.adapter, - policy: options.policy, - createdAt: new Date(), - lastActiveAt: new Date(), - status: 'active', - taskCount: 0, - }; - - this.currentSession = session; - this.saveSession(session); - - return session; - } - - /** - * Get current session - */ - getCurrentSession(): SessionInfo | undefined { - return this.currentSession; - } - - /** - * Load session by ID - */ - loadSession(sessionId: string): SessionInfo | null { - const sessionPath = join(this.sessionDir, `${sessionId}.json`); - - if (!existsSync(sessionPath)) { - return null; - } - - try { - const data = readFileSync(sessionPath, 'utf-8'); - const session = JSON.parse(data) as SessionInfo; - - // Convert date strings back to Date objects - session.createdAt = new Date(session.createdAt); - session.lastActiveAt = new Date(session.lastActiveAt); - - this.currentSession = session; - return session; - } catch (error) { - console.error(`Failed to load session ${sessionId}:`, error); - return null; - } - } - - /** - * Save session - */ - saveSession(session: SessionInfo): void { - const sessionPath = join(this.sessionDir, `${session.id}.json`); - - try { - writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf-8'); - } catch (error) { - console.error(`Failed to save session ${session.id}:`, error); - } - } - - /** - * Update session status - */ - updateStatus(status: SessionInfo['status']): void { - if (!this.currentSession) { - throw new Error('No active session'); - } - - this.currentSession.status = status; - this.currentSession.lastActiveAt = new Date(); - this.saveSession(this.currentSession); - } - - /** - * Increment task count - */ - incrementTaskCount(): void { - if (!this.currentSession) { - throw new Error('No active session'); - } - - this.currentSession.taskCount++; - this.currentSession.lastActiveAt = new Date(); - this.saveSession(this.currentSession); - } - - /** - * List all sessions - */ - listSessions(): SessionInfo[] { - const sessions: SessionInfo[] = []; - - try { - const files = readdirSync(this.sessionDir); - - for (const file of files) { - if (file.endsWith('.json')) { - const sessionPath = join(this.sessionDir, file); - const data = readFileSync(sessionPath, 'utf-8'); - const session = JSON.parse(data) as SessionInfo; - - // Convert date strings - session.createdAt = new Date(session.createdAt); - session.lastActiveAt = new Date(session.lastActiveAt); - - sessions.push(session); - } - } - } catch (error) { - console.error('Failed to list sessions:', error); - } - - // Sort by last active (most recent first) - return sessions.sort((a, b) => - b.lastActiveAt.getTime() - a.lastActiveAt.getTime() - ); - } - - /** - * Delete session - */ - deleteSession(sessionId: string): boolean { - const sessionPath = join(this.sessionDir, `${sessionId}.json`); - - if (!existsSync(sessionPath)) { - return false; - } - - try { - unlinkSync(sessionPath); - - if (this.currentSession?.id === sessionId) { - this.currentSession = undefined; - this.taskLogs = []; - } - - return true; - } catch (error) { - console.error(`Failed to delete session ${sessionId}:`, error); - return false; - } - } - - /** - * Clean old sessions - */ - cleanOldSessions(daysOld: number = 30): number { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - daysOld); - - const sessions = this.listSessions(); - let deletedCount = 0; - - for (const session of sessions) { - if (session.status === 'completed' || session.status === 'failed') { - if (session.lastActiveAt < cutoffDate) { - if (this.deleteSession(session.id)) { - deletedCount++; - } - } - } - } - - return deletedCount; - } - - /** - * Add task log - */ - addLog(entry: Omit): void { - const log: TaskLogEntry = { - ...entry, - timestamp: new Date(), - }; - - this.taskLogs.push(log); - } - - /** - * Get task logs - */ - getLogs(): TaskLogEntry[] { - return [...this.taskLogs]; - } - - /** - * Clear task logs - */ - clearLogs(): void { - this.taskLogs = []; - } - - /** - * Ensure session directory exists - */ - private ensureSessionDir(): void { - if (!existsSync(this.sessionDir)) { - mkdirSync(this.sessionDir, { recursive: true }); - } - } -} diff --git a/apps/airiscode-cli/src/sessionStorage.ts b/apps/airiscode-cli/src/sessionStorage.ts deleted file mode 100644 index e16cd62d5..000000000 --- a/apps/airiscode-cli/src/sessionStorage.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @license - * Copyright 2025 AIRIS Code - * SPDX-License-Identifier: MIT - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; - -export interface Message { - role: "user" | "assistant"; - content: string; - timestamp: number; -} - -export interface SessionMetadata { - sessionId: string; - startedAt: number; - messageCount: number; -} - -export class SessionStorage { - private sessionId: string; - private sessionDir: string; - private sessionFile: string; - - constructor(sessionId?: string) { - this.sessionId = sessionId || `session-${Date.now()}`; - this.sessionDir = path.join(os.homedir(), ".airiscode", "sessions"); - this.sessionFile = path.join(this.sessionDir, `${this.sessionId}.jsonl`); - - // Ensure sessions directory exists - if (!fs.existsSync(this.sessionDir)) { - fs.mkdirSync(this.sessionDir, { recursive: true }); - } - } - - getSessionId(): string { - return this.sessionId; - } - - appendMessage(message: Message): void { - const line = JSON.stringify(message) + "\n"; - fs.appendFileSync(this.sessionFile, line, "utf8"); - } - - loadMessages(): Message[] { - if (!fs.existsSync(this.sessionFile)) { - return []; - } - - const content = fs.readFileSync(this.sessionFile, "utf8"); - const lines = content.trim().split("\n").filter((line) => line.length > 0); - - return lines.map((line) => JSON.parse(line) as Message); - } - - listSessions(): SessionMetadata[] { - if (!fs.existsSync(this.sessionDir)) { - return []; - } - - const files = fs.readdirSync(this.sessionDir); - const sessions: SessionMetadata[] = []; - - for (const file of files) { - if (!file.endsWith(".jsonl")) continue; - - const filePath = path.join(this.sessionDir, file); - const content = fs.readFileSync(filePath, "utf8"); - const lines = content.trim().split("\n").filter((line) => line.length > 0); - - if (lines.length === 0) continue; - - const firstMessage = JSON.parse(lines[0]) as Message; - const sessionId = file.replace(".jsonl", ""); - - sessions.push({ - sessionId, - startedAt: firstMessage.timestamp, - messageCount: lines.length, - }); - } - - // Sort by most recent first - return sessions.sort((a, b) => b.startedAt - a.startedAt); - } - - deleteSession(): void { - if (fs.existsSync(this.sessionFile)) { - fs.unlinkSync(this.sessionFile); - } - } -} diff --git a/apps/airiscode-cli/src/gemini-base/test-utils/customMatchers.ts b/apps/airiscode-cli/src/test-utils/customMatchers.ts similarity index 100% rename from apps/airiscode-cli/src/gemini-base/test-utils/customMatchers.ts rename to apps/airiscode-cli/src/test-utils/customMatchers.ts diff --git a/apps/airiscode-cli/src/gemini-base/test-utils/mockCommandContext.ts b/apps/airiscode-cli/src/test-utils/mockCommandContext.ts similarity index 80% rename from apps/airiscode-cli/src/gemini-base/test-utils/mockCommandContext.ts rename to apps/airiscode-cli/src/test-utils/mockCommandContext.ts index 60e47323c..4901aa121 100644 --- a/apps/airiscode-cli/src/gemini-base/test-utils/mockCommandContext.ts +++ b/apps/airiscode-cli/src/test-utils/mockCommandContext.ts @@ -7,8 +7,9 @@ import { vi } from 'vitest'; import type { CommandContext } from '../ui/commands/types.js'; import type { LoadedSettings } from '../config/settings.js'; -import type { GitService } from '@airiscode/gemini-cli-core'; +import type { GitService } from '@airiscode/core'; import type { SessionStatsState } from '../ui/contexts/SessionContext.js'; +import { ToolCallDecision } from '../ui/contexts/SessionContext.js'; // A utility type to make all properties of an object, and its nested objects, partial. type DeepPartial = T extends object @@ -35,7 +36,10 @@ export const createMockCommandContext = ( }, services: { config: null, - settings: { merged: {} } as LoadedSettings, + settings: { + merged: {}, + setValue: vi.fn(), + } as unknown as LoadedSettings, git: undefined as GitService | undefined, logger: { log: vi.fn(), @@ -51,16 +55,22 @@ export const createMockCommandContext = ( setDebugMessage: vi.fn(), pendingItem: null, setPendingItem: vi.fn(), + btwItem: null, + setBtwItem: vi.fn(), + cancelBtw: vi.fn(), + btwAbortControllerRef: { current: null }, loadHistory: vi.fn(), - toggleCorgiMode: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), + reloadCommands: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { sessionShellAllowlist: new Set(), + startNewSession: vi.fn(), stats: { + sessionId: '', sessionStartTime: new Date(), lastPromptTokenCount: 0, metrics: { @@ -70,10 +80,17 @@ export const createMockCommandContext = ( totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { + [ToolCallDecision.ACCEPT]: 0, + [ToolCallDecision.REJECT]: 0, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, + }, byName: {}, }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, }, + promptCount: 0, } as SessionStatsState, }, }; diff --git a/apps/airiscode-cli/src/test-utils/render.tsx b/apps/airiscode-cli/src/test-utils/render.tsx new file mode 100644 index 000000000..a827a9590 --- /dev/null +++ b/apps/airiscode-cli/src/test-utils/render.tsx @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import type React from 'react'; +import type { Config } from '@airiscode/core'; +import { LoadedSettings } from '../config/settings.js'; +import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; +import { SettingsContext } from '../ui/contexts/SettingsContext.js'; +import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; +import { ConfigContext } from '../ui/contexts/ConfigContext.js'; + +const mockSettings = new LoadedSettings( + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + true, + new Set(), +); + +export const renderWithProviders = ( + component: React.ReactElement, + { + shellFocus = true, + settings = mockSettings, + config = undefined, + }: { + shellFocus?: boolean; + settings?: LoadedSettings; + config?: Config; + } = {}, +): ReturnType => + render( + + + + + {component} + + + + , + ); diff --git a/apps/airiscode-cli/src/types.ts b/apps/airiscode-cli/src/types.ts deleted file mode 100644 index fff10f5ff..000000000 --- a/apps/airiscode-cli/src/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * CLI types - */ - -import type { PolicyProfile } from '@airiscode/policies'; - -/** - * CLI configuration - */ -export interface CLIConfig { - /** Default driver */ - defaultDriver: string; - /** Default adapter */ - defaultAdapter: string; - /** Default policy profile */ - defaultPolicy: PolicyProfile; - /** MCP Gateway URL */ - mcpGatewayUrl?: string; - /** Session storage directory */ - sessionDir: string; - /** Enable telemetry */ - telemetry: boolean; -} - -/** - * Session info - */ -export interface SessionInfo { - /** Session ID */ - id: string; - /** Session name */ - name?: string; - /** Working directory */ - workingDir: string; - /** Driver used */ - driver: string; - /** Adapter used */ - adapter: string; - /** Policy profile */ - policy: PolicyProfile; - /** Created timestamp */ - createdAt: Date; - /** Last active timestamp */ - lastActiveAt: Date; - /** Session status */ - status: 'active' | 'paused' | 'completed' | 'failed'; - /** Task count */ - taskCount: number; -} - -/** - * Task log entry - */ -export interface TaskLogEntry { - /** Timestamp */ - timestamp: Date; - /** Log level */ - level: 'info' | 'warn' | 'error' | 'debug'; - /** Source */ - source: 'driver' | 'adapter' | 'runner' | 'system'; - /** Message */ - message: string; - /** Additional data */ - data?: Record; -} - -/** - * Code command options - */ -export interface CodeCommandOptions { - /** Driver to use */ - driver?: string; - /** Adapter to use */ - adapter?: string; - /** Policy profile */ - policy?: 'restricted' | 'sandboxed' | 'untrusted'; - /** Working directory */ - cwd?: string; - /** Session name */ - session?: string; - /** JSON output mode */ - json?: boolean; - /** Verbose output */ - verbose?: boolean; -} - -/** - * Config command options - */ -export interface ConfigCommandOptions { - /** Get config value */ - get?: string; - /** Set config value */ - set?: string; - /** List all config */ - list?: boolean; - /** Reset to defaults */ - reset?: boolean; -} - -/** - * Session command options - */ -export interface SessionCommandOptions { - /** List sessions */ - list?: boolean; - /** Show session details */ - show?: string; - /** Resume session */ - resume?: string; - /** Delete session */ - delete?: string; - /** Clean old sessions */ - clean?: boolean; -} diff --git a/apps/airiscode-cli/src/ui-full/App.tsx b/apps/airiscode-cli/src/ui-full/App.tsx deleted file mode 100644 index 2c3e424ae..000000000 --- a/apps/airiscode-cli/src/ui-full/App.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useIsScreenReaderEnabled } from 'ink'; -import { useUIState } from './contexts/UIStateContext.js'; -import { StreamingContext } from './contexts/StreamingContext.js'; -import { QuittingDisplay } from './components/QuittingDisplay.js'; -import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js'; -import { DefaultAppLayout } from './layouts/DefaultAppLayout.js'; -import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js'; -import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; - -export const App = () => { - const uiState = useUIState(); - const isAlternateBuffer = useAlternateBuffer(); - const isScreenReaderEnabled = useIsScreenReaderEnabled(); - - if (uiState.quittingMessages) { - if (isAlternateBuffer) { - return ( - - - - ); - } else { - return ; - } - } - - return ( - - {isScreenReaderEnabled ? : } - - ); -}; diff --git a/apps/airiscode-cli/src/ui-full/AppContainer.tsx b/apps/airiscode-cli/src/ui-full/AppContainer.tsx deleted file mode 100644 index b9fcda9d1..000000000 --- a/apps/airiscode-cli/src/ui-full/AppContainer.tsx +++ /dev/null @@ -1,1518 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - useMemo, - useState, - useCallback, - useEffect, - useRef, - useLayoutEffect, -} from 'react'; -import { type DOMElement, measureElement } from 'ink'; -import { App } from './App.js'; -import { AppContext } from './contexts/AppContext.js'; -import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; -import { - UIActionsContext, - type UIActions, -} from './contexts/UIActionsContext.js'; -import { ConfigContext } from './contexts/ConfigContext.js'; -import { - type HistoryItem, - ToolCallStatus, - type HistoryItemWithoutId, - AuthState, -} from './types.js'; -import { MessageType, StreamingState } from './types.js'; -import { - type EditorType, - type Config, - type IdeInfo, - type IdeContext, - type UserTierId, - type UserFeedbackPayload, - DEFAULT_GEMINI_FLASH_MODEL, - IdeClient, - ideContextStore, - getErrorMessage, - getAllGeminiMdFilenames, - AuthType, - clearCachedCredentialFile, - type ResumedSessionData, - recordExitFail, - ShellExecutionService, - saveApiKey, - debugLogger, - coreEvents, - CoreEvent, - refreshServerHierarchicalMemory, - type ModelChangedPayload, - type MemoryChangedPayload, -} from '@airiscode/gemini-cli-core'; -import { validateAuthMethod } from '../config/auth.js'; -import process from 'node:process'; -import { useHistory } from './hooks/useHistoryManager.js'; -import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; -import { useThemeCommand } from './hooks/useThemeCommand.js'; -import { useAuthCommand } from './auth/useAuth.js'; -import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; -import { useEditorSettings } from './hooks/useEditorSettings.js'; -import { useSettingsCommand } from './hooks/useSettingsCommand.js'; -import { useModelCommand } from './hooks/useModelCommand.js'; -import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; -import { useVimMode } from './contexts/VimModeContext.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; -import { useTerminalSize } from './hooks/useTerminalSize.js'; -import { calculatePromptWidths } from './components/InputPrompt.js'; -import { useStdout, useStdin } from 'ink'; -import { calculateMainAreaWidth } from './utils/ui-sizing.js'; -import ansiEscapes from 'ansi-escapes'; -import * as fs from 'node:fs'; -import { basename } from 'node:path'; -import { computeWindowTitle } from '../utils/windowTitle.js'; -import { useTextBuffer } from './components/shared/text-buffer.js'; -import { useLogger } from './hooks/useLogger.js'; -import { useGeminiStream } from './hooks/useGeminiStream.js'; -import { useVim } from './hooks/vim.js'; -import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; -import { type InitializationResult } from '../core/initializer.js'; -import { useFocus } from './hooks/useFocus.js'; -import { useBracketedPaste } from './hooks/useBracketedPaste.js'; -import { useKeypress, type Key } from './hooks/useKeypress.js'; -import { keyMatchers, Command } from './keyMatchers.js'; -import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; -import { useFolderTrust } from './hooks/useFolderTrust.js'; -import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; -import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; -import { appEvents, AppEvent } from '../utils/events.js'; -import { type UpdateObject } from './utils/updateCheck.js'; -import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; -import { ConsolePatcher } from './utils/ConsolePatcher.js'; -import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; -import { useMessageQueue } from './hooks/useMessageQueue.js'; -import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; -import { useSessionStats } from './contexts/SessionContext.js'; -import { useGitBranchName } from './hooks/useGitBranchName.js'; -import { - useConfirmUpdateRequests, - useExtensionUpdates, -} from './hooks/useExtensionUpdates.js'; -import { ShellFocusContext } from './contexts/ShellFocusContext.js'; -import { useSessionResume } from './hooks/useSessionResume.js'; -import { type ExtensionManager } from '../config/extension-manager.js'; -import { requestConsentInteractive } from '../config/extensions/consent.js'; -import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; -import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; -import { useSettings } from './contexts/SettingsContext.js'; - -const CTRL_EXIT_PROMPT_DURATION_MS = 1000; -const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; - -function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { - return pendingHistoryItems.some((item) => { - if (item && item.type === 'tool_group') { - return item.tools.some( - (tool) => ToolCallStatus.Executing === tool.status, - ); - } - return false; - }); -} - -interface AppContainerProps { - config: Config; - startupWarnings?: string[]; - version: string; - initializationResult: InitializationResult; - resumedSessionData?: ResumedSessionData; -} - -/** - * The fraction of the terminal width to allocate to the shell. - * This provides horizontal padding. - */ -const SHELL_WIDTH_FRACTION = 0.89; - -/** - * The number of lines to subtract from the available terminal height - * for the shell. This provides vertical padding and space for other UI elements. - */ -const SHELL_HEIGHT_PADDING = 10; - -export const AppContainer = (props: AppContainerProps) => { - const { config, initializationResult, resumedSessionData } = props; - const historyManager = useHistory(); - useMemoryMonitor(historyManager); - const settings = useSettings(); - const isAlternateBuffer = useAlternateBuffer(); - const [corgiMode, setCorgiMode] = useState(false); - const [debugMessage, setDebugMessage] = useState(''); - const [quittingMessages, setQuittingMessages] = useState< - HistoryItem[] | null - >(null); - const [showPrivacyNotice, setShowPrivacyNotice] = useState(false); - const [themeError, setThemeError] = useState( - initializationResult.themeError, - ); - const [isProcessing, setIsProcessing] = useState(false); - const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); - const [showDebugProfiler, setShowDebugProfiler] = useState(false); - const [copyModeEnabled, setCopyModeEnabled] = useState(false); - - const [shellModeActive, setShellModeActive] = useState(false); - const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = - useState(false); - const [historyRemountKey, setHistoryRemountKey] = useState(0); - const [updateInfo, setUpdateInfo] = useState(null); - const [isTrustedFolder, setIsTrustedFolder] = useState( - config.isTrustedFolder(), - ); - - const [queueErrorMessage, setQueueErrorMessage] = useState( - null, - ); - - const extensionManager = config.getExtensionLoader() as ExtensionManager; - // We are in the interactive CLI, update how we request consent and settings. - extensionManager.setRequestConsent((description) => - requestConsentInteractive(description, addConfirmUpdateExtensionRequest), - ); - extensionManager.setRequestSetting(); - - const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } = - useConfirmUpdateRequests(); - const { - extensionsUpdateState, - extensionsUpdateStateInternal, - dispatchExtensionStateUpdate, - } = useExtensionUpdates( - extensionManager, - historyManager.addItem, - config.getEnableExtensionReloading(), - ); - - const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); - const openPermissionsDialog = useCallback( - () => setPermissionsDialogOpen(true), - [], - ); - const closePermissionsDialog = useCallback( - () => setPermissionsDialogOpen(false), - [], - ); - - const toggleDebugProfiler = useCallback( - () => setShowDebugProfiler((prev) => !prev), - [], - ); - - // Helper to determine the effective model, considering the fallback state. - const getEffectiveModel = useCallback(() => { - if (config.isInFallbackMode()) { - return DEFAULT_GEMINI_FLASH_MODEL; - } - return config.getModel(); - }, [config]); - - const [currentModel, setCurrentModel] = useState(getEffectiveModel()); - - const [userTier, setUserTier] = useState(undefined); - - const [isConfigInitialized, setConfigInitialized] = useState(false); - - const logger = useLogger(config.storage); - const [userMessages, setUserMessages] = useState([]); - - // Terminal and layout hooks - const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); - const { stdin, setRawMode } = useStdin(); - const { stdout } = useStdout(); - - // Additional hooks moved from App.tsx - const { stats: sessionStats } = useSessionStats(); - const branchName = useGitBranchName(config.getTargetDir()); - - // Layout measurements - const mainControlsRef = useRef(null); - // For performance profiling only - const rootUiRef = useRef(null); - const originalTitleRef = useRef( - computeWindowTitle(basename(config.getTargetDir())), - ); - const lastTitleRef = useRef(null); - const staticExtraHeight = 3; - - useEffect(() => { - (async () => { - // Note: the program will not work if this fails so let errors be - // handled by the global catch. - await config.initialize(); - setConfigInitialized(true); - })(); - registerCleanup(async () => { - // Turn off mouse scroll. - disableMouseEvents(); - const ideClient = await IdeClient.getInstance(); - await ideClient.disconnect(); - }); - }, [config]); - - useEffect( - () => setUpdateHandler(historyManager.addItem, setUpdateInfo), - [historyManager.addItem], - ); - - // Subscribe to fallback mode and model changes from core - useEffect(() => { - const handleFallbackModeChanged = () => { - const effectiveModel = getEffectiveModel(); - setCurrentModel(effectiveModel); - }; - - const handleModelChanged = (payload: ModelChangedPayload) => { - setCurrentModel(payload.model); - }; - - coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); - coreEvents.on(CoreEvent.ModelChanged, handleModelChanged); - return () => { - coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); - coreEvents.off(CoreEvent.ModelChanged, handleModelChanged); - }; - }, [getEffectiveModel]); - - const { - consoleMessages, - handleNewMessage, - clearConsoleMessages: clearConsoleMessagesState, - } = useConsoleMessages(); - - useEffect(() => { - const consolePatcher = new ConsolePatcher({ - onNewMessage: handleNewMessage, - debugMode: config.getDebugMode(), - }); - consolePatcher.patch(); - registerCleanup(consolePatcher.cleanup); - }, [handleNewMessage, config]); - - const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); - // Derive widths for InputPrompt using shared helper - const { inputWidth, suggestionsWidth } = useMemo(() => { - const { inputWidth, suggestionsWidth } = - calculatePromptWidths(mainAreaWidth); - return { inputWidth, suggestionsWidth }; - }, [mainAreaWidth]); - - const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); - - const isValidPath = useCallback((filePath: string): boolean => { - try { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - } catch (_e) { - return false; - } - }, []); - - const buffer = useTextBuffer({ - initialText: '', - viewport: { height: 10, width: inputWidth }, - stdin, - setRawMode, - isValidPath, - shellModeActive, - }); - - useEffect(() => { - const fetchUserMessages = async () => { - const pastMessagesRaw = (await logger?.getPreviousUserMessages()) || []; - const currentSessionUserMessages = historyManager.history - .filter( - (item): item is HistoryItem & { type: 'user'; text: string } => - item.type === 'user' && - typeof item.text === 'string' && - item.text.trim() !== '', - ) - .map((item) => item.text) - .reverse(); - const combinedMessages = [ - ...currentSessionUserMessages, - ...pastMessagesRaw, - ]; - const deduplicatedMessages: string[] = []; - if (combinedMessages.length > 0) { - deduplicatedMessages.push(combinedMessages[0]); - for (let i = 1; i < combinedMessages.length; i++) { - if (combinedMessages[i] !== combinedMessages[i - 1]) { - deduplicatedMessages.push(combinedMessages[i]); - } - } - } - setUserMessages(deduplicatedMessages.reverse()); - }; - fetchUserMessages(); - }, [historyManager.history, logger]); - - const refreshStatic = useCallback(() => { - if (!isAlternateBuffer) { - stdout.write(ansiEscapes.clearTerminal); - } - setHistoryRemountKey((prev) => prev + 1); - }, [setHistoryRemountKey, stdout, isAlternateBuffer]); - - const { - isThemeDialogOpen, - openThemeDialog, - closeThemeDialog, - handleThemeSelect, - handleThemeHighlight, - } = useThemeCommand( - settings, - setThemeError, - historyManager.addItem, - initializationResult.themeError, - ); - - const { - authState, - setAuthState, - authError, - onAuthError, - apiKeyDefaultValue, - reloadApiKey, - } = useAuthCommand(settings, config); - - const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ - config, - historyManager, - userTier, - setModelSwitchedFromQuotaError, - }); - - // Derive auth state variables for backward compatibility with UIStateContext - const isAuthDialogOpen = authState === AuthState.Updating; - const isAuthenticating = authState === AuthState.Unauthenticated; - - // Session browser and resume functionality - const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized(); - - useSessionResume({ - config, - historyManager, - refreshStatic, - isGeminiClientInitialized, - setQuittingMessages, - resumedSessionData, - isAuthenticating, - }); - - // Create handleAuthSelect wrapper for backward compatibility - const handleAuthSelect = useCallback( - async (authType: AuthType | undefined, scope: LoadableSettingScope) => { - if (authType) { - await clearCachedCredentialFile(); - settings.setValue(scope, 'security.auth.selectedType', authType); - - try { - await config.refreshAuth(authType); - setAuthState(AuthState.Authenticated); - } catch (e) { - onAuthError( - `Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`, - ); - return; - } - - if ( - authType === AuthType.LOGIN_WITH_GOOGLE && - config.isBrowserLaunchSuppressed() - ) { - await runExitCleanup(); - debugLogger.log(` ----------------------------------------------------------------- -Logging in with Google... Please restart Gemini CLI to continue. ----------------------------------------------------------------- - `); - process.exit(0); - } - } - setAuthState(AuthState.Authenticated); - }, - [settings, config, setAuthState, onAuthError], - ); - - const handleApiKeySubmit = useCallback( - async (apiKey: string) => { - try { - onAuthError(null); - if (!apiKey.trim() && apiKey.length > 1) { - onAuthError( - 'API key cannot be empty string with length greater than 1.', - ); - return; - } - - await saveApiKey(apiKey); - await reloadApiKey(); - await config.refreshAuth(AuthType.USE_GEMINI); - setAuthState(AuthState.Authenticated); - } catch (e) { - onAuthError( - `Failed to save API key: ${e instanceof Error ? e.message : String(e)}`, - ); - } - }, - [setAuthState, onAuthError, reloadApiKey, config], - ); - - const handleApiKeyCancel = useCallback(() => { - // Go back to auth method selection - setAuthState(AuthState.Updating); - }, [setAuthState]); - - // Sync user tier from config when authentication changes - useEffect(() => { - // Only sync when not currently authenticating - if (authState === AuthState.Authenticated) { - setUserTier(config.getUserTier()); - } - }, [config, authState]); - - // Check for enforced auth type mismatch - useEffect(() => { - if ( - settings.merged.security?.auth?.enforcedType && - settings.merged.security?.auth.selectedType && - settings.merged.security?.auth.enforcedType !== - settings.merged.security?.auth.selectedType - ) { - onAuthError( - `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`, - ); - } else if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { - const error = validateAuthMethod( - settings.merged.security.auth.selectedType, - ); - if (error) { - onAuthError(error); - } - } - }, [ - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.enforcedType, - settings.merged.security?.auth?.useExternal, - onAuthError, - ]); - - const [editorError, setEditorError] = useState(null); - const { - isEditorDialogOpen, - openEditorDialog, - handleEditorSelect, - exitEditorDialog, - } = useEditorSettings(settings, setEditorError, historyManager.addItem); - - const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = - useSettingsCommand(); - - const { isModelDialogOpen, openModelDialog, closeModelDialog } = - useModelCommand(); - - const { toggleVimEnabled } = useVimMode(); - - const slashCommandActions = useMemo( - () => ({ - openAuthDialog: () => setAuthState(AuthState.Updating), - openThemeDialog, - openEditorDialog, - openPrivacyNotice: () => setShowPrivacyNotice(true), - openSettingsDialog, - openModelDialog, - openPermissionsDialog, - quit: (messages: HistoryItem[]) => { - setQuittingMessages(messages); - setTimeout(async () => { - await runExitCleanup(); - process.exit(0); - }, 100); - }, - setDebugMessage, - toggleCorgiMode: () => setCorgiMode((prev) => !prev), - toggleDebugProfiler, - dispatchExtensionStateUpdate, - addConfirmUpdateExtensionRequest, - }), - [ - setAuthState, - openThemeDialog, - openEditorDialog, - openSettingsDialog, - openModelDialog, - setQuittingMessages, - setDebugMessage, - setShowPrivacyNotice, - setCorgiMode, - dispatchExtensionStateUpdate, - openPermissionsDialog, - addConfirmUpdateExtensionRequest, - toggleDebugProfiler, - ], - ); - - const { - handleSlashCommand, - slashCommands, - pendingHistoryItems: pendingSlashCommandHistoryItems, - commandContext, - shellConfirmationRequest, - confirmationRequest, - } = useSlashCommandProcessor( - config, - settings, - historyManager.addItem, - historyManager.clearItems, - historyManager.loadHistory, - refreshStatic, - toggleVimEnabled, - setIsProcessing, - slashCommandActions, - extensionsUpdateStateInternal, - isConfigInitialized, - ); - - const performMemoryRefresh = useCallback(async () => { - historyManager.addItem( - { - type: MessageType.INFO, - text: 'Refreshing hierarchical memory (GEMINI.md or other context files)...', - }, - Date.now(), - ); - try { - const { memoryContent, fileCount } = - await refreshServerHierarchicalMemory(config); - - historyManager.addItem( - { - type: MessageType.INFO, - text: `Memory refreshed successfully. ${ - memoryContent.length > 0 - ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).` - : 'No memory content found.' - }`, - }, - Date.now(), - ); - if (config.getDebugMode()) { - debugLogger.log( - `[DEBUG] Refreshed memory content in config: ${memoryContent.substring( - 0, - 200, - )}...`, - ); - } - } catch (error) { - const errorMessage = getErrorMessage(error); - historyManager.addItem( - { - type: MessageType.ERROR, - text: `Error refreshing memory: ${errorMessage}`, - }, - Date.now(), - ); - debugLogger.warn('Error refreshing memory:', error); - } - }, [config, historyManager]); - - const cancelHandlerRef = useRef<() => void>(() => {}); - - const getPreferredEditor = useCallback( - () => settings.merged.general?.preferredEditor as EditorType, - [settings.merged.general?.preferredEditor], - ); - - const onCancelSubmit = useCallback(() => { - cancelHandlerRef.current(); - }, []); - - const { - streamingState, - submitQuery, - initError, - pendingHistoryItems: pendingGeminiHistoryItems, - thought, - cancelOngoingRequest, - handleApprovalModeChange, - activePtyId, - loopDetectionConfirmationRequest, - } = useGeminiStream( - config.getGeminiClient(), - historyManager.history, - historyManager.addItem, - config, - settings, - setDebugMessage, - handleSlashCommand, - shellModeActive, - getPreferredEditor, - onAuthError, - performMemoryRefresh, - modelSwitchedFromQuotaError, - setModelSwitchedFromQuotaError, - refreshStatic, - onCancelSubmit, - setEmbeddedShellFocused, - terminalWidth, - terminalHeight, - embeddedShellFocused, - ); - - // Auto-accept indicator - const showAutoAcceptIndicator = useAutoAcceptIndicator({ - config, - addItem: historyManager.addItem, - onApprovalModeChange: handleApprovalModeChange, - }); - - const { - messageQueue, - addMessage, - clearQueue, - getQueuedMessagesText, - popAllMessages, - } = useMessageQueue({ - isConfigInitialized, - streamingState, - submitQuery, - }); - - cancelHandlerRef.current = useCallback(() => { - const pendingHistoryItems = [ - ...pendingSlashCommandHistoryItems, - ...pendingGeminiHistoryItems, - ]; - if (isToolExecuting(pendingHistoryItems)) { - buffer.setText(''); // Just clear the prompt - return; - } - - const lastUserMessage = userMessages.at(-1); - let textToSet = lastUserMessage || ''; - - const queuedText = getQueuedMessagesText(); - if (queuedText) { - textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText; - clearQueue(); - } - - if (textToSet) { - buffer.setText(textToSet); - } - }, [ - buffer, - userMessages, - getQueuedMessagesText, - clearQueue, - pendingSlashCommandHistoryItems, - pendingGeminiHistoryItems, - ]); - - const handleFinalSubmit = useCallback( - (submittedValue: string) => { - addMessage(submittedValue); - }, - [addMessage], - ); - - const handleClearScreen = useCallback(() => { - historyManager.clearItems(); - clearConsoleMessagesState(); - if (!isAlternateBuffer) { - console.clear(); - } - refreshStatic(); - }, [ - historyManager, - clearConsoleMessagesState, - refreshStatic, - isAlternateBuffer, - ]); - - const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); - - /** - * Determines if the input prompt should be active and accept user input. - * Input is disabled during: - * - Initialization errors - * - Slash command processing - * - Tool confirmations (WaitingForConfirmation state) - * - Any future streaming states not explicitly allowed - */ - const isInputActive = - !initError && - !isProcessing && - !!slashCommands && - (streamingState === StreamingState.Idle || - streamingState === StreamingState.Responding) && - !proQuotaRequest; - - const [controlsHeight, setControlsHeight] = useState(0); - - useLayoutEffect(() => { - if (mainControlsRef.current) { - const fullFooterMeasurement = measureElement(mainControlsRef.current); - if (fullFooterMeasurement.height > 0) { - setControlsHeight(fullFooterMeasurement.height); - } - } - }, [buffer, terminalWidth, terminalHeight]); - - // Compute available terminal height based on controls measurement - const availableTerminalHeight = Math.max( - 0, - terminalHeight - controlsHeight - staticExtraHeight - 2, - ); - - config.setShellExecutionConfig({ - terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - terminalHeight: Math.max( - Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), - 1, - ), - pager: settings.merged.tools?.shell?.pager, - showColor: settings.merged.tools?.shell?.showColor, - }); - - const isFocused = useFocus(); - useBracketedPaste(); - - // Context file names computation - const contextFileNames = useMemo(() => { - const fromSettings = settings.merged.context?.fileName; - return fromSettings - ? Array.isArray(fromSettings) - ? fromSettings - : [fromSettings] - : getAllGeminiMdFilenames(); - }, [settings.merged.context?.fileName]); - // Initial prompt handling - const initialPrompt = useMemo(() => config.getQuestion(), [config]); - const initialPromptSubmitted = useRef(false); - const geminiClient = config.getGeminiClient(); - - useEffect(() => { - if (activePtyId) { - try { - ShellExecutionService.resizePty( - activePtyId, - Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - Math.max( - Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), - 1, - ), - ); - } catch (e) { - // This can happen in a race condition where the pty exits - // right before we try to resize it. - if ( - !( - e instanceof Error && - e.message.includes('Cannot resize a pty that has already exited') - ) - ) { - throw e; - } - } - } - }, [terminalWidth, availableTerminalHeight, activePtyId]); - - useEffect(() => { - if ( - initialPrompt && - isConfigInitialized && - !initialPromptSubmitted.current && - !isAuthenticating && - !isAuthDialogOpen && - !isThemeDialogOpen && - !isEditorDialogOpen && - !showPrivacyNotice && - geminiClient?.isInitialized?.() - ) { - handleFinalSubmit(initialPrompt); - initialPromptSubmitted.current = true; - } - }, [ - initialPrompt, - isConfigInitialized, - handleFinalSubmit, - isAuthenticating, - isAuthDialogOpen, - isThemeDialogOpen, - isEditorDialogOpen, - showPrivacyNotice, - geminiClient, - ]); - - const [idePromptAnswered, setIdePromptAnswered] = useState(false); - const [currentIDE, setCurrentIDE] = useState(null); - - useEffect(() => { - const getIde = async () => { - const ideClient = await IdeClient.getInstance(); - const currentIde = ideClient.getCurrentIde(); - setCurrentIDE(currentIde || null); - }; - getIde(); - }, []); - const shouldShowIdePrompt = Boolean( - currentIDE && - !config.getIdeMode() && - !settings.merged.ide?.hasSeenNudge && - !idePromptAnswered, - ); - - const [showErrorDetails, setShowErrorDetails] = useState(false); - const [showFullTodos, setShowFullTodos] = useState(false); - const [renderMarkdown, setRenderMarkdown] = useState(true); - - const [ctrlCPressCount, setCtrlCPressCount] = useState(0); - const ctrlCTimerRef = useRef(null); - const [ctrlDPressCount, setCtrlDPressCount] = useState(0); - const ctrlDTimerRef = useRef(null); - const [constrainHeight, setConstrainHeight] = useState(true); - const [ideContextState, setIdeContextState] = useState< - IdeContext | undefined - >(); - const [showEscapePrompt, setShowEscapePrompt] = useState(false); - const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); - - const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = - useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); - const { - needsRestart: ideNeedsRestart, - restartReason: ideTrustRestartReason, - } = useIdeTrustListener(); - const isInitialMount = useRef(true); - - useEffect(() => { - if (ideNeedsRestart) { - // IDE trust changed, force a restart. - setShowIdeRestartPrompt(true); - } - }, [ideNeedsRestart]); - - useEffect(() => { - if (queueErrorMessage) { - const timer = setTimeout(() => { - setQueueErrorMessage(null); - }, QUEUE_ERROR_DISPLAY_DURATION_MS); - - return () => clearTimeout(timer); - } - return undefined; - }, [queueErrorMessage, setQueueErrorMessage]); - - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - const handler = setTimeout(() => { - refreshStatic(); - }, 300); - - return () => { - clearTimeout(handler); - }; - }, [terminalWidth, refreshStatic]); - - useEffect(() => { - const unsubscribe = ideContextStore.subscribe(setIdeContextState); - setIdeContextState(ideContextStore.get()); - return unsubscribe; - }, []); - - useEffect(() => { - const openDebugConsole = () => { - setShowErrorDetails(true); - setConstrainHeight(false); - }; - appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole); - - const logErrorHandler = (errorMessage: unknown) => { - handleNewMessage({ - type: 'error', - content: String(errorMessage), - count: 1, - }); - }; - appEvents.on(AppEvent.LogError, logErrorHandler); - - return () => { - appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); - appEvents.off(AppEvent.LogError, logErrorHandler); - }; - }, [handleNewMessage, config]); - - useEffect(() => { - if (ctrlCTimerRef.current) { - clearTimeout(ctrlCTimerRef.current); - ctrlCTimerRef.current = null; - } - if (ctrlCPressCount > 2) { - recordExitFail(config); - } - if (ctrlCPressCount > 1) { - handleSlashCommand('/quit'); - } else { - ctrlCTimerRef.current = setTimeout(() => { - setCtrlCPressCount(0); - ctrlCTimerRef.current = null; - }, CTRL_EXIT_PROMPT_DURATION_MS); - } - }, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]); - - useEffect(() => { - if (ctrlDTimerRef.current) { - clearTimeout(ctrlDTimerRef.current); - ctrlCTimerRef.current = null; - } - if (ctrlDPressCount > 2) { - recordExitFail(config); - } - if (ctrlDPressCount > 1) { - handleSlashCommand('/quit'); - } else { - ctrlDTimerRef.current = setTimeout(() => { - setCtrlDPressCount(0); - ctrlDTimerRef.current = null; - }, CTRL_EXIT_PROMPT_DURATION_MS); - } - }, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]); - - const handleEscapePromptChange = useCallback((showPrompt: boolean) => { - setShowEscapePrompt(showPrompt); - }, []); - - const handleIdePromptComplete = useCallback( - (result: IdeIntegrationNudgeResult) => { - if (result.userSelection === 'yes') { - handleSlashCommand('/ide install'); - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); - } else if (result.userSelection === 'dismiss') { - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); - } - setIdePromptAnswered(true); - }, - [handleSlashCommand, settings], - ); - - const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( - streamingState, - settings.merged.ui?.customWittyPhrases, - ); - - const handleGlobalKeypress = useCallback( - (key: Key) => { - if (copyModeEnabled) { - setCopyModeEnabled(false); - enableMouseEvents(); - // We don't want to process any other keys if we're in copy mode. - return; - } - - // Debug log keystrokes if enabled - if (settings.merged.general?.debugKeystrokeLogging) { - debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); - } - - if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { - setCopyModeEnabled(true); - disableMouseEvents(); - return; - } - - if (keyMatchers[Command.QUIT](key)) { - // If the user presses Ctrl+C, we want to cancel any ongoing requests. - // This should happen regardless of the count. - cancelOngoingRequest?.(); - - setCtrlCPressCount((prev) => prev + 1); - return; - } else if (keyMatchers[Command.EXIT](key)) { - if (buffer.text.length > 0) { - return; - } - setCtrlDPressCount((prev) => prev + 1); - return; - } - - let enteringConstrainHeightMode = false; - if (!constrainHeight) { - enteringConstrainHeightMode = true; - setConstrainHeight(true); - } - - if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { - setShowErrorDetails((prev) => !prev); - } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { - setShowFullTodos((prev) => !prev); - } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { - setRenderMarkdown((prev) => { - const newValue = !prev; - // Force re-render of static content - refreshStatic(); - return newValue; - }); - } else if ( - keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) && - config.getIdeMode() && - ideContextState - ) { - handleSlashCommand('/ide status'); - } else if ( - keyMatchers[Command.SHOW_MORE_LINES](key) && - !enteringConstrainHeightMode - ) { - setConstrainHeight(false); - } else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { - if (activePtyId || embeddedShellFocused) { - setEmbeddedShellFocused((prev) => !prev); - } - } - }, - [ - constrainHeight, - setConstrainHeight, - setShowErrorDetails, - config, - ideContextState, - setCtrlCPressCount, - buffer.text.length, - setCtrlDPressCount, - handleSlashCommand, - cancelOngoingRequest, - activePtyId, - embeddedShellFocused, - settings.merged.general?.debugKeystrokeLogging, - refreshStatic, - setCopyModeEnabled, - copyModeEnabled, - isAlternateBuffer, - ], - ); - - useKeypress(handleGlobalKeypress, { isActive: true }); - - // Update terminal title with Gemini CLI status and thoughts - useEffect(() => { - // Respect both showStatusInTitle and hideWindowTitle settings - if ( - !settings.merged.ui?.showStatusInTitle || - settings.merged.ui?.hideWindowTitle - ) - return; - - let title; - if (streamingState === StreamingState.Idle) { - title = originalTitleRef.current; - } else { - const statusText = thought?.subject - ?.replace(/[\r\n]+/g, ' ') - .substring(0, 80); - title = statusText || originalTitleRef.current; - } - - // Pad the title to a fixed width to prevent taskbar icon resizing. - const paddedTitle = title.padEnd(80, ' '); - - // Only update the title if it's different from the last value we set - if (lastTitleRef.current !== paddedTitle) { - lastTitleRef.current = paddedTitle; - stdout.write(`\x1b]2;${paddedTitle}\x07`); - } - // Note: We don't need to reset the window title on exit because Gemini CLI is already doing that elsewhere - }, [ - streamingState, - thought, - settings.merged.ui?.showStatusInTitle, - settings.merged.ui?.hideWindowTitle, - stdout, - ]); - - useEffect(() => { - const handleUserFeedback = (payload: UserFeedbackPayload) => { - let type: MessageType; - switch (payload.severity) { - case 'error': - type = MessageType.ERROR; - break; - case 'warning': - type = MessageType.WARNING; - break; - case 'info': - type = MessageType.INFO; - break; - default: - throw new Error( - `Unexpected severity for user feedback: ${payload.severity}`, - ); - } - - historyManager.addItem( - { - type, - text: payload.message, - }, - Date.now(), - ); - - // If there is an attached error object, log it to the debug drawer. - if (payload.error) { - debugLogger.warn( - `[Feedback Details for "${payload.message}"]`, - payload.error, - ); - } - }; - - coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); - - // Flush any messages that happened during startup before this component - // mounted. - coreEvents.drainFeedbackBacklog(); - - return () => { - coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); - }; - }, [historyManager]); - - const filteredConsoleMessages = useMemo(() => { - if (config.getDebugMode()) { - return consoleMessages; - } - return consoleMessages.filter((msg) => msg.type !== 'debug'); - }, [consoleMessages, config]); - - // Computed values - const errorCount = useMemo( - () => - filteredConsoleMessages - .filter((msg) => msg.type === 'error') - .reduce((total, msg) => total + msg.count, 0), - [filteredConsoleMessages], - ); - - const nightly = props.version.includes('nightly'); - - const dialogsVisible = - shouldShowIdePrompt || - isFolderTrustDialogOpen || - !!shellConfirmationRequest || - !!confirmationRequest || - confirmUpdateExtensionRequests.length > 0 || - !!loopDetectionConfirmationRequest || - isThemeDialogOpen || - isSettingsDialogOpen || - isModelDialogOpen || - isPermissionsDialogOpen || - isAuthenticating || - isAuthDialogOpen || - isEditorDialogOpen || - showPrivacyNotice || - showIdeRestartPrompt || - !!proQuotaRequest || - isAuthDialogOpen || - authState === AuthState.AwaitingApiKeyInput; - - const pendingHistoryItems = useMemo( - () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], - [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], - ); - - const [geminiMdFileCount, setGeminiMdFileCount] = useState( - config.getGeminiMdFileCount(), - ); - useEffect(() => { - const handleMemoryChanged = (result: MemoryChangedPayload) => { - setGeminiMdFileCount(result.fileCount); - }; - coreEvents.on(CoreEvent.MemoryChanged, handleMemoryChanged); - return () => { - coreEvents.off(CoreEvent.MemoryChanged, handleMemoryChanged); - }; - }, []); - - const uiState: UIState = useMemo( - () => ({ - history: historyManager.history, - historyManager, - isThemeDialogOpen, - themeError, - isAuthenticating, - isConfigInitialized, - authError, - isAuthDialogOpen, - isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, - apiKeyDefaultValue, - editorError, - isEditorDialogOpen, - showPrivacyNotice, - corgiMode, - debugMessage, - quittingMessages, - isSettingsDialogOpen, - isModelDialogOpen, - isPermissionsDialogOpen, - slashCommands, - pendingSlashCommandHistoryItems, - commandContext, - shellConfirmationRequest, - confirmationRequest, - confirmUpdateExtensionRequests, - loopDetectionConfirmationRequest, - geminiMdFileCount, - streamingState, - initError, - pendingGeminiHistoryItems, - thought, - shellModeActive, - userMessages, - buffer, - inputWidth, - suggestionsWidth, - isInputActive, - shouldShowIdePrompt, - isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, - isTrustedFolder, - constrainHeight, - showErrorDetails, - showFullTodos, - filteredConsoleMessages, - ideContextState, - renderMarkdown, - ctrlCPressedOnce: ctrlCPressCount >= 1, - ctrlDPressedOnce: ctrlDPressCount >= 1, - showEscapePrompt, - isFocused, - elapsedTime, - currentLoadingPhrase, - historyRemountKey, - messageQueue, - queueErrorMessage, - showAutoAcceptIndicator, - currentModel, - userTier, - proQuotaRequest, - contextFileNames, - errorCount, - availableTerminalHeight, - mainAreaWidth, - staticAreaMaxItemHeight, - staticExtraHeight, - dialogsVisible, - pendingHistoryItems, - nightly, - branchName, - sessionStats, - terminalWidth, - terminalHeight, - mainControlsRef, - rootUiRef, - currentIDE, - updateInfo, - showIdeRestartPrompt, - ideTrustRestartReason, - isRestarting, - extensionsUpdateState, - activePtyId, - embeddedShellFocused, - showDebugProfiler, - copyModeEnabled, - }), - [ - isThemeDialogOpen, - themeError, - isAuthenticating, - isConfigInitialized, - authError, - isAuthDialogOpen, - editorError, - isEditorDialogOpen, - showPrivacyNotice, - corgiMode, - debugMessage, - quittingMessages, - isSettingsDialogOpen, - isModelDialogOpen, - isPermissionsDialogOpen, - slashCommands, - pendingSlashCommandHistoryItems, - commandContext, - shellConfirmationRequest, - confirmationRequest, - confirmUpdateExtensionRequests, - loopDetectionConfirmationRequest, - geminiMdFileCount, - streamingState, - initError, - pendingGeminiHistoryItems, - thought, - shellModeActive, - userMessages, - buffer, - inputWidth, - suggestionsWidth, - isInputActive, - shouldShowIdePrompt, - isFolderTrustDialogOpen, - isTrustedFolder, - constrainHeight, - showErrorDetails, - showFullTodos, - filteredConsoleMessages, - ideContextState, - renderMarkdown, - ctrlCPressCount, - ctrlDPressCount, - showEscapePrompt, - isFocused, - elapsedTime, - currentLoadingPhrase, - historyRemountKey, - messageQueue, - queueErrorMessage, - showAutoAcceptIndicator, - userTier, - proQuotaRequest, - contextFileNames, - errorCount, - availableTerminalHeight, - mainAreaWidth, - staticAreaMaxItemHeight, - staticExtraHeight, - dialogsVisible, - pendingHistoryItems, - nightly, - branchName, - sessionStats, - terminalWidth, - terminalHeight, - mainControlsRef, - rootUiRef, - currentIDE, - updateInfo, - showIdeRestartPrompt, - ideTrustRestartReason, - isRestarting, - currentModel, - extensionsUpdateState, - activePtyId, - historyManager, - embeddedShellFocused, - showDebugProfiler, - apiKeyDefaultValue, - authState, - copyModeEnabled, - ], - ); - - const exitPrivacyNotice = useCallback( - () => setShowPrivacyNotice(false), - [setShowPrivacyNotice], - ); - - const uiActions: UIActions = useMemo( - () => ({ - handleThemeSelect, - closeThemeDialog, - handleThemeHighlight, - handleAuthSelect, - setAuthState, - onAuthError, - handleEditorSelect, - exitEditorDialog, - exitPrivacyNotice, - closeSettingsDialog, - closeModelDialog, - closePermissionsDialog, - setShellModeActive, - vimHandleInput, - handleIdePromptComplete, - handleFolderTrustSelect, - setConstrainHeight, - onEscapePromptChange: handleEscapePromptChange, - refreshStatic, - handleFinalSubmit, - handleClearScreen, - handleProQuotaChoice, - setQueueErrorMessage, - popAllMessages, - handleApiKeySubmit, - handleApiKeyCancel, - }), - [ - handleThemeSelect, - closeThemeDialog, - handleThemeHighlight, - handleAuthSelect, - setAuthState, - onAuthError, - handleEditorSelect, - exitEditorDialog, - exitPrivacyNotice, - closeSettingsDialog, - closeModelDialog, - closePermissionsDialog, - setShellModeActive, - vimHandleInput, - handleIdePromptComplete, - handleFolderTrustSelect, - setConstrainHeight, - handleEscapePromptChange, - refreshStatic, - handleFinalSubmit, - handleClearScreen, - handleProQuotaChoice, - setQueueErrorMessage, - popAllMessages, - handleApiKeySubmit, - handleApiKeyCancel, - ], - ); - - return ( - - - - - - - - - - - - ); -}; diff --git a/apps/airiscode-cli/src/ui-full/IdeIntegrationNudge.tsx b/apps/airiscode-cli/src/ui-full/IdeIntegrationNudge.tsx deleted file mode 100644 index cbd2f4385..000000000 --- a/apps/airiscode-cli/src/ui-full/IdeIntegrationNudge.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { IdeInfo } from '@airiscode/gemini-cli-core'; -import { Box, Text } from 'ink'; -import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js'; -import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js'; -import { useKeypress } from './hooks/useKeypress.js'; -import { theme } from './semantic-colors.js'; - -export type IdeIntegrationNudgeResult = { - userSelection: 'yes' | 'no' | 'dismiss'; - isExtensionPreInstalled: boolean; -}; - -interface IdeIntegrationNudgeProps { - ide: IdeInfo; - onComplete: (result: IdeIntegrationNudgeResult) => void; -} - -export function IdeIntegrationNudge({ - ide, - onComplete, -}: IdeIntegrationNudgeProps) { - useKeypress( - (key) => { - if (key.name === 'escape') { - onComplete({ - userSelection: 'no', - isExtensionPreInstalled: false, - }); - } - }, - { isActive: true }, - ); - - const { displayName: ideName } = ide; - // Assume extension is already installed if the env variables are set. - const isExtensionPreInstalled = - !!process.env['GEMINI_CLI_IDE_SERVER_PORT'] && - !!process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; - - const OPTIONS: Array> = [ - { - label: 'Yes', - value: { - userSelection: 'yes', - isExtensionPreInstalled, - }, - key: 'Yes', - }, - { - label: 'No (esc)', - value: { - userSelection: 'no', - isExtensionPreInstalled, - }, - key: 'No (esc)', - }, - { - label: "No, don't ask again", - value: { - userSelection: 'dismiss', - isExtensionPreInstalled, - }, - key: "No, don't ask again", - }, - ]; - - const installText = isExtensionPreInstalled - ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${ - ideName ?? 'your editor' - }.` - : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ - ideName ?? 'your editor' - }.`; - - return ( - - - - {'> '} - {`Do you want to connect ${ideName ?? 'your editor'} to Gemini CLI?`} - - {installText} - - - - ); -} diff --git a/apps/airiscode-cli/src/ui-full/auth/ApiAuthDialog.tsx b/apps/airiscode-cli/src/ui-full/auth/ApiAuthDialog.tsx deleted file mode 100644 index a1723efa2..000000000 --- a/apps/airiscode-cli/src/ui-full/auth/ApiAuthDialog.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import { TextInput } from '../components/shared/TextInput.js'; -import { useTextBuffer } from '../components/shared/text-buffer.js'; -import { useUIState } from '../contexts/UIStateContext.js'; - -interface ApiAuthDialogProps { - onSubmit: (apiKey: string) => void; - onCancel: () => void; - error?: string | null; - defaultValue?: string; -} - -export function ApiAuthDialog({ - onSubmit, - onCancel, - error, - defaultValue = '', -}: ApiAuthDialogProps): React.JSX.Element { - const { mainAreaWidth } = useUIState(); - const viewportWidth = mainAreaWidth - 8; - - const buffer = useTextBuffer({ - initialText: defaultValue || '', - initialCursorOffset: defaultValue?.length || 0, - viewport: { - width: viewportWidth, - height: 4, - }, - isValidPath: () => false, // No path validation needed for API key - inputFilter: (text) => - text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''), - singleLine: true, - }); - - const handleSubmit = (value: string) => { - onSubmit(value); - }; - - return ( - - - Enter Gemini API Key - - - - Please enter your Gemini API key. It will be securely stored in your - system keychain. - - - You can get an API key from{' '} - - https://aistudio.google.com/app/apikey - - - - - - - - - {error && ( - - {error} - - )} - - - (Press Enter to submit, Esc to cancel) - - - - ); -} diff --git a/apps/airiscode-cli/src/ui-full/auth/AuthDialog.tsx b/apps/airiscode-cli/src/ui-full/auth/AuthDialog.tsx deleted file mode 100644 index 37d0ad877..000000000 --- a/apps/airiscode-cli/src/ui-full/auth/AuthDialog.tsx +++ /dev/null @@ -1,216 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useCallback } from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; -import type { - LoadableSettingScope, - LoadedSettings, -} from '../../config/settings.js'; -import { SettingScope } from '../../config/settings.js'; -import { - AuthType, - clearCachedCredentialFile, - debugLogger, - type Config, -} from '@airiscode/gemini-cli-core'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { AuthState } from '../types.js'; -import { runExitCleanup } from '../../utils/cleanup.js'; -import { validateAuthMethodWithSettings } from './useAuth.js'; - -interface AuthDialogProps { - config: Config; - settings: LoadedSettings; - setAuthState: (state: AuthState) => void; - authError: string | null; - onAuthError: (error: string | null) => void; -} - -export function AuthDialog({ - config, - settings, - setAuthState, - authError, - onAuthError, -}: AuthDialogProps): React.JSX.Element { - let items = [ - { - label: 'Login with Google', - value: AuthType.LOGIN_WITH_GOOGLE, - key: AuthType.LOGIN_WITH_GOOGLE, - }, - ...(process.env['CLOUD_SHELL'] === 'true' - ? [ - { - label: 'Use Cloud Shell user credentials', - value: AuthType.CLOUD_SHELL, - key: AuthType.CLOUD_SHELL, - }, - ] - : []), - { - label: 'Use Gemini API Key', - value: AuthType.USE_GEMINI, - key: AuthType.USE_GEMINI, - }, - { - label: 'Vertex AI', - value: AuthType.USE_VERTEX_AI, - key: AuthType.USE_VERTEX_AI, - }, - ]; - - if (settings.merged.security?.auth?.enforcedType) { - items = items.filter( - (item) => item.value === settings.merged.security?.auth?.enforcedType, - ); - } - - let defaultAuthType = null; - const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE']; - if ( - defaultAuthTypeEnv && - Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType) - ) { - defaultAuthType = defaultAuthTypeEnv as AuthType; - } - - let initialAuthIndex = items.findIndex((item) => { - if (settings.merged.security?.auth?.selectedType) { - return item.value === settings.merged.security.auth.selectedType; - } - - if (defaultAuthType) { - return item.value === defaultAuthType; - } - - if (process.env['GEMINI_API_KEY']) { - return item.value === AuthType.USE_GEMINI; - } - - return item.value === AuthType.LOGIN_WITH_GOOGLE; - }); - if (settings.merged.security?.auth?.enforcedType) { - initialAuthIndex = 0; - } - - const onSelect = useCallback( - async (authType: AuthType | undefined, scope: LoadableSettingScope) => { - if (authType) { - await clearCachedCredentialFile(); - - settings.setValue(scope, 'security.auth.selectedType', authType); - if ( - authType === AuthType.LOGIN_WITH_GOOGLE && - config.isBrowserLaunchSuppressed() - ) { - runExitCleanup(); - debugLogger.log( - ` ----------------------------------------------------------------- -Logging in with Google... Please restart Gemini CLI to continue. ----------------------------------------------------------------- - `, - ); - process.exit(0); - } - } - if (authType === AuthType.USE_GEMINI) { - setAuthState(AuthState.AwaitingApiKeyInput); - return; - } - setAuthState(AuthState.Unauthenticated); - }, - [settings, config, setAuthState], - ); - - const handleAuthSelect = (authMethod: AuthType) => { - const error = validateAuthMethodWithSettings(authMethod, settings); - if (error) { - onAuthError(error); - } else { - onSelect(authMethod, SettingScope.User); - } - }; - - useKeypress( - (key) => { - if (key.name === 'escape') { - // Prevent exit if there is an error message. - // This means they user is not authenticated yet. - if (authError) { - return; - } - if (settings.merged.security?.auth?.selectedType === undefined) { - // Prevent exiting if no auth method is set - onAuthError( - 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', - ); - return; - } - onSelect(undefined, SettingScope.User); - } - }, - { isActive: true }, - ); - - return ( - - ? - - - Get started - - - - How would you like to authenticate for this project? - - - - { - onAuthError(null); - }} - /> - - {authError && ( - - {authError} - - )} - - (Use Enter to select) - - - - Terms of Services and Privacy Notice for Gemini CLI - - - - - { - 'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md' - } - - - - - ); -} diff --git a/apps/airiscode-cli/src/ui-full/auth/__snapshots__/ApiAuthDialog.test.tsx.snap b/apps/airiscode-cli/src/ui-full/auth/__snapshots__/ApiAuthDialog.test.tsx.snap deleted file mode 100644 index 5f0f58b6b..000000000 --- a/apps/airiscode-cli/src/ui-full/auth/__snapshots__/ApiAuthDialog.test.tsx.snap +++ /dev/null @@ -1,18 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ApiAuthDialog > renders correctly 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ Enter Gemini API Key │ -│ │ -│ Please enter your Gemini API key. It will be securely stored in your system keychain. │ -│ You can get an API key from https://aistudio.google.com/app/apikey │ -│ │ -│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ -│ │ Paste your API key here │ │ -│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ -│ │ -│ (Press Enter to submit, Esc to cancel) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" -`; diff --git a/apps/airiscode-cli/src/ui-full/auth/useAuth.ts b/apps/airiscode-cli/src/ui-full/auth/useAuth.ts deleted file mode 100644 index 024e9be36..000000000 --- a/apps/airiscode-cli/src/ui-full/auth/useAuth.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useEffect, useCallback } from 'react'; -import type { LoadedSettings } from '../../config/settings.js'; -import { - AuthType, - type Config, - loadApiKey, - debugLogger, -} from '@airiscode/gemini-cli-core'; -import { getErrorMessage } from '@airiscode/gemini-cli-core'; -import { AuthState } from '../types.js'; -import { validateAuthMethod } from '../../config/auth.js'; - -export function validateAuthMethodWithSettings( - authType: AuthType, - settings: LoadedSettings, -): string | null { - const enforcedType = settings.merged.security?.auth?.enforcedType; - if (enforcedType && enforcedType !== authType) { - return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; - } - if (settings.merged.security?.auth?.useExternal) { - return null; - } - // If using Gemini API key, we don't validate it here as we might need to prompt for it. - if (authType === AuthType.USE_GEMINI) { - return null; - } - return validateAuthMethod(authType); -} - -export const useAuthCommand = (settings: LoadedSettings, config: Config) => { - const [authState, setAuthState] = useState( - AuthState.Unauthenticated, - ); - - const [authError, setAuthError] = useState(null); - const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< - string | undefined - >(undefined); - - const onAuthError = useCallback( - (error: string | null) => { - setAuthError(error); - if (error) { - setAuthState(AuthState.Updating); - } - }, - [setAuthError, setAuthState], - ); - - const reloadApiKey = useCallback(async () => { - const storedKey = (await loadApiKey()) ?? ''; - const envKey = process.env['GEMINI_API_KEY'] ?? ''; - const key = storedKey || envKey; - setApiKeyDefaultValue(key); - return key; // Return the key for immediate use - }, []); - - useEffect(() => { - (async () => { - if (authState !== AuthState.Unauthenticated) { - return; - } - - const authType = settings.merged.security?.auth?.selectedType; - if (!authType) { - if (process.env['GEMINI_API_KEY']) { - onAuthError( - 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.', - ); - } else { - onAuthError('No authentication method selected.'); - } - return; - } - - if (authType === AuthType.USE_GEMINI) { - const key = await reloadApiKey(); // Use the unified function - if (!key) { - setAuthState(AuthState.AwaitingApiKeyInput); - return; - } - } - - const error = validateAuthMethodWithSettings(authType, settings); - if (error) { - onAuthError(error); - return; - } - - const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE']; - if ( - defaultAuthType && - !Object.values(AuthType).includes(defaultAuthType as AuthType) - ) { - onAuthError( - `Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${defaultAuthType}". ` + - `Valid values are: ${Object.values(AuthType).join(', ')}.`, - ); - return; - } - - try { - await config.refreshAuth(authType); - - debugLogger.log(`Authenticated via "${authType}".`); - setAuthError(null); - setAuthState(AuthState.Authenticated); - } catch (e) { - onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); - } - })(); - }, [ - settings, - config, - authState, - setAuthState, - setAuthError, - onAuthError, - reloadApiKey, - ]); - - return { - authState, - setAuthState, - authError, - onAuthError, - apiKeyDefaultValue, - reloadApiKey, - }; -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/aboutCommand.ts b/apps/airiscode-cli/src/ui-full/commands/aboutCommand.ts deleted file mode 100644 index 9980b082a..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/aboutCommand.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { getCliVersion } from '../../utils/version.js'; -import type { CommandContext, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; -import process from 'node:process'; -import { MessageType, type HistoryItemAbout } from '../types.js'; -import { IdeClient } from '@airiscode/gemini-cli-core'; - -export const aboutCommand: SlashCommand = { - name: 'about', - description: 'Show version info', - kind: CommandKind.BUILT_IN, - action: async (context) => { - const osVersion = process.platform; - let sandboxEnv = 'no sandbox'; - if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { - sandboxEnv = process.env['SANDBOX']; - } else if (process.env['SANDBOX'] === 'sandbox-exec') { - sandboxEnv = `sandbox-exec (${ - process.env['SEATBELT_PROFILE'] || 'unknown' - })`; - } - const modelVersion = context.services.config?.getModel() || 'Unknown'; - const cliVersion = await getCliVersion(); - const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; - const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; - const ideClient = await getIdeClientName(context); - - const aboutItem: Omit = { - type: MessageType.ABOUT, - cliVersion, - osVersion, - sandboxEnv, - modelVersion, - selectedAuthType, - gcpProject, - ideClient, - }; - - context.ui.addItem(aboutItem, Date.now()); - }, -}; - -async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { - return ''; - } - const ideClient = await IdeClient.getInstance(); - return ideClient?.getDetectedIdeDisplayName() ?? ''; -} diff --git a/apps/airiscode-cli/src/ui-full/commands/authCommand.ts b/apps/airiscode-cli/src/ui-full/commands/authCommand.ts deleted file mode 100644 index 1d2df73f2..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/authCommand.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; - -export const authCommand: SlashCommand = { - name: 'auth', - description: 'Change the auth method', - kind: CommandKind.BUILT_IN, - action: (_context, _args): OpenDialogActionReturn => ({ - type: 'dialog', - dialog: 'auth', - }), -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/bugCommand.ts b/apps/airiscode-cli/src/ui-full/commands/bugCommand.ts deleted file mode 100644 index 4d92f0abc..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/bugCommand.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import open from 'open'; -import process from 'node:process'; -import { - type CommandContext, - type SlashCommand, - CommandKind, -} from './types.js'; -import { MessageType } from '../types.js'; -import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; -import { formatMemoryUsage } from '../utils/formatters.js'; -import { getCliVersion } from '../../utils/version.js'; -import { IdeClient, sessionId } from '@airiscode/gemini-cli-core'; - -export const bugCommand: SlashCommand = { - name: 'bug', - description: 'Submit a bug report', - kind: CommandKind.BUILT_IN, - action: async (context: CommandContext, args?: string): Promise => { - const bugDescription = (args || '').trim(); - const { config } = context.services; - - const osVersion = `${process.platform} ${process.version}`; - let sandboxEnv = 'no sandbox'; - if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { - sandboxEnv = process.env['SANDBOX'].replace(/^gemini-(?:code-)?/, ''); - } else if (process.env['SANDBOX'] === 'sandbox-exec') { - sandboxEnv = `sandbox-exec (${ - process.env['SEATBELT_PROFILE'] || 'unknown' - })`; - } - const modelVersion = config?.getModel() || 'Unknown'; - const cliVersion = await getCliVersion(); - const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); - const ideClient = await getIdeClientName(context); - - let info = ` -* **CLI Version:** ${cliVersion} -* **Git Commit:** ${GIT_COMMIT_INFO} -* **Session ID:** ${sessionId} -* **Operating System:** ${osVersion} -* **Sandbox Environment:** ${sandboxEnv} -* **Model Version:** ${modelVersion} -* **Memory Usage:** ${memoryUsage} -`; - if (ideClient) { - info += `* **IDE Client:** ${ideClient}\n`; - } - - let bugReportUrl = - 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}'; - - const bugCommandSettings = config?.getBugCommand(); - if (bugCommandSettings?.urlTemplate) { - bugReportUrl = bugCommandSettings.urlTemplate; - } - - bugReportUrl = bugReportUrl - .replace('{title}', encodeURIComponent(bugDescription)) - .replace('{info}', encodeURIComponent(info)); - - context.ui.addItem( - { - type: MessageType.INFO, - text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`, - }, - Date.now(), - ); - - try { - await open(bugReportUrl); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Could not open URL in browser: ${errorMessage}`, - }, - Date.now(), - ); - } - }, -}; - -async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { - return ''; - } - const ideClient = await IdeClient.getInstance(); - return ideClient.getDetectedIdeDisplayName() ?? ''; -} diff --git a/apps/airiscode-cli/src/ui-full/commands/chatCommand.ts b/apps/airiscode-cli/src/ui-full/commands/chatCommand.ts deleted file mode 100644 index 4c3b99b0e..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/chatCommand.ts +++ /dev/null @@ -1,369 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fsPromises from 'node:fs/promises'; -import React from 'react'; -import { Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import type { - CommandContext, - SlashCommand, - MessageActionReturn, - SlashCommandActionReturn, -} from './types.js'; -import { CommandKind } from './types.js'; -import { decodeTagName } from '@airiscode/gemini-cli-core'; -import path from 'node:path'; -import type { - HistoryItemWithoutId, - HistoryItemChatList, - ChatDetail, -} from '../types.js'; -import { MessageType } from '../types.js'; -import type { Content } from '@google/genai'; - -const getSavedChatTags = async ( - context: CommandContext, - mtSortDesc: boolean, -): Promise => { - const cfg = context.services.config; - const geminiDir = cfg?.storage?.getProjectTempDir(); - if (!geminiDir) { - return []; - } - try { - const file_head = 'checkpoint-'; - const file_tail = '.json'; - const files = await fsPromises.readdir(geminiDir); - const chatDetails: ChatDetail[] = []; - - for (const file of files) { - if (file.startsWith(file_head) && file.endsWith(file_tail)) { - const filePath = path.join(geminiDir, file); - const stats = await fsPromises.stat(filePath); - const tagName = file.slice(file_head.length, -file_tail.length); - chatDetails.push({ - name: decodeTagName(tagName), - mtime: stats.mtime.toISOString(), - }); - } - } - - chatDetails.sort((a, b) => - mtSortDesc - ? b.mtime.localeCompare(a.mtime) - : a.mtime.localeCompare(b.mtime), - ); - - return chatDetails; - } catch (_err) { - return []; - } -}; - -const listCommand: SlashCommand = { - name: 'list', - description: 'List saved conversation checkpoints', - kind: CommandKind.BUILT_IN, - action: async (context): Promise => { - const chatDetails = await getSavedChatTags(context, false); - - const item: HistoryItemChatList = { - type: MessageType.CHAT_LIST, - chats: chatDetails, - }; - - context.ui.addItem(item, Date.now()); - }, -}; - -const saveCommand: SlashCommand = { - name: 'save', - description: - 'Save the current conversation as a checkpoint. Usage: /chat save ', - kind: CommandKind.BUILT_IN, - action: async (context, args): Promise => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: 'Missing tag. Usage: /chat save ', - }; - } - - const { logger, config } = context.services; - await logger.initialize(); - - if (!context.overwriteConfirmed) { - const exists = await logger.checkpointExists(tag); - if (exists) { - return { - type: 'confirm_action', - prompt: React.createElement( - Text, - null, - 'A checkpoint with the tag ', - React.createElement(Text, { color: theme.text.accent }, tag), - ' already exists. Do you want to overwrite it?', - ), - originalInvocation: { - raw: context.invocation?.raw || `/chat save ${tag}`, - }, - }; - } - } - - const chat = await config?.getGeminiClient()?.getChat(); - if (!chat) { - return { - type: 'message', - messageType: 'error', - content: 'No chat client available to save conversation.', - }; - } - - const history = chat.getHistory(); - if (history.length > 2) { - await logger.saveCheckpoint(history, tag); - return { - type: 'message', - messageType: 'info', - content: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`, - }; - } else { - return { - type: 'message', - messageType: 'info', - content: 'No conversation found to save.', - }; - } - }, -}; - -const resumeCommand: SlashCommand = { - name: 'resume', - altNames: ['load'], - description: - 'Resume a conversation from a checkpoint. Usage: /chat resume ', - kind: CommandKind.BUILT_IN, - action: async (context, args) => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: 'Missing tag. Usage: /chat resume ', - }; - } - - const { logger } = context.services; - await logger.initialize(); - const conversation = await logger.loadCheckpoint(tag); - - if (conversation.length === 0) { - return { - type: 'message', - messageType: 'info', - content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`, - }; - } - - const rolemap: { [key: string]: MessageType } = { - user: MessageType.USER, - model: MessageType.GEMINI, - }; - - const uiHistory: HistoryItemWithoutId[] = []; - let hasSystemPrompt = false; - let i = 0; - - for (const item of conversation) { - i += 1; - const text = - item.parts - ?.filter((m) => !!m.text) - .map((m) => m.text) - .join('') || ''; - if (!text) { - continue; - } - if (i === 1 && text.match(/context for our chat/)) { - hasSystemPrompt = true; - } - if (i > 2 || !hasSystemPrompt) { - uiHistory.push({ - type: (item.role && rolemap[item.role]) || MessageType.GEMINI, - text, - } as HistoryItemWithoutId); - } - } - return { - type: 'load_history', - history: uiHistory, - clientHistory: conversation, - }; - }, - completion: async (context, partialArg) => { - const chatDetails = await getSavedChatTags(context, true); - return chatDetails - .map((chat) => chat.name) - .filter((name) => name.startsWith(partialArg)); - }, -}; - -const deleteCommand: SlashCommand = { - name: 'delete', - description: 'Delete a conversation checkpoint. Usage: /chat delete ', - kind: CommandKind.BUILT_IN, - action: async (context, args): Promise => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: 'Missing tag. Usage: /chat delete ', - }; - } - - const { logger } = context.services; - await logger.initialize(); - const deleted = await logger.deleteCheckpoint(tag); - - if (deleted) { - return { - type: 'message', - messageType: 'info', - content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`, - }; - } else { - return { - type: 'message', - messageType: 'error', - content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`, - }; - } - }, - completion: async (context, partialArg) => { - const chatDetails = await getSavedChatTags(context, true); - return chatDetails - .map((chat) => chat.name) - .filter((name) => name.startsWith(partialArg)); - }, -}; - -export function serializeHistoryToMarkdown(history: Content[]): string { - return history - .map((item) => { - const text = - item.parts - ?.map((part) => { - if (part.text) { - return part.text; - } - if (part.functionCall) { - return `**Tool Command**:\n\`\`\`json\n${JSON.stringify( - part.functionCall, - null, - 2, - )}\n\`\`\``; - } - if (part.functionResponse) { - return `**Tool Response**:\n\`\`\`json\n${JSON.stringify( - part.functionResponse, - null, - 2, - )}\n\`\`\``; - } - return ''; - }) - .join('') || ''; - const roleIcon = item.role === 'user' ? '🧑‍💻' : '✨'; - return `${roleIcon} ## ${(item.role || 'model').toUpperCase()}\n\n${text}`; - }) - .join('\n\n---\n\n'); -} - -const shareCommand: SlashCommand = { - name: 'share', - description: - 'Share the current conversation to a markdown or json file. Usage: /chat share ', - kind: CommandKind.BUILT_IN, - action: async (context, args): Promise => { - let filePathArg = args.trim(); - if (!filePathArg) { - filePathArg = `gemini-conversation-${Date.now()}.json`; - } - - const filePath = path.resolve(filePathArg); - const extension = path.extname(filePath); - if (extension !== '.md' && extension !== '.json') { - return { - type: 'message', - messageType: 'error', - content: 'Invalid file format. Only .md and .json are supported.', - }; - } - - const chat = await context.services.config?.getGeminiClient()?.getChat(); - if (!chat) { - return { - type: 'message', - messageType: 'error', - content: 'No chat client available to share conversation.', - }; - } - - const history = chat.getHistory(); - - // An empty conversation has two hidden messages that setup the context for - // the chat. Thus, to check whether a conversation has been started, we - // can't check for length 0. - if (history.length <= 2) { - return { - type: 'message', - messageType: 'info', - content: 'No conversation found to share.', - }; - } - - let content = ''; - if (extension === '.json') { - content = JSON.stringify(history, null, 2); - } else { - content = serializeHistoryToMarkdown(history); - } - - try { - await fsPromises.writeFile(filePath, content); - return { - type: 'message', - messageType: 'info', - content: `Conversation shared to ${filePath}`, - }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - return { - type: 'message', - messageType: 'error', - content: `Error sharing conversation: ${errorMessage}`, - }; - } - }, -}; - -export const chatCommand: SlashCommand = { - name: 'chat', - description: 'Manage conversation history', - kind: CommandKind.BUILT_IN, - subCommands: [ - listCommand, - saveCommand, - resumeCommand, - deleteCommand, - shareCommand, - ], -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/clearCommand.ts b/apps/airiscode-cli/src/ui-full/commands/clearCommand.ts deleted file mode 100644 index f2eec62a8..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/clearCommand.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { uiTelemetryService } from '@airiscode/gemini-cli-core'; -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; - -export const clearCommand: SlashCommand = { - name: 'clear', - description: 'Clear the screen and conversation history', - kind: CommandKind.BUILT_IN, - action: async (context, _args) => { - const geminiClient = context.services.config?.getGeminiClient(); - - if (geminiClient) { - context.ui.setDebugMessage('Clearing terminal and resetting chat.'); - // If resetChat fails, the exception will propagate and halt the command, - // which is the correct behavior to signal a failure to the user. - await geminiClient.resetChat(); - } else { - context.ui.setDebugMessage('Clearing terminal.'); - } - - uiTelemetryService.setLastPromptTokenCount(0); - context.ui.clear(); - }, -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/compressCommand.ts b/apps/airiscode-cli/src/ui-full/commands/compressCommand.ts deleted file mode 100644 index a36bf47b8..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/compressCommand.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { HistoryItemCompression } from '../types.js'; -import { MessageType } from '../types.js'; -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; - -export const compressCommand: SlashCommand = { - name: 'compress', - altNames: ['summarize'], - description: 'Compresses the context by replacing it with a summary', - kind: CommandKind.BUILT_IN, - action: async (context) => { - const { ui } = context; - if (ui.pendingItem) { - ui.addItem( - { - type: MessageType.ERROR, - text: 'Already compressing, wait for previous request to complete', - }, - Date.now(), - ); - return; - } - - const pendingMessage: HistoryItemCompression = { - type: MessageType.COMPRESSION, - compression: { - isPending: true, - originalTokenCount: null, - newTokenCount: null, - compressionStatus: null, - }, - }; - - try { - ui.setPendingItem(pendingMessage); - const promptId = `compress-${Date.now()}`; - const compressed = await context.services.config - ?.getGeminiClient() - ?.tryCompressChat(promptId, true); - if (compressed) { - ui.addItem( - { - type: MessageType.COMPRESSION, - compression: { - isPending: false, - originalTokenCount: compressed.originalTokenCount, - newTokenCount: compressed.newTokenCount, - compressionStatus: compressed.compressionStatus, - }, - } as HistoryItemCompression, - Date.now(), - ); - } else { - ui.addItem( - { - type: MessageType.ERROR, - text: 'Failed to compress chat history.', - }, - Date.now(), - ); - } - } catch (e) { - ui.addItem( - { - type: MessageType.ERROR, - text: `Failed to compress chat history: ${ - e instanceof Error ? e.message : String(e) - }`, - }, - Date.now(), - ); - } finally { - ui.setPendingItem(null); - } - }, -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/corgiCommand.ts b/apps/airiscode-cli/src/ui-full/commands/corgiCommand.ts deleted file mode 100644 index f1e120fcb..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/corgiCommand.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandKind, type SlashCommand } from './types.js'; - -export const corgiCommand: SlashCommand = { - name: 'corgi', - description: 'Toggles corgi mode', - hidden: true, - kind: CommandKind.BUILT_IN, - action: (context, _args) => { - context.ui.toggleCorgiMode(); - }, -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/directoryCommand.tsx b/apps/airiscode-cli/src/ui-full/commands/directoryCommand.tsx deleted file mode 100644 index 3f932743d..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/directoryCommand.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { SlashCommand, CommandContext } from './types.js'; -import { CommandKind } from './types.js'; -import { MessageType } from '../types.js'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { refreshServerHierarchicalMemory } from '@airiscode/gemini-cli-core'; - -export function expandHomeDir(p: string): string { - if (!p) { - return ''; - } - let expandedPath = p; - if (p.toLowerCase().startsWith('%userprofile%')) { - expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p === '~' || p.startsWith('~/')) { - expandedPath = os.homedir() + p.substring(1); - } - return path.normalize(expandedPath); -} - -export const directoryCommand: SlashCommand = { - name: 'directory', - altNames: ['dir'], - description: 'Manage workspace directories', - kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'add', - description: - 'Add directories to the workspace. Use comma to separate multiple paths', - kind: CommandKind.BUILT_IN, - action: async (context: CommandContext, args: string) => { - const { - ui: { addItem }, - services: { config }, - } = context; - const [...rest] = args.split(' '); - - if (!config) { - addItem( - { - type: MessageType.ERROR, - text: 'Configuration is not available.', - }, - Date.now(), - ); - return; - } - - const workspaceContext = config.getWorkspaceContext(); - - const pathsToAdd = rest - .join(' ') - .split(',') - .filter((p) => p); - if (pathsToAdd.length === 0) { - addItem( - { - type: MessageType.ERROR, - text: 'Please provide at least one path to add.', - }, - Date.now(), - ); - return; - } - - if (config.isRestrictiveSandbox()) { - return { - type: 'message' as const, - messageType: 'error' as const, - content: - 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', - }; - } - - const added: string[] = []; - const errors: string[] = []; - - for (const pathToAdd of pathsToAdd) { - try { - workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim())); - added.push(pathToAdd.trim()); - } catch (e) { - const error = e as Error; - errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`); - } - } - - try { - if (config.shouldLoadMemoryFromIncludeDirectories()) { - await refreshServerHierarchicalMemory(config); - } - addItem( - { - type: MessageType.INFO, - text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, - }, - Date.now(), - ); - } catch (error) { - errors.push(`Error refreshing memory: ${(error as Error).message}`); - } - - if (added.length > 0) { - const gemini = config.getGeminiClient(); - if (gemini) { - await gemini.addDirectoryContext(); - } - addItem( - { - type: MessageType.INFO, - text: `Successfully added directories:\n- ${added.join('\n- ')}`, - }, - Date.now(), - ); - } - - if (errors.length > 0) { - addItem( - { type: MessageType.ERROR, text: errors.join('\n') }, - Date.now(), - ); - } - return; - }, - }, - { - name: 'show', - description: 'Show all directories in the workspace', - kind: CommandKind.BUILT_IN, - action: async (context: CommandContext) => { - const { - ui: { addItem }, - services: { config }, - } = context; - if (!config) { - addItem( - { - type: MessageType.ERROR, - text: 'Configuration is not available.', - }, - Date.now(), - ); - return; - } - const workspaceContext = config.getWorkspaceContext(); - const directories = workspaceContext.getDirectories(); - const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); - addItem( - { - type: MessageType.INFO, - text: `Current workspace directories:\n${directoryList}`, - }, - Date.now(), - ); - }, - }, - ], -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/docsCommand.ts b/apps/airiscode-cli/src/ui-full/commands/docsCommand.ts deleted file mode 100644 index 5225ce9c6..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/docsCommand.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import open from 'open'; -import process from 'node:process'; -import { - type CommandContext, - type SlashCommand, - CommandKind, -} from './types.js'; -import { MessageType } from '../types.js'; - -export const docsCommand: SlashCommand = { - name: 'docs', - description: 'Open full Gemini CLI documentation in your browser', - kind: CommandKind.BUILT_IN, - action: async (context: CommandContext): Promise => { - const docsUrl = 'https://goo.gle/gemini-cli-docs'; - - if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, - }, - Date.now(), - ); - } else { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Opening documentation in your browser: ${docsUrl}`, - }, - Date.now(), - ); - await open(docsUrl); - } - }, -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/extensionsCommand.ts b/apps/airiscode-cli/src/ui-full/commands/extensionsCommand.ts deleted file mode 100644 index 8e9f6d74f..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/extensionsCommand.ts +++ /dev/null @@ -1,505 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { debugLogger, listExtensions } from '@airiscode/gemini-cli-core'; -import type { ExtensionUpdateInfo } from '../../config/extension.js'; -import { getErrorMessage } from '../../utils/errors.js'; -import { - emptyIcon, - MessageType, - type HistoryItemExtensionsList, - type HistoryItemInfo, -} from '../types.js'; -import { - type CommandContext, - type SlashCommand, - CommandKind, -} from './types.js'; -import open from 'open'; -import process from 'node:process'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import { SettingScope } from '../../config/settings.js'; -import { theme } from '../semantic-colors.js'; - -async function listAction(context: CommandContext) { - const historyItem: HistoryItemExtensionsList = { - type: MessageType.EXTENSIONS_LIST, - extensions: context.services.config - ? listExtensions(context.services.config) - : [], - }; - - context.ui.addItem(historyItem, Date.now()); -} - -function updateAction(context: CommandContext, args: string): Promise { - const updateArgs = args.split(' ').filter((value) => value.length > 0); - const all = updateArgs.length === 1 && updateArgs[0] === '--all'; - const names = all ? null : updateArgs; - - if (!all && names?.length === 0) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Usage: /extensions update |--all', - }, - Date.now(), - ); - return Promise.resolve(); - } - - let resolveUpdateComplete: (updateInfo: ExtensionUpdateInfo[]) => void; - const updateComplete = new Promise( - (resolve) => (resolveUpdateComplete = resolve), - ); - - const historyItem: HistoryItemExtensionsList = { - type: MessageType.EXTENSIONS_LIST, - extensions: context.services.config - ? listExtensions(context.services.config) - : [], - }; - - updateComplete.then((updateInfos) => { - if (updateInfos.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: 'No extensions to update.', - }, - Date.now(), - ); - } - - context.ui.addItem(historyItem, Date.now()); - context.ui.setPendingItem(null); - }); - - try { - context.ui.setPendingItem(historyItem); - - context.ui.dispatchExtensionStateUpdate({ - type: 'SCHEDULE_UPDATE', - payload: { - all, - names, - onComplete: (updateInfos) => { - resolveUpdateComplete(updateInfos); - }, - }, - }); - if (names?.length) { - const extensions = listExtensions(context.services.config!); - for (const name of names) { - const extension = extensions.find( - (extension) => extension.name === name, - ); - if (!extension) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Extension ${name} not found.`, - }, - Date.now(), - ); - continue; - } - } - } - } catch (error) { - resolveUpdateComplete!([]); - context.ui.addItem( - { - type: MessageType.ERROR, - text: getErrorMessage(error), - }, - Date.now(), - ); - } - return updateComplete.then((_) => {}); -} - -async function restartAction( - context: CommandContext, - args: string, -): Promise { - const extensionLoader = context.services.config?.getExtensionLoader(); - if (!extensionLoader) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: "Extensions are not yet loaded, can't restart yet", - }, - Date.now(), - ); - return; - } - - const restartArgs = args.split(' ').filter((value) => value.length > 0); - const all = restartArgs.length === 1 && restartArgs[0] === '--all'; - const names = all ? null : restartArgs; - if (!all && names?.length === 0) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', - }, - Date.now(), - ); - return Promise.resolve(); - } - - let extensionsToRestart = extensionLoader - .getExtensions() - .filter((extension) => extension.isActive); - if (names) { - extensionsToRestart = extensionsToRestart.filter((extension) => - names.includes(extension.name), - ); - if (names.length !== extensionsToRestart.length) { - const notFound = names.filter( - (name) => - !extensionsToRestart.some((extension) => extension.name === name), - ); - if (notFound.length > 0) { - context.ui.addItem( - { - type: MessageType.WARNING, - text: `Extension(s) not found or not active: ${notFound.join( - ', ', - )}`, - }, - Date.now(), - ); - } - } - } - if (extensionsToRestart.length === 0) { - // We will have logged a different message above already. - return; - } - - const s = extensionsToRestart.length > 1 ? 's' : ''; - - const restartingMessage = { - type: MessageType.INFO, - text: `Restarting ${extensionsToRestart.length} extension${s}...`, - color: theme.text.primary, - }; - context.ui.addItem(restartingMessage, Date.now()); - - const results = await Promise.allSettled( - extensionsToRestart.map(async (extension) => { - if (extension.isActive) { - await extensionLoader.restartExtension(extension); - context.ui.dispatchExtensionStateUpdate({ - type: 'RESTARTED', - payload: { - name: extension.name, - }, - }); - } - }), - ); - - const failures = results.filter( - (result): result is PromiseRejectedResult => result.status === 'rejected', - ); - - if (failures.length > 0) { - const errorMessages = failures - .map((failure, index) => { - const extensionName = extensionsToRestart[index].name; - return `${extensionName}: ${getErrorMessage(failure.reason)}`; - }) - .join('\n '); - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Failed to restart some extensions:\n ${errorMessages}`, - }, - Date.now(), - ); - } else { - const infoItem: HistoryItemInfo = { - type: MessageType.INFO, - text: `${extensionsToRestart.length} extension${s} restarted successfully.`, - icon: emptyIcon, - color: theme.text.primary, - }; - context.ui.addItem(infoItem, Date.now()); - } -} - -async function exploreAction(context: CommandContext) { - const extensionsUrl = 'https://geminicli.com/extensions/'; - - // Only check for NODE_ENV for explicit test mode, not for unit test framework - if (process.env['NODE_ENV'] === 'test') { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, - }, - Date.now(), - ); - } else if ( - process.env['SANDBOX'] && - process.env['SANDBOX'] !== 'sandbox-exec' - ) { - context.ui.addItem( - { - type: MessageType.INFO, - text: `View available extensions at ${extensionsUrl}`, - }, - Date.now(), - ); - } else { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Opening extensions page in your browser: ${extensionsUrl}`, - }, - Date.now(), - ); - try { - await open(extensionsUrl); - } catch (_error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, - }, - Date.now(), - ); - } - } -} - -function getEnableDisableContext( - context: CommandContext, - argumentsString: string, -): { - extensionManager: ExtensionManager; - names: string[]; - scope: SettingScope; -} | null { - const extensionLoader = context.services.config?.getExtensionLoader(); - if (!(extensionLoader instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - return null; - } - const parts = argumentsString.split(' '); - const name = parts[0]; - if ( - name === '' || - !( - (parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope= - (parts.length === 3 && parts[1] === '--scope') // --scope - ) - ) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Usage: /extensions ${context.invocation?.name} [--scope=]`, - }, - Date.now(), - ); - return null; - } - let scope: SettingScope; - // Transform `--scope=` to `--scope `. - if (parts.length === 2) { - parts.push(...parts[1].split('=')); - parts.splice(1, 1); - } - switch (parts[2].toLowerCase()) { - case 'workspace': - scope = SettingScope.Workspace; - break; - case 'user': - scope = SettingScope.User; - break; - case 'session': - scope = SettingScope.Session; - break; - default: - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Unsupported scope ${parts[2]}, should be one of "user", "workspace", or "session"`, - }, - Date.now(), - ); - debugLogger.error(); - return null; - } - let names: string[] = []; - if (name === '--all') { - let extensions = extensionLoader.getExtensions(); - if (context.invocation?.name === 'enable') { - extensions = extensions.filter((ext) => !ext.isActive); - } - if (context.invocation?.name === 'disable') { - extensions = extensions.filter((ext) => ext.isActive); - } - names = extensions.map((ext) => ext.name); - } else { - names = [name]; - } - - return { - extensionManager: extensionLoader, - names, - scope, - }; -} - -async function disableAction(context: CommandContext, args: string) { - const enableContext = getEnableDisableContext(context, args); - if (!enableContext) return; - - const { names, scope, extensionManager } = enableContext; - for (const name of names) { - await extensionManager.disableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Extension "${name}" disabled for the scope "${scope}"`, - }, - Date.now(), - ); - } -} - -async function enableAction(context: CommandContext, args: string) { - const enableContext = getEnableDisableContext(context, args); - if (!enableContext) return; - - const { names, scope, extensionManager } = enableContext; - for (const name of names) { - await extensionManager.enableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Extension "${name}" enabled for the scope "${scope}"`, - }, - Date.now(), - ); - } -} - -/** - * Exported for testing. - */ -export function completeExtensions( - context: CommandContext, - partialArg: string, -) { - let extensions = context.services.config?.getExtensions() ?? []; - - if (context.invocation?.name === 'enable') { - extensions = extensions.filter((ext) => !ext.isActive); - } - if ( - context.invocation?.name === 'disable' || - context.invocation?.name === 'restart' - ) { - extensions = extensions.filter((ext) => ext.isActive); - } - const extensionNames = extensions.map((ext) => ext.name); - const suggestions = extensionNames.filter((name) => - name.startsWith(partialArg), - ); - - if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) { - suggestions.unshift('--all'); - } - - return suggestions; -} - -export function completeExtensionsAndScopes( - context: CommandContext, - partialArg: string, -) { - return completeExtensions(context, partialArg).flatMap((s) => [ - `${s} --scope user`, - `${s} --scope workspace`, - `${s} --scope session`, - ]); -} - -const listExtensionsCommand: SlashCommand = { - name: 'list', - description: 'List active extensions', - kind: CommandKind.BUILT_IN, - action: listAction, -}; - -const updateExtensionsCommand: SlashCommand = { - name: 'update', - description: 'Update extensions. Usage: update |--all', - kind: CommandKind.BUILT_IN, - action: updateAction, - completion: completeExtensions, -}; - -const disableCommand: SlashCommand = { - name: 'disable', - description: 'Disable an extension', - kind: CommandKind.BUILT_IN, - action: disableAction, - completion: completeExtensionsAndScopes, -}; - -const enableCommand: SlashCommand = { - name: 'enable', - description: 'Enable an extension', - kind: CommandKind.BUILT_IN, - action: enableAction, - completion: completeExtensionsAndScopes, -}; - -const exploreExtensionsCommand: SlashCommand = { - name: 'explore', - description: 'Open extensions page in your browser', - kind: CommandKind.BUILT_IN, - action: exploreAction, -}; - -const restartCommand: SlashCommand = { - name: 'restart', - description: 'Restart all extensions', - kind: CommandKind.BUILT_IN, - action: restartAction, - completion: completeExtensions, -}; - -export function extensionsCommand( - enableExtensionReloading?: boolean, -): SlashCommand { - const conditionalCommands = enableExtensionReloading - ? [disableCommand, enableCommand] - : []; - return { - name: 'extensions', - description: 'Manage extensions', - kind: CommandKind.BUILT_IN, - subCommands: [ - listExtensionsCommand, - updateExtensionsCommand, - exploreExtensionsCommand, - restartCommand, - ...conditionalCommands, - ], - action: (context, args) => - // Default to list if no subcommand is provided - listExtensionsCommand.action!(context, args), - }; -} diff --git a/apps/airiscode-cli/src/ui-full/commands/initCommand.ts b/apps/airiscode-cli/src/ui-full/commands/initCommand.ts deleted file mode 100644 index acf96c442..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/initCommand.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { - CommandContext, - SlashCommand, - SlashCommandActionReturn, -} from './types.js'; -import { CommandKind } from './types.js'; - -export const initCommand: SlashCommand = { - name: 'init', - description: 'Analyzes the project and creates a tailored GEMINI.md file', - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - _args: string, - ): Promise => { - if (!context.services.config) { - return { - type: 'message', - messageType: 'error', - content: 'Configuration not available.', - }; - } - const targetDir = context.services.config.getTargetDir(); - const geminiMdPath = path.join(targetDir, 'GEMINI.md'); - - if (fs.existsSync(geminiMdPath)) { - return { - type: 'message', - messageType: 'info', - content: - 'A GEMINI.md file already exists in this directory. No changes were made.', - }; - } - - // Create an empty GEMINI.md file - fs.writeFileSync(geminiMdPath, '', 'utf8'); - - context.ui.addItem( - { - type: 'info', - text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', - }, - Date.now(), - ); - - return { - type: 'submit_prompt', - content: ` -You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions. - -**Analysis Process:** - -1. **Initial Exploration:** - * Start by listing the files and directories to get a high-level overview of the structure. - * Read the README file (e.g., \`README.md\`, \`README.txt\`) if it exists. This is often the best place to start. - -2. **Iterative Deep Dive (up to 10 files):** - * Based on your initial findings, select a few files that seem most important (e.g., configuration files, main source files, documentation). - * Read them. As you learn more, refine your understanding and decide which files to read next. You don't need to decide all 10 files at once. Let your discoveries guide your exploration. - -3. **Identify Project Type:** - * **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project. - * **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else. - -**GEMINI.md Content Generation:** - -**For a Code Project:** - -* **Project Overview:** Write a clear and concise summary of the project's purpose, main technologies, and architecture. -* **Building and Running:** Document the key commands for building, running, and testing the project. Infer these from the files you've read (e.g., \`scripts\` in \`package.json\`, \`Makefile\`, etc.). If you can't find explicit commands, provide a placeholder with a TODO. -* **Development Conventions:** Describe any coding styles, testing practices, or contribution guidelines you can infer from the codebase. - -**For a Non-Code Project:** - -* **Directory Overview:** Describe the purpose and contents of the directory. What is it for? What kind of information does it hold? -* **Key Files:** List the most important files and briefly explain what they contain. -* **Usage:** Explain how the contents of this directory are intended to be used. - -**Final Output:** - -Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown. -`, - }; - }, -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/mcpCommand.ts b/apps/airiscode-cli/src/ui-full/commands/mcpCommand.ts deleted file mode 100644 index 4140736ba..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/mcpCommand.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - SlashCommand, - SlashCommandActionReturn, - CommandContext, - MessageActionReturn, -} from './types.js'; -import { CommandKind } from './types.js'; -import type { DiscoveredMCPPrompt } from '@airiscode/gemini-cli-core'; -import { - DiscoveredMCPTool, - getMCPDiscoveryState, - getMCPServerStatus, - MCPDiscoveryState, - MCPServerStatus, - getErrorMessage, - MCPOAuthTokenStorage, -} from '@airiscode/gemini-cli-core'; -import { appEvents, AppEvent } from '../../utils/events.js'; -import { MessageType, type HistoryItemMcpStatus } from '../types.js'; - -const authCommand: SlashCommand = { - name: 'auth', - description: 'Authenticate with an OAuth-enabled MCP server', - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - const serverName = args.trim(); - const { config } = context.services; - - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Config not loaded.', - }; - } - - const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {}; - - if (!serverName) { - // List servers that support OAuth - const oauthServers = Object.entries(mcpServers) - .filter(([_, server]) => server.oauth?.enabled) - .map(([name, _]) => name); - - if (oauthServers.length === 0) { - return { - type: 'message', - messageType: 'info', - content: 'No MCP servers configured with OAuth authentication.', - }; - } - - return { - type: 'message', - messageType: 'info', - content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth to authenticate.`, - }; - } - - const server = mcpServers[serverName]; - if (!server) { - return { - type: 'message', - messageType: 'error', - content: `MCP server '${serverName}' not found.`, - }; - } - - // Always attempt OAuth authentication, even if not explicitly configured - // The authentication process will discover OAuth requirements automatically - - const displayListener = (message: string) => { - context.ui.addItem({ type: 'info', text: message }, Date.now()); - }; - - appEvents.on(AppEvent.OauthDisplayMessage, displayListener); - - try { - context.ui.addItem( - { - type: 'info', - text: `Starting OAuth authentication for MCP server '${serverName}'...`, - }, - Date.now(), - ); - - // Import dynamically to avoid circular dependencies - const { MCPOAuthProvider } = await import('@airiscode/gemini-cli-core'); - - let oauthConfig = server.oauth; - if (!oauthConfig) { - oauthConfig = { enabled: false }; - } - - const mcpServerUrl = server.httpUrl || server.url; - const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); - await authProvider.authenticate( - serverName, - oauthConfig, - mcpServerUrl, - appEvents, - ); - - context.ui.addItem( - { - type: 'info', - text: `✅ Successfully authenticated with MCP server '${serverName}'!`, - }, - Date.now(), - ); - - // Trigger tool re-discovery to pick up authenticated server - const mcpClientManager = config.getMcpClientManager(); - if (mcpClientManager) { - context.ui.addItem( - { - type: 'info', - text: `Restarting MCP server '${serverName}'...`, - }, - Date.now(), - ); - await mcpClientManager.restartServer(serverName); - } - // Update the client with the new tools - const geminiClient = config.getGeminiClient(); - if (geminiClient?.isInitialized()) { - await geminiClient.setTools(); - } - - // Reload the slash commands to reflect the changes. - context.ui.reloadCommands(); - - return { - type: 'message', - messageType: 'info', - content: `Successfully authenticated and refreshed tools for '${serverName}'.`, - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, - }; - } finally { - appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); - } - }, - completion: async (context: CommandContext, partialArg: string) => { - const { config } = context.services; - if (!config) return []; - - const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; - return Object.keys(mcpServers).filter((name) => - name.startsWith(partialArg), - ); - }, -}; - -const listAction = async ( - context: CommandContext, - showDescriptions = false, - showSchema = false, -): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Config not loaded.', - }; - } - - const toolRegistry = config.getToolRegistry(); - if (!toolRegistry) { - return { - type: 'message', - messageType: 'error', - content: 'Could not retrieve tool registry.', - }; - } - - const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; - const serverNames = Object.keys(mcpServers); - const blockedMcpServers = - config.getMcpClientManager()?.getBlockedMcpServers() || []; - - const connectingServers = serverNames.filter( - (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, - ); - const discoveryState = getMCPDiscoveryState(); - const discoveryInProgress = - discoveryState === MCPDiscoveryState.IN_PROGRESS || - connectingServers.length > 0; - - const allTools = toolRegistry.getAllTools(); - const mcpTools = allTools.filter( - (tool) => tool instanceof DiscoveredMCPTool, - ) as DiscoveredMCPTool[]; - - const promptRegistry = await config.getPromptRegistry(); - const mcpPrompts = promptRegistry - .getAllPrompts() - .filter( - (prompt) => - 'serverName' in prompt && - serverNames.includes(prompt.serverName as string), - ) as DiscoveredMCPPrompt[]; - - const authStatus: HistoryItemMcpStatus['authStatus'] = {}; - const tokenStorage = new MCPOAuthTokenStorage(); - for (const serverName of serverNames) { - const server = mcpServers[serverName]; - if (server.oauth?.enabled) { - const creds = await tokenStorage.getCredentials(serverName); - if (creds) { - if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) { - authStatus[serverName] = 'expired'; - } else { - authStatus[serverName] = 'authenticated'; - } - } else { - authStatus[serverName] = 'unauthenticated'; - } - } else { - authStatus[serverName] = 'not-configured'; - } - } - - const mcpStatusItem: HistoryItemMcpStatus = { - type: MessageType.MCP_STATUS, - servers: mcpServers, - tools: mcpTools.map((tool) => ({ - serverName: tool.serverName, - name: tool.name, - description: tool.description, - schema: tool.schema, - })), - prompts: mcpPrompts.map((prompt) => ({ - serverName: prompt.serverName as string, - name: prompt.name, - description: prompt.description, - })), - authStatus, - blockedServers: blockedMcpServers, - discoveryInProgress, - connectingServers, - showDescriptions, - showSchema, - }; - - context.ui.addItem(mcpStatusItem, Date.now()); -}; - -const listCommand: SlashCommand = { - name: 'list', - altNames: ['ls', 'nodesc', 'nodescription'], - description: 'List configured MCP servers and tools', - kind: CommandKind.BUILT_IN, - action: (context) => listAction(context), -}; - -const descCommand: SlashCommand = { - name: 'desc', - altNames: ['description'], - description: 'List configured MCP servers and tools with descriptions', - kind: CommandKind.BUILT_IN, - action: (context) => listAction(context, true), -}; - -const schemaCommand: SlashCommand = { - name: 'schema', - description: - 'List configured MCP servers and tools with descriptions and schemas', - kind: CommandKind.BUILT_IN, - action: (context) => listAction(context, true, true), -}; - -const refreshCommand: SlashCommand = { - name: 'refresh', - description: 'Restarts MCP servers', - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - ): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Config not loaded.', - }; - } - - const mcpClientManager = config.getMcpClientManager(); - if (!mcpClientManager) { - return { - type: 'message', - messageType: 'error', - content: 'Could not retrieve mcp client manager.', - }; - } - - context.ui.addItem( - { - type: 'info', - text: 'Restarting MCP servers...', - }, - Date.now(), - ); - - await mcpClientManager.restart(); - - // Update the client with the new tools - const geminiClient = config.getGeminiClient(); - if (geminiClient?.isInitialized()) { - await geminiClient.setTools(); - } - - // Reload the slash commands to reflect the changes. - context.ui.reloadCommands(); - - return listCommand.action!(context, ''); - }, -}; - -export const mcpCommand: SlashCommand = { - name: 'mcp', - description: 'Manage configured Model Context Protocol (MCP) servers', - kind: CommandKind.BUILT_IN, - subCommands: [ - listCommand, - descCommand, - schemaCommand, - authCommand, - refreshCommand, - ], - action: async (context: CommandContext) => listAction(context), -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/memoryCommand.ts b/apps/airiscode-cli/src/ui-full/commands/memoryCommand.ts deleted file mode 100644 index 32aab8d1d..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/memoryCommand.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - getErrorMessage, - refreshServerHierarchicalMemory, -} from '@airiscode/gemini-cli-core'; -import { MessageType } from '../types.js'; -import type { SlashCommand, SlashCommandActionReturn } from './types.js'; -import { CommandKind } from './types.js'; - -export const memoryCommand: SlashCommand = { - name: 'memory', - description: 'Commands for interacting with memory', - kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'show', - description: 'Show the current memory contents', - kind: CommandKind.BUILT_IN, - action: async (context) => { - const memoryContent = context.services.config?.getUserMemory() || ''; - const fileCount = context.services.config?.getGeminiMdFileCount() || 0; - - const messageContent = - memoryContent.length > 0 - ? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---` - : 'Memory is currently empty.'; - - context.ui.addItem( - { - type: MessageType.INFO, - text: messageContent, - }, - Date.now(), - ); - }, - }, - { - name: 'add', - description: 'Add content to the memory', - kind: CommandKind.BUILT_IN, - action: (context, args): SlashCommandActionReturn | void => { - if (!args || args.trim() === '') { - return { - type: 'message', - messageType: 'error', - content: 'Usage: /memory add ', - }; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: `Attempting to save to memory: "${args.trim()}"`, - }, - Date.now(), - ); - - return { - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact: args.trim() }, - }; - }, - }, - { - name: 'refresh', - description: 'Refresh the memory from the source', - kind: CommandKind.BUILT_IN, - action: async (context) => { - context.ui.addItem( - { - type: MessageType.INFO, - text: 'Refreshing memory from source files...', - }, - Date.now(), - ); - - try { - const config = await context.services.config; - if (config) { - const { memoryContent, fileCount } = - await refreshServerHierarchicalMemory(config); - - const successMessage = - memoryContent.length > 0 - ? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).` - : 'Memory refreshed successfully. No memory content found.'; - - context.ui.addItem( - { - type: MessageType.INFO, - text: successMessage, - }, - Date.now(), - ); - } - } catch (error) { - const errorMessage = getErrorMessage(error); - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Error refreshing memory: ${errorMessage}`, - }, - Date.now(), - ); - } - }, - }, - { - name: 'list', - description: 'Lists the paths of the GEMINI.md files in use', - kind: CommandKind.BUILT_IN, - action: async (context) => { - const filePaths = context.services.config?.getGeminiMdFilePaths() || []; - const fileCount = filePaths.length; - - const messageContent = - fileCount > 0 - ? `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}` - : 'No GEMINI.md files in use.'; - - context.ui.addItem( - { - type: MessageType.INFO, - text: messageContent, - }, - Date.now(), - ); - }, - }, - ], -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/modelCommand.ts b/apps/airiscode-cli/src/ui-full/commands/modelCommand.ts deleted file mode 100644 index bfabbb483..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/modelCommand.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandKind, type SlashCommand } from './types.js'; - -export const modelCommand: SlashCommand = { - name: 'model', - description: 'Opens a dialog to configure the model', - kind: CommandKind.BUILT_IN, - action: async () => ({ - type: 'dialog', - dialog: 'model', - }), -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/policiesCommand.ts b/apps/airiscode-cli/src/ui-full/commands/policiesCommand.ts deleted file mode 100644 index c449990df..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/policiesCommand.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandKind, type SlashCommand } from './types.js'; -import { MessageType } from '../types.js'; - -const listPoliciesCommand: SlashCommand = { - name: 'list', - description: 'List all active policies', - kind: CommandKind.BUILT_IN, - action: async (context) => { - const { config } = context.services; - if (!config) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Error: Config not available.', - }, - Date.now(), - ); - return; - } - - const policyEngine = config.getPolicyEngine(); - const rules = policyEngine.getRules(); - - if (rules.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: 'No active policies.', - }, - Date.now(), - ); - return; - } - - let content = '**Active Policies**\n\n'; - rules.forEach((rule, index) => { - content += `${index + 1}. **${rule.decision.toUpperCase()}**`; - if (rule.toolName) { - content += ` tool: \`${rule.toolName}\``; - } else { - content += ` all tools`; - } - if (rule.argsPattern) { - content += ` (args match: \`${rule.argsPattern.source}\`)`; - } - if (rule.priority !== undefined) { - content += ` [Priority: ${rule.priority}]`; - } - content += '\n'; - }); - - context.ui.addItem( - { - type: MessageType.INFO, - text: content, - }, - Date.now(), - ); - }, -}; - -export const policiesCommand: SlashCommand = { - name: 'policies', - description: 'Manage policies', - kind: CommandKind.BUILT_IN, - subCommands: [listPoliciesCommand], -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/privacyCommand.ts b/apps/airiscode-cli/src/ui-full/commands/privacyCommand.ts deleted file mode 100644 index 838590040..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/privacyCommand.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; - -export const privacyCommand: SlashCommand = { - name: 'privacy', - description: 'Display the privacy notice', - kind: CommandKind.BUILT_IN, - action: (): OpenDialogActionReturn => ({ - type: 'dialog', - dialog: 'privacy', - }), -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/profileCommand.ts b/apps/airiscode-cli/src/ui-full/commands/profileCommand.ts deleted file mode 100644 index e31fc82be..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/profileCommand.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { isDevelopment } from '../../utils/installationInfo.js'; -import { CommandKind, type SlashCommand } from './types.js'; - -export const profileCommand: SlashCommand | null = isDevelopment - ? { - name: 'profile', - kind: CommandKind.BUILT_IN, - description: 'Toggle the debug profile display', - action: async (context) => { - context.ui.toggleDebugProfiler(); - return { - type: 'message', - messageType: 'info', - content: 'Toggled profile display.', - }; - }, - } - : null; diff --git a/apps/airiscode-cli/src/ui-full/commands/terminalSetupCommand.ts b/apps/airiscode-cli/src/ui-full/commands/terminalSetupCommand.ts deleted file mode 100644 index 09e5240c5..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/terminalSetupCommand.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { MessageActionReturn, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; -import { terminalSetup } from '../utils/terminalSetup.js'; - -/** - * Command to configure terminal keybindings for multiline input support. - * - * This command automatically detects and configures VS Code, Cursor, and Windsurf - * to support Shift+Enter and Ctrl+Enter for multiline input. - */ -export const terminalSetupCommand: SlashCommand = { - name: 'terminal-setup', - description: - 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)', - kind: CommandKind.BUILT_IN, - - action: async (): Promise => { - try { - const result = await terminalSetup(); - - let content = result.message; - if (result.requiresRestart) { - content += - '\n\nPlease restart your terminal for the changes to take effect.'; - } - - return { - type: 'message', - content, - messageType: result.success ? 'info' : 'error', - }; - } catch (error) { - return { - type: 'message', - content: `Failed to configure terminal: ${error}`, - messageType: 'error', - }; - } - }, -}; diff --git a/apps/airiscode-cli/src/ui-full/commands/types.ts b/apps/airiscode-cli/src/ui-full/commands/types.ts deleted file mode 100644 index cb17b2aaa..000000000 --- a/apps/airiscode-cli/src/ui-full/commands/types.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ReactNode } from 'react'; -import type { Content, PartListUnion } from '@google/genai'; -import type { - HistoryItemWithoutId, - HistoryItem, - ConfirmationRequest, -} from '../types.js'; -import type { Config, GitService, Logger } from '@airiscode/gemini-cli-core'; -import type { LoadedSettings } from '../../config/settings.js'; -import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; -import type { SessionStatsState } from '../contexts/SessionContext.js'; -import type { - ExtensionUpdateAction, - ExtensionUpdateStatus, -} from '../state/extensions.js'; - -// Grouped dependencies for clarity and easier mocking -export interface CommandContext { - // Invocation properties for when commands are called. - invocation?: { - /** The raw, untrimmed input string from the user. */ - raw: string; - /** The primary name of the command that was matched. */ - name: string; - /** The arguments string that follows the command name. */ - args: string; - }; - // Core services and configuration - services: { - // TODO(abhipatel12): Ensure that config is never null. - config: Config | null; - settings: LoadedSettings; - git: GitService | undefined; - logger: Logger; - }; - // UI state and history management - ui: { - /** Adds a new item to the history display. */ - addItem: UseHistoryManagerReturn['addItem']; - /** Clears all history items and the console screen. */ - clear: () => void; - /** - * Sets the transient debug message displayed in the application footer in debug mode. - */ - setDebugMessage: (message: string) => void; - /** The currently pending history item, if any. */ - pendingItem: HistoryItemWithoutId | null; - /** - * Sets a pending item in the history, which is useful for indicating - * that a long-running operation is in progress. - * - * @param item The history item to display as pending, or `null` to clear. - */ - setPendingItem: (item: HistoryItemWithoutId | null) => void; - /** - * Loads a new set of history items, replacing the current history. - * - * @param history The array of history items to load. - */ - loadHistory: UseHistoryManagerReturn['loadHistory']; - /** Toggles a special display mode. */ - toggleCorgiMode: () => void; - toggleDebugProfiler: () => void; - toggleVimEnabled: () => Promise; - reloadCommands: () => void; - extensionsUpdateState: Map; - dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; - addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; - }; - // Session-specific data - session: { - stats: SessionStatsState; - /** A transient list of shell commands the user has approved for this session. */ - sessionShellAllowlist: Set; - }; - // Flag to indicate if an overwrite has been confirmed - overwriteConfirmed?: boolean; -} - -/** - * The return type for a command action that results in scheduling a tool call. - */ -export interface ToolActionReturn { - type: 'tool'; - toolName: string; - toolArgs: Record; -} - -/** The return type for a command action that results in the app quitting. */ -export interface QuitActionReturn { - type: 'quit'; - messages: HistoryItem[]; -} - -/** - * The return type for a command action that results in a simple message - * being displayed to the user. - */ -export interface MessageActionReturn { - type: 'message'; - messageType: 'info' | 'error'; - content: string; -} - -/** - * The return type for a command action that needs to open a dialog. - */ -export interface OpenDialogActionReturn { - type: 'dialog'; - - dialog: - | 'help' - | 'auth' - | 'theme' - | 'editor' - | 'privacy' - | 'settings' - | 'model' - | 'permissions'; -} - -/** - * The return type for a command action that results in replacing - * the entire conversation history. - */ -export interface LoadHistoryActionReturn { - type: 'load_history'; - history: HistoryItemWithoutId[]; - clientHistory: Content[]; // The history for the generative client -} - -/** - * The return type for a command action that should immediately submit - * content as a prompt to the Gemini model. - */ -export interface SubmitPromptActionReturn { - type: 'submit_prompt'; - content: PartListUnion; -} - -/** - * The return type for a command action that needs to pause and request - * confirmation for a set of shell commands before proceeding. - */ -export interface ConfirmShellCommandsActionReturn { - type: 'confirm_shell_commands'; - /** The list of shell commands that require user confirmation. */ - commandsToConfirm: string[]; - /** The original invocation context to be re-run after confirmation. */ - originalInvocation: { - raw: string; - }; -} - -export interface ConfirmActionReturn { - type: 'confirm_action'; - /** The React node to display as the confirmation prompt. */ - prompt: ReactNode; - /** The original invocation context to be re-run after confirmation. */ - originalInvocation: { - raw: string; - }; -} - -export type SlashCommandActionReturn = - | ToolActionReturn - | MessageActionReturn - | QuitActionReturn - | OpenDialogActionReturn - | LoadHistoryActionReturn - | SubmitPromptActionReturn - | ConfirmShellCommandsActionReturn - | ConfirmActionReturn; - -export enum CommandKind { - BUILT_IN = 'built-in', - FILE = 'file', - MCP_PROMPT = 'mcp-prompt', -} - -// The standardized contract for any command in the system. -export interface SlashCommand { - name: string; - altNames?: string[]; - description: string; - hidden?: boolean; - - kind: CommandKind; - - // Optional metadata for extension commands - extensionName?: string; - extensionId?: string; - - // The action to run. Optional for parent commands that only group sub-commands. - action?: ( - context: CommandContext, - args: string, // TODO: Remove args. CommandContext now contains the complete invocation. - ) => - | void - | SlashCommandActionReturn - | Promise; - - // Provides argument completion (e.g., completing a tag for `/chat resume `). - completion?: ( - context: CommandContext, - partialArg: string, - ) => Promise | string[]; - - subCommands?: SlashCommand[]; -} diff --git a/apps/airiscode-cli/src/ui-full/components/AboutBox.tsx b/apps/airiscode-cli/src/ui-full/components/AboutBox.tsx deleted file mode 100644 index b05d3d3d8..000000000 --- a/apps/airiscode-cli/src/ui-full/components/AboutBox.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.ts'; -import { GIT_COMMIT_INFO } from '../../generated/git-commit.ts'; - -interface AboutBoxProps { - cliVersion: string; - osVersion: string; - sandboxEnv: string; - modelVersion: string; - selectedAuthType: string; - gcpProject: string; - ideClient: string; -} - -export const AboutBox: React.FC = ({ - cliVersion, - osVersion, - sandboxEnv, - modelVersion, - selectedAuthType, - gcpProject, - ideClient, -}) => ( - - - - About Gemini CLI - - - - - - CLI Version - - - - {cliVersion} - - - {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && ( - - - - Git Commit - - - - {GIT_COMMIT_INFO} - - - )} - - - - Model - - - - {modelVersion} - - - - - - Sandbox - - - - {sandboxEnv} - - - - - - OS - - - - {osVersion} - - - - - - Auth Method - - - - - {selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType} - - - - {gcpProject && ( - - - - GCP Project - - - - {gcpProject} - - - )} - {ideClient && ( - - - - IDE Client - - - - {ideClient} - - - )} - -); diff --git a/apps/airiscode-cli/src/ui-full/components/AlternateBufferQuittingDisplay.tsx b/apps/airiscode-cli/src/ui-full/components/AlternateBufferQuittingDisplay.tsx deleted file mode 100644 index 0defa735e..000000000 --- a/apps/airiscode-cli/src/ui-full/components/AlternateBufferQuittingDisplay.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box } from 'ink'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { AppHeader } from './AppHeader.js'; -import { HistoryItemDisplay } from './HistoryItemDisplay.js'; -import { QuittingDisplay } from './QuittingDisplay.js'; -import { useAppContext } from '../contexts/AppContext.js'; -import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; - -export const AlternateBufferQuittingDisplay = () => { - const { version } = useAppContext(); - const uiState = useUIState(); - - // We render the entire chat history and header here to ensure that the - // conversation history is visible to the user after the app quits and the - // user exits alternate buffer mode. - // Our version of Ink is clever and will render a final frame outside of - // the alternate buffer on app exit. - return ( - - - {uiState.history.map((h) => ( - - ))} - {uiState.pendingHistoryItems.map((item, i) => ( - - ))} - - - ); -}; diff --git a/apps/airiscode-cli/src/ui-full/components/AnsiOutput.tsx b/apps/airiscode-cli/src/ui-full/components/AnsiOutput.tsx deleted file mode 100644 index f178e9818..000000000 --- a/apps/airiscode-cli/src/ui-full/components/AnsiOutput.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import type { AnsiLine, AnsiOutput, AnsiToken } from '@airiscode/gemini-cli-core'; - -const DEFAULT_HEIGHT = 24; - -interface AnsiOutputProps { - data: AnsiOutput; - availableTerminalHeight?: number; - width: number; -} - -export const AnsiOutputText: React.FC = ({ - data, - availableTerminalHeight, - width, -}) => { - const lastLines = data.slice( - -(availableTerminalHeight && availableTerminalHeight > 0 - ? availableTerminalHeight - : DEFAULT_HEIGHT), - ); - return ( - - {lastLines.map((line: AnsiLine, lineIndex: number) => ( - - {line.length > 0 - ? line.map((token: AnsiToken, tokenIndex: number) => ( - - {token.text} - - )) - : null} - - ))} - - ); -}; diff --git a/apps/airiscode-cli/src/ui-full/components/AppHeader.tsx b/apps/airiscode-cli/src/ui-full/components/AppHeader.tsx deleted file mode 100644 index 9b6c5fc3b..000000000 --- a/apps/airiscode-cli/src/ui-full/components/AppHeader.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box } from 'ink'; -import { Header } from './Header.js'; -import { Tips } from './Tips.js'; -import { useSettings } from '../contexts/SettingsContext.js'; -import { useConfig } from '../contexts/ConfigContext.js'; -import { useUIState } from '../contexts/UIStateContext.js'; - -interface AppHeaderProps { - version: string; -} - -export const AppHeader = ({ version }: AppHeaderProps) => { - const settings = useSettings(); - const config = useConfig(); - const { nightly } = useUIState(); - - return ( - - {!(settings.merged.ui?.hideBanner || config.getScreenReader()) && ( -
- )} - {!(settings.merged.ui?.hideTips || config.getScreenReader()) && ( - - )} - - ); -}; diff --git a/apps/airiscode-cli/src/ui-full/components/AsciiArt.ts b/apps/airiscode-cli/src/ui-full/components/AsciiArt.ts deleted file mode 100644 index e1cb5e058..000000000 --- a/apps/airiscode-cli/src/ui-full/components/AsciiArt.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -export const shortAsciiLogo = ` - █████████ ██████████ ██████ ██████ █████ ██████ █████ █████ - ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███ - ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███ -░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███ -░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███ -░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███ - ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████ - ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ -`; - -export const longAsciiLogo = ` - ███ █████████ ██████████ ██████ ██████ █████ ██████ █████ █████ -░░░███ ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███ - ░░░███ ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███ - ░░░███ ░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███ - ███░ ░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███ - ███░ ░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███ - ███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████ -░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ -`; - -export const tinyAsciiLogo = ` - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ -`; - -export const shortAsciiLogoIde = ` - ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ - ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░ - ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ - █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░ - ███░░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░ - ███░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░ - ███░░░░████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░ - ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███ - ███ ███ ███ ███ ███ ███ ███ █████ ███ - █████████ ██████████ ███ ███ █████ ███ █████ █████ -`; - -export const longAsciiLogoIde = ` - ░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ - ░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░ - ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ - ███ ░░░ █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░ - ███ ░░░ ███░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░ - ███ ███░░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░ - ░░░ ███ ███ ░░░█████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░ - ███ ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███ - ███ ███ ███ ███ ███ ███ ███ ███ █████ ███ - ███ █████████ ██████████ ███ ███ █████ ███ █████ █████ -`; - -export const tinyAsciiLogoIde = ` - ░░░ ░░░░░░░░░ - ░░░ ░░░ ░░░ - ░░░ ░░░ - ███ ░░░ █████████░░░ - ███ ░░░ ███░░ ███░░ - ███ ███░░ ░░░ - ░░░ ███ ███░░░░████░ - ███ ███ ███ - ███ ███ ███ - ███ █████████ -`; diff --git a/apps/airiscode-cli/src/ui-full/components/AutoAcceptIndicator.tsx b/apps/airiscode-cli/src/ui-full/components/AutoAcceptIndicator.tsx deleted file mode 100644 index 3023ac1a7..000000000 --- a/apps/airiscode-cli/src/ui-full/components/AutoAcceptIndicator.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import { ApprovalMode } from '@airiscode/gemini-cli-core'; - -interface AutoAcceptIndicatorProps { - approvalMode: ApprovalMode; -} - -export const AutoAcceptIndicator: React.FC = ({ - approvalMode, -}) => { - let textColor = ''; - let textContent = ''; - let subText = ''; - - switch (approvalMode) { - case ApprovalMode.AUTO_EDIT: - textColor = theme.status.warning; - textContent = 'accepting edits'; - subText = ' (shift + tab to toggle)'; - break; - case ApprovalMode.YOLO: - textColor = theme.status.error; - textContent = 'YOLO mode'; - subText = ' (ctrl + y to toggle)'; - break; - case ApprovalMode.DEFAULT: - default: - break; - } - - return ( - - - {textContent} - {subText && {subText}} - - - ); -}; diff --git a/apps/airiscode-cli/src/ui-full/components/CliSpinner.tsx b/apps/airiscode-cli/src/ui-full/components/CliSpinner.tsx deleted file mode 100644 index 6795bf267..000000000 --- a/apps/airiscode-cli/src/ui-full/components/CliSpinner.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import Spinner from 'ink-spinner'; -import { type ComponentProps, useEffect } from 'react'; -import { debugState } from '../debug.js'; - -export type SpinnerProps = ComponentProps; - -export const CliSpinner = (props: SpinnerProps) => { - useEffect(() => { - debugState.debugNumAnimatedComponents++; - return () => { - debugState.debugNumAnimatedComponents--; - }; - }, []); - - return ; -}; diff --git a/apps/airiscode-cli/src/ui-full/components/Composer.tsx b/apps/airiscode-cli/src/ui-full/components/Composer.tsx deleted file mode 100644 index e5603ff20..000000000 --- a/apps/airiscode-cli/src/ui-full/components/Composer.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState } from 'react'; -import { Box, Text, useIsScreenReaderEnabled } from 'ink'; -import { LoadingIndicator } from './LoadingIndicator.js'; -import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; -import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; -import { ShellModeIndicator } from './ShellModeIndicator.js'; -import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; -import { InputPrompt } from './InputPrompt.js'; -import { Footer } from './Footer.js'; -import { ShowMoreLines } from './ShowMoreLines.js'; -import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; -import { OverflowProvider } from '../contexts/OverflowContext.js'; -import { theme } from '../semantic-colors.js'; -import { isNarrowWidth } from '../utils/isNarrowWidth.js'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { useUIActions } from '../contexts/UIActionsContext.js'; -import { useVimMode } from '../contexts/VimModeContext.js'; -import { useConfig } from '../contexts/ConfigContext.js'; -import { useSettings } from '../contexts/SettingsContext.js'; -import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; -import { ApprovalMode } from '@airiscode/gemini-cli-core'; -import { StreamingState } from '../types.js'; -import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; -import { TodoTray } from './messages/Todo.js'; - -export const Composer = () => { - const config = useConfig(); - const settings = useSettings(); - const isScreenReaderEnabled = useIsScreenReaderEnabled(); - const uiState = useUIState(); - const uiActions = useUIActions(); - const { vimEnabled } = useVimMode(); - const terminalWidth = process.stdout.columns; - const isNarrow = isNarrowWidth(terminalWidth); - const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); - const [suggestionsVisible, setSuggestionsVisible] = useState(false); - - const isAlternateBuffer = useAlternateBuffer(); - const { contextFileNames, showAutoAcceptIndicator } = uiState; - const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; - const hideContextSummary = - suggestionsVisible && suggestionsPosition === 'above'; - - return ( - - {!uiState.embeddedShellFocused && ( - - )} - - {(!uiState.slashCommands || !uiState.isConfigInitialized) && ( - - )} - - - - - - - - {process.env['GEMINI_SYSTEM_MD'] && ( - |⌐■_■| - )} - {uiState.ctrlCPressedOnce ? ( - - Press Ctrl+C again to exit. - - ) : uiState.ctrlDPressedOnce ? ( - - Press Ctrl+D again to exit. - - ) : uiState.showEscapePrompt ? ( - Press Esc again to clear. - ) : uiState.queueErrorMessage ? ( - {uiState.queueErrorMessage} - ) : ( - !settings.merged.ui?.hideContextSummary && - !hideContextSummary && ( - - ) - )} - - - {showAutoAcceptIndicator !== ApprovalMode.DEFAULT && - !uiState.shellModeActive && ( - - )} - {uiState.shellModeActive && } - {!uiState.renderMarkdown && } - - - - {uiState.showErrorDetails && ( - - - - - - - )} - - {uiState.isInputActive && ( - - )} - - {!settings.merged.ui?.hideFooter && !isScreenReaderEnabled &&