diff --git a/src/iac_code/__init__.py b/src/iac_code/__init__.py index d78f9bf..d9b9b21 100644 --- a/src/iac_code/__init__.py +++ b/src/iac_code/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.3.1" +__version__ = "0.4.0" __release_date__ = "" diff --git a/src/iac_code/acp/server.py b/src/iac_code/acp/server.py index 02c9f9f..6ccf006 100644 --- a/src/iac_code/acp/server.py +++ b/src/iac_code/acp/server.py @@ -18,8 +18,12 @@ from iac_code.acp.version import negotiate_version from iac_code.commands import LocalCommand, create_default_registry from iac_code.config import DEFAULT_MODEL, get_active_provider_key, load_saved_model +from iac_code.i18n import _ from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime +from iac_code.services.session_index import SessionEntry, SessionIndex +from iac_code.services.session_resolver import ResolutionStatus, resolve_session_argument from iac_code.services.session_storage import SessionStorage +from iac_code.utils.project_paths import format_resume_command, same_project_path SESSION_IDLE_TIMEOUT = 3600 # 1 hour CLEANUP_INTERVAL = 300 # 5 minutes @@ -152,7 +156,12 @@ async def new_session( runtime.session_id, ) session = ACPSession( - runtime.session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics + runtime.session_id, + runtime.agent_loop, + self.conn, + mcp_configs=mcp_configs, + metrics=self.metrics, + memory_manager=getattr(runtime, "memory_manager", None), ) self.sessions[session.id] = session self.metrics.record_session_created() @@ -228,25 +237,16 @@ async def list_sessions( cwd: str | None = None, **kwargs: Any, ) -> acp.schema.ListSessionsResponse: - from iac_code.utils.project_paths import get_project_dir, get_projects_dir - - session_ids: list[str] = [] - if cwd: - project_dir = get_project_dir(cwd) - if project_dir.exists(): - session_ids = [p.stem for p in project_dir.glob("*.jsonl")] - else: - projects_root = get_projects_dir() - if projects_root.exists(): - session_ids = [p.stem for p in projects_root.glob("*/*.jsonl")] + index = SessionIndex() + entries = index.list_for_cwd(cwd) if cwd else index.list_all_projects() return acp.schema.ListSessionsResponse( sessions=[ acp.schema.SessionInfo( - session_id=session_id, - cwd=cwd or "", - title=session_id, + session_id=entry.session_id, + cwd=entry.cwd or cwd or "", + title=entry.title, ) - for session_id in session_ids + for entry in entries ], next_cursor=None, ) @@ -297,7 +297,14 @@ async def load_session( runtime.agent_loop.context_manager.load_messages(history) # 4. Register session - session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics) + session = ACPSession( + session_id, + runtime.agent_loop, + self.conn, + mcp_configs=mcp_configs, + metrics=self.metrics, + memory_manager=getattr(runtime, "memory_manager", None), + ) self.sessions[session_id] = session self.metrics.record_session_created() logger.info("Session loaded, session_id=%s, history_messages=%d", session_id, len(history)) @@ -360,7 +367,12 @@ async def fork_session( # 4. Register the forked session session = ACPSession( - new_session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics + new_session_id, + runtime.agent_loop, + self.conn, + mcp_configs=mcp_configs, + metrics=self.metrics, + memory_manager=getattr(runtime, "memory_manager", None), ) self.sessions[new_session_id] = session self.metrics.record_session_created() @@ -384,20 +396,66 @@ async def resume_session( mcp_servers: list[MCPServer] | None = None, **kwargs: Any, ) -> acp.schema.ResumeSessionResponse: - # 1. If session is still active in memory, return directly - if session_id in self.sessions: + # 1. If session is still active in memory by exact id, enforce project ownership before returning. + active_session = self.sessions.get(session_id) + if active_session is not None: + error = _active_session_project_error(cwd, session_id, session_id, active_session) + if error is not None: + raise error await self._push_available_commands(session_id) return acp.schema.ResumeSessionResponse() if self.conn is None: raise acp.RequestError.internal_error({"error": "ACP client not connected"}) - # 2. Try to load persisted history from SessionStorage + resolution = resolve_session_argument(SessionIndex(), cwd, session_id) + if resolution.status == ResolutionStatus.NOT_FOUND: + raise _invalid_params(_("Session not found"), {"session_id": session_id}) + if resolution.status == ResolutionStatus.AMBIGUOUS_NAME: + candidate_ids = [entry.session_id for entry in resolution.candidates] + message = _("Session name is ambiguous. Candidates: {candidates}").format( + candidates=", ".join(candidate_ids) + ) + raise _invalid_params( + message, + { + "session_id": session_id, + "candidates": [_resume_candidate_data(entry) for entry in resolution.candidates], + }, + ) + + entry = resolution.entry + if entry is None: # pragma: no cover - defensive guard for inconsistent resolver output + raise _invalid_params(_("Session not found"), {"session_id": session_id}) + + resolved_session_id = entry.session_id + if entry.cwd and not same_project_path(entry.cwd, cwd): + hint = _resume_command(entry.cwd, resolved_session_id) + message = _("Session belongs to another project. Run: {hint}").format(hint=hint) + raise _invalid_params( + message, + { + "session_id": session_id, + "resolved_session_id": resolved_session_id, + "cwd": entry.cwd, + "hint": hint, + }, + ) + + active_session = self.sessions.get(resolved_session_id) + if active_session is not None: + error = _active_session_project_error(cwd, session_id, resolved_session_id, active_session) + if error is not None: + raise error + await self._push_available_commands(resolved_session_id) + return acp.schema.ResumeSessionResponse() + + # 2. Try to load persisted history from SessionStorage. storage = SessionStorage() - if not storage.exists(cwd, session_id): - raise acp.RequestError.invalid_params({"session_id": "Session not found"}) + if not storage.exists(cwd, resolved_session_id): + raise _invalid_params(_("Session not found"), {"session_id": session_id}) - history = storage.load(cwd, session_id) + history = storage.load(cwd, resolved_session_id) history = SessionStorage.repair_interrupted(history) # Convert MCP server configs from ACP protocol types to internal dicts @@ -405,7 +463,7 @@ async def resume_session( # 3. Rebuild agent runtime with restored history model = load_saved_model() or DEFAULT_MODEL - runtime = self._create_runtime_with_auth_check(model=model, session_id=session_id, cwd=cwd) + runtime = self._create_runtime_with_auth_check(model=model, session_id=resolved_session_id, cwd=cwd) replace_bash_with_acp_terminal( runtime.tool_registry, self.client_capabilities, @@ -418,10 +476,17 @@ async def resume_session( runtime.agent_loop.context_manager.load_messages(history) # 4. Register the resumed session - session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics) - self.sessions[session_id] = session + session = ACPSession( + resolved_session_id, + runtime.agent_loop, + self.conn, + mcp_configs=mcp_configs, + metrics=self.metrics, + memory_manager=getattr(runtime, "memory_manager", None), + ) + self.sessions[resolved_session_id] = session self.metrics.record_session_created() - await self._push_available_commands(session_id) + await self._push_available_commands(resolved_session_id) return acp.schema.ResumeSessionResponse() @@ -619,6 +684,48 @@ def _convert_mcp_servers(mcp_servers: list[MCPServer] | None) -> list[dict[str, return configs +def _invalid_params(message: str, data: dict[str, Any] | None = None) -> acp.RequestError: + """Create an ACP invalid-params error with a useful message.""" + return acp.RequestError(-32602, message, data) + + +def _resume_command(cwd: str, session_id: str) -> str: + return format_resume_command(cwd, session_id) + + +def _active_session_cwd(session: ACPSession) -> str | None: + cwd = getattr(session.agent_loop, "_cwd", None) + return cwd if isinstance(cwd, str) and cwd else None + + +def _active_session_project_error( + cwd: str, session_id: str, resolved_session_id: str, session: ACPSession +) -> acp.RequestError | None: + active_cwd = _active_session_cwd(session) + if not active_cwd or same_project_path(active_cwd, cwd): + return None + hint = _resume_command(active_cwd, resolved_session_id) + message = _("Session belongs to another project. Run: {hint}").format(hint=hint) + return _invalid_params( + message, + { + "session_id": session_id, + "resolved_session_id": resolved_session_id, + "cwd": active_cwd, + "hint": hint, + }, + ) + + +def _resume_candidate_data(entry: SessionEntry) -> dict[str, str | None]: + return { + "session_id": entry.session_id, + "name": entry.name, + "cwd": entry.cwd, + "command": _resume_command(entry.cwd, entry.session_id), + } + + # --------------------------------------------------------------------------- # Auth methods declaration # --------------------------------------------------------------------------- diff --git a/src/iac_code/acp/session.py b/src/iac_code/acp/session.py index 9761ddc..adc717b 100644 --- a/src/iac_code/acp/session.py +++ b/src/iac_code/acp/session.py @@ -165,9 +165,11 @@ def __init__( conn: acp.Client, mcp_configs: list[dict] | None = None, metrics: ACPMetrics | None = None, + memory_manager=None, ) -> None: self.id = session_id self.agent_loop = agent_loop + self.memory_manager = memory_manager self._conn = conn self._current_task: asyncio.Task | None = None self._replay_task: asyncio.Task[None] | None = None @@ -292,7 +294,11 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: prompt_text = acp_blocks_to_prompt_text(prompt) slash_registry = ACPSlashRegistry() if slash_registry.is_slash_command(prompt_text): - result = await slash_registry.execute(prompt_text, self.agent_loop) + result = await slash_registry.execute( + prompt_text, + self.agent_loop, + memory_manager=self.memory_manager, + ) await self._conn.session_update( session_id=self.id, update=acp.schema.AgentMessageChunk( diff --git a/src/iac_code/acp/slash_registry.py b/src/iac_code/acp/slash_registry.py index 24ca565..bdcdc50 100644 --- a/src/iac_code/acp/slash_registry.py +++ b/src/iac_code/acp/slash_registry.py @@ -1,7 +1,7 @@ """ACP slash command registry. Manages commands supported over the ACP protocol. -Only /compact, /clear, and /debug are allowed; +Only /compact, /clear, /debug, /memory, and /rename are allowed; all other slash commands are rejected with a clear message. """ @@ -10,10 +10,12 @@ import logging from iac_code.i18n import _ +from iac_code.services.session_metadata import normalize_session_name +from iac_code.services.session_storage import SessionStorage logger = logging.getLogger(__name__) -ACP_SUPPORTED_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug"}) +ACP_SUPPORTED_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug", "memory", "rename"}) class ACPSlashRegistry: @@ -51,6 +53,10 @@ async def execute(self, text: str, agent_loop, **context) -> str: return await self._handle_clear(agent_loop) if cmd_name == "debug": return self._handle_debug(args_str) + if cmd_name == "memory": + return self._handle_memory(args_str, context.get("memory_manager")) + if cmd_name == "rename": + return self._handle_rename(args_str, agent_loop) # Should not reach here return _("Command '/{cmd_name}' handler not implemented.").format(cmd_name=cmd_name) # pragma: no cover @@ -123,3 +129,36 @@ def _handle_debug(self, args: str) -> str: return _("Debug logging disabled.") return _("Usage: /debug [on|off]") + + def _handle_memory(self, args: str, memory_manager) -> str: + """View and manage persistent memories.""" + if memory_manager is None: + return _("Memory manager is unavailable.") + + from iac_code.commands.memory import execute_memory_command + + return execute_memory_command(memory_manager, args.split()) + + def _handle_rename(self, args: str, agent_loop) -> str: + """Rename the current ACP session non-interactively.""" + parts = args.split() + if len(parts) != 1: + return _("Usage: /rename ") + + cwd = getattr(agent_loop, "_cwd", None) + session_id = getattr(agent_loop, "_session_id", None) + git_branch = getattr(agent_loop, "_current_git_branch", None) + if not isinstance(cwd, str) or not isinstance(session_id, str): + return _("Rename is only available after a session is created.") + if not isinstance(git_branch, str): + git_branch = None + + try: + name = normalize_session_name(parts[0]) + result = SessionStorage().rename_session(cwd, session_id, name, git_branch=git_branch) + except ValueError as exc: + return str(exc) + + if result == "unchanged": + return _("Session is already named {name}").format(name=name) + return _("Renamed session to {name}").format(name=name) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index bf4deb1..e7ccafa 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -15,6 +15,7 @@ from iac_code.agent.message import ContentBlock, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock from iac_code.i18n import _ from iac_code.services.context_manager import ContextManager +from iac_code.services.session_usage import SessionUsageStore, SessionUsageTotals from iac_code.tools.base import ToolContext, ToolRegistry, ToolResult from iac_code.tools.result_storage import ResultStorage from iac_code.tools.tool_executor import ToolCallRequest, ToolExecutor @@ -65,6 +66,7 @@ def __init__( tool_registry: ToolRegistry, max_turns: int = 100, session_storage: Any = None, # SessionStorage + session_usage_store: SessionUsageStore | None = None, session_id: str | None = None, resume_messages: list | None = None, cwd: str | None = None, @@ -79,6 +81,8 @@ def __init__( self._session_storage = session_storage self._session_id = session_id or str(uuid.uuid4())[:8] self._cwd = cwd or os.getcwd() + self._session_usage_store = session_usage_store or SessionUsageStore() + self._session_usage_totals = self._session_usage_store.load(self._cwd, self._session_id) self._permission_context = permission_context self._permission_context_getter = permission_context_getter self._auto_trigger_skills = auto_trigger_skills or [] @@ -113,6 +117,10 @@ def set_provider(self, provider_manager: Any, system_prompt: str | None = None) self.system_prompt = system_prompt self.context_manager.set_system_prompt(system_prompt) + def set_auto_trigger_skills(self, skill_commands: list[Any] | None) -> None: + """Refresh skills considered for automatic trigger injection.""" + self._auto_trigger_skills = list(skill_commands or []) + def _get_tool_definitions(self): """Convert tool registry to provider ToolDefinition format.""" from iac_code.providers.base import ToolDefinition @@ -250,6 +258,7 @@ async def run_streaming(self, user_input: str | list[ContentBlock]) -> AsyncGene final_text_chunks.append(event.text) if isinstance(event, MessageEndEvent): final_stop_reason = event.stop_reason + self._record_session_usage(event.usage) yield event except asyncio.CancelledError: log_event(Events.SESSION_CANCELLED, {"stage": "in_query"}) @@ -609,6 +618,7 @@ async def _auto_compact(self) -> CompactionEvent | None: messages=[ProviderMessage.user(compaction_prompt)], system="You are a helpful assistant that summarizes conversations concisely.", ) + self._record_response_usage(response) if response.text: original, new = self.context_manager.apply_compaction(response.text) duration_ms = int((time.monotonic() - started) * 1000) @@ -650,6 +660,7 @@ async def compact(self) -> CompactResult: messages=[ProviderMessage.user(compaction_prompt)], system="You are a helpful assistant that summarizes conversations concisely.", ) + self._record_response_usage(response) if response.text: original, compacted = self.context_manager.apply_compaction(response.text) return CompactResult( @@ -691,6 +702,7 @@ def replace_session(self, session_id: str, resume_messages: list | None) -> None self.context_manager.reset() if resume_messages: self.context_manager.load_messages(resume_messages) + self._session_usage_totals = self._session_usage_store.load(self._cwd, self._session_id) self._result_storage = ResultStorage( storage_dir=os.path.join(str(get_config_dir()), "tool-results", session_id), ) @@ -712,5 +724,54 @@ def reset(self) -> None: self._auto_loaded_skills.clear() self.context_manager.reset() + @property + def session_id(self) -> str: + return self._session_id + + @property + def max_turns(self) -> int: + return self._max_turns + def get_context_usage(self) -> dict: return self.context_manager.get_usage() + + def get_session_usage(self) -> SessionUsageTotals: + return self._session_usage_totals.copy() + + def _record_session_usage(self, usage: Usage) -> None: + if not self._session_usage_totals.add(usage): + return + + provider = self._get_runtime_provider_key() + model = self._provider_manager.get_model_name() if hasattr(self._provider_manager, "get_model_name") else "" + try: + self._session_usage_store.append( + self._cwd, + self._session_id, + usage, + provider=provider, + model=model, + ) + except Exception as exc: + logger.debug("Failed to persist session usage for {}: {}", self._session_id, exc) + + def _record_response_usage(self, response: Any) -> None: + usage = getattr(response, "usage", None) + if isinstance(usage, Usage): + self._record_session_usage(usage) + + def _get_runtime_provider_key(self) -> str: + if hasattr(self._provider_manager, "get_provider_key"): + try: + provider_key = self._provider_manager.get_provider_key() + except Exception: + pass + else: + if isinstance(provider_key, str): + return provider_key + try: + from iac_code.config import get_active_provider_key + + return get_active_provider_key() or "" + except Exception: + return "" diff --git a/src/iac_code/cli/main.py b/src/iac_code/cli/main.py index 060b8c1..8879c36 100644 --- a/src/iac_code/cli/main.py +++ b/src/iac_code/cli/main.py @@ -86,7 +86,7 @@ def main( debug: bool = typer.Option(False, "--debug", "-d", help=_("Enable debug logging")), verbose: bool = typer.Option(False, "--verbose", help=_("Show headless progress on stderr")), version: bool = typer.Option(False, "--version", "-v", "-V", is_eager=True, help=_("Show version and exit")), - resume: str = typer.Option("", "--resume", "-r", help=_("Resume a session by ID")), + resume: str = typer.Option("", "--resume", "-r", help=_("Resume a session by ID or name")), continue_session: bool = typer.Option(False, "--continue", "-c", help=_("Resume the most recent session")), install_completion: bool = typer.Option( None, diff --git a/src/iac_code/commands/__init__.py b/src/iac_code/commands/__init__.py index 88aafdf..0ef1557 100644 --- a/src/iac_code/commands/__init__.py +++ b/src/iac_code/commands/__init__.py @@ -7,9 +7,13 @@ from iac_code.commands.effort import effort_command from iac_code.commands.exit import exit_command from iac_code.commands.help import help_command +from iac_code.commands.memory import memory_command from iac_code.commands.model import model_command -from iac_code.commands.registry import Command, CommandRegistry, LocalCommand, PromptCommand +from iac_code.commands.registry import Command, CommandRegistry, CommandResult, LocalCommand, PromptCommand +from iac_code.commands.rename import rename_command from iac_code.commands.resume import resume_command +from iac_code.commands.skills import skills_command +from iac_code.commands.status import status_command from iac_code.i18n import _ @@ -87,6 +91,15 @@ def create_default_registry() -> CommandRegistry: history_mode="session", ) ) + registry.register( + LocalCommand( + name="memory", + description=_("View and manage persistent memories"), + handler=memory_command, + arg_hint=_("[|search |delete |help]"), + history_mode="session", + ) + ) registry.register( LocalCommand( name="resume", @@ -96,7 +109,32 @@ def create_default_registry() -> CommandRegistry: history_mode="session", ) ) + registry.register( + LocalCommand( + name="rename", + description=_("Rename the current session"), + handler=rename_command, + arg_hint="", + history_mode="session", + ) + ) + registry.register( + LocalCommand( + name="skills", + description=_("Manage skills"), + handler=skills_command, + history_mode="session", + ) + ) + registry.register( + LocalCommand( + name="status", + description=_("Show current session status"), + handler=status_command, + history_mode="session", + ) + ) return registry -__all__ = ["Command", "CommandRegistry", "LocalCommand", "PromptCommand", "create_default_registry"] +__all__ = ["Command", "CommandRegistry", "CommandResult", "LocalCommand", "PromptCommand", "create_default_registry"] diff --git a/src/iac_code/commands/auth.py b/src/iac_code/commands/auth.py index d8ef820..ef7deb5 100644 --- a/src/iac_code/commands/auth.py +++ b/src/iac_code/commands/auth.py @@ -4,8 +4,11 @@ import os import sys +import threading import unicodedata from collections.abc import Callable +from contextlib import contextmanager +from datetime import datetime from typing import TYPE_CHECKING, TypedDict from urllib.parse import urlparse @@ -102,6 +105,11 @@ def _build_providers_from_registry() -> list[LLMProvider]: _C_BOLD = "\033[1m" _BACK = _BackSentinel() +_ALIYUN_EPOCH_FIELDS = { + "oauth_access_token_expire", + "oauth_refresh_token_expire", + "sts_expiration", +} # ── Data helpers ────────────────────────────────────────────────────── @@ -799,6 +807,8 @@ async def auth_command(context: "CommandContext | None" = None, **kwargs) -> str if context and hasattr(context, "repl") and context.repl: repl = context.repl repl._reinitialize_provider(repl.store.get_state().model) + if hasattr(repl, "refresh_cloud_tools"): + repl.refresh_cloud_tools() return result @@ -1173,29 +1183,245 @@ def _nb(timeout=0.05): def _render_credential_info(credential: AliyunCredential, source: str) -> None: """Write current credential info lines (called between title and options).""" - from iac_code.services.providers.aliyun import MODE_DISPLAY_NAMES, MODE_FIELDS, mask_sensitive + from iac_code.services.providers.aliyun import MODE_FIELDS, mask_sensitive _write(" {}{} ({}){}\n".format(_C_DIM, _("Current configuration"), source, _C_RST)) - mode_display = _(MODE_DISPLAY_NAMES.get(credential.mode, credential.mode)) + mode_display = _aliyun_credential_mode_label(credential.mode) _write(" {}{}: {}{}\n".format(_C_DIM, _("Mode"), mode_display, _C_RST)) mode_fields = MODE_FIELDS.get(credential.mode, []) for field_name, label, sensitive in mode_fields: - value = getattr(credential, field_name, "") - if value and sensitive: - value = mask_sensitive(value) + raw_value = getattr(credential, field_name, "") + value = _format_aliyun_credential_field_value(field_name, raw_value, sensitive, mask_sensitive) display_value = value if value else _("(not set)") - _write(f" {_C_DIM}{label}: {display_value}{_C_RST}\n") + _write(" {}{}: {}{}\n".format(_C_DIM, _aliyun_credential_field_label(label), display_value, _C_RST)) _write(" {}{}: {}{}\n".format(_C_DIM, _("Region"), credential.region_id, _C_RST)) _write("\n") +def _aliyun_credential_mode_label(mode: str) -> str: + if mode == "AK": + return _("AccessKey") + if mode == "StsToken": + return _("STS Token") + if mode == "RamRoleArn": + return _("RAM Role") + if mode == "OAuth": + return _("OAuth Login (Browser)") + return mode + + +def _aliyun_credential_field_label(label: str) -> str: + translations = { + "AccessKey ID": _("AccessKey ID"), + "AccessKey Secret": _("AccessKey Secret"), + "STS Token": _("STS Token"), + "RAM Role ARN": _("RAM Role ARN"), + "Session Name": _("Session Name"), + "OAuth Site Type": _("OAuth Site Type"), + "OAuth Access Token": _("OAuth Access Token"), + "OAuth Refresh Token": _("OAuth Refresh Token"), + "OAuth Access Token Expire": _("OAuth Access Token Expire"), + "OAuth Refresh Token Expire": _("OAuth Refresh Token Expire"), + "STS Expiration": _("STS Expiration"), + } + return translations.get(label, label) + + +def _format_aliyun_credential_field_value( + field_name: str, + raw_value: object, + sensitive: bool, + mask_sensitive: Callable[[str], str], +) -> str: + if field_name in _ALIYUN_EPOCH_FIELDS: + return _format_local_epoch(raw_value) + + value = str(raw_value) if raw_value not in ("", None) else "" + if value and sensitive: + value = mask_sensitive(value) + return value + + +def _format_local_epoch(raw_value: object) -> str: + if raw_value in ("", None): + return "" + + if isinstance(raw_value, int): + epoch = raw_value + elif isinstance(raw_value, str): + try: + epoch = int(raw_value) + except ValueError: + return raw_value + else: + return str(raw_value) + + if epoch <= 0: + return "" + + try: + dt = datetime.fromtimestamp(epoch).astimezone() + except (OSError, OverflowError, ValueError): + return str(raw_value) + + display = dt.strftime("%Y-%m-%d %H:%M:%S") + offset = dt.strftime("%z") + if offset: + return "{} (UTC{}:{})".format(display, offset[:3], offset[3:]) + timezone_name = dt.tzname() + if timezone_name: + return "{} ({})".format(display, timezone_name) + return display + + +@contextmanager +def _oauth_escape_cancel_event(): + cancel_event = threading.Event() + stop_event = threading.Event() + listener: threading.Thread | None = None + fd: int | None = None + old_terminal_settings = None + + if sys.stdin is None or not sys.stdin.isatty(): + yield cancel_event + return + + if _IS_WIN32: + listener = threading.Thread(target=_watch_oauth_escape_win, args=(cancel_event, stop_event), daemon=True) + else: + import termios + import tty + + try: + fd = sys.stdin.fileno() + old_terminal_settings = termios.tcgetattr(fd) + tty.setcbreak(fd) + except Exception: + yield cancel_event + return + listener = threading.Thread(target=_watch_oauth_escape_posix, args=(fd, cancel_event, stop_event), daemon=True) + + listener.start() + try: + yield cancel_event + finally: + stop_event.set() + listener.join(timeout=0.2) + if fd is not None and old_terminal_settings is not None: + import termios + + termios.tcsetattr(fd, termios.TCSADRAIN, old_terminal_settings) + + +def _watch_oauth_escape_win(cancel_event: threading.Event, stop_event: threading.Event) -> None: + try: + msvcrt = _get_msvcrt() + while not stop_event.wait(0.05): + if not msvcrt.kbhit(): + continue + key = msvcrt.getch()[0] + if key in (0x00, 0xE0): + if msvcrt.kbhit(): + msvcrt.getch() + continue + if key in (3, 27): + cancel_event.set() + return + except (Exception, KeyboardInterrupt): + cancel_event.set() + + +def _watch_oauth_escape_posix(fd: int, cancel_event: threading.Event, stop_event: threading.Event) -> None: + import select as select_mod + + while not stop_event.is_set(): + try: + ready, _, _ = select_mod.select([fd], [], [], 0.05) + except Exception: + return + if not ready: + continue + + try: + key = os.read(fd, 1) + except OSError: + return + + if key == b"\x03": + cancel_event.set() + return + if key != b"\x1b": + continue + + # Treat lone Esc as cancel, but consume escape sequences such as arrow keys. + try: + ready, _, _ = select_mod.select([fd], [], [], 0.03) + if ready: + os.read(fd, 4096) + continue + except OSError: + return + + cancel_event.set() + return + + +def _aliyun_oauth_login_flow(existing_cred: "AliyunCredential | None") -> str | None | _BackSentinel: + from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials + from iac_code.services.providers.aliyun_oauth import ( + AliyunOAuthCancelledError, + AliyunOAuthClient, + AliyunOAuthError, + get_oauth_site, + oauth_site_options, + run_browser_oauth_flow, + ) + + site_options = oauth_site_options() + site_label_by_type = { + "CN": _("China"), + "INTL": _("International"), + } + site_idx = _select(_("Choose site type"), [site_label_by_type[site_type] for site_type, _label in site_options]) + if site_idx is None: + return _BACK + + site_type = site_options[site_idx][0] + site = get_oauth_site(site_type) + client = AliyunOAuthClient(site) + + try: + with _oauth_escape_cancel_event() as cancel_event: + token = run_browser_oauth_flow(site_type, oauth_client=client, cancel_event=cancel_event) + sts = client.exchange_access_token_for_sts(token.access_token) + except AliyunOAuthCancelledError: + return _BACK + except AliyunOAuthError as exc: + return _("Alibaba Cloud OAuth login failed: {error}").format(error=str(exc)) + + credential = AliyunCredential( + mode="OAuth", + region_id=existing_cred.region_id if existing_cred else "cn-hangzhou", + oauth_site_type=site_type, + oauth_access_token=token.access_token, + oauth_refresh_token=token.refresh_token, + oauth_access_token_expire=token.access_token_expire, + oauth_refresh_token_expire=token.refresh_token_expire, + access_key_id=sts.access_key_id, + access_key_secret=sts.access_key_secret, + sts_token=sts.sts_token, + sts_expiration=sts.sts_expiration, + ) + AliyunCredentials.save(credential) + return _("Configured: Alibaba Cloud OAuth credentials saved") + + def _aliyun_credential_flow() -> str | None | _BackSentinel: """Configure Aliyun credentials with type selection.""" from iac_code.services.providers.aliyun import ( CREDENTIAL_MODES, - MODE_DISPLAY_NAMES, MODE_FIELDS, AliyunCredential, AliyunCredentials, @@ -1222,7 +1448,7 @@ def _aliyun_credential_flow() -> str | None | _BackSentinel: # action_idx == 0: continue to reconfigure # Select credential mode - mode_options = [_(MODE_DISPLAY_NAMES[m]) for m in CREDENTIAL_MODES] + mode_options = [_aliyun_credential_mode_label(mode) for mode in CREDENTIAL_MODES] default_mode_idx = 0 if existing_cred and existing_cred.mode in CREDENTIAL_MODES: default_mode_idx = CREDENTIAL_MODES.index(existing_cred.mode) @@ -1234,6 +1460,12 @@ def _aliyun_credential_flow() -> str | None | _BackSentinel: return _BACK selected_mode = CREDENTIAL_MODES[mode_idx] + if selected_mode == "OAuth": + result = _aliyun_oauth_login_flow(existing_cred) + if result is _BACK: + continue + return result + mode_fields = MODE_FIELDS[selected_mode] # Collect field values diff --git a/src/iac_code/commands/clear.py b/src/iac_code/commands/clear.py index 541ef68..1a63c43 100644 --- a/src/iac_code/commands/clear.py +++ b/src/iac_code/commands/clear.py @@ -31,6 +31,16 @@ async def clear_command(context=None, **kwargs) -> str: state = store.get_state() if store else None if state: - console.print(render_welcome_banner(state.model, state.cwd)) + repl = getattr(context, "repl", None) + session_id = getattr(repl, "_session_id", None) + session_name = getattr(repl, "_session_name", None) + console.print( + render_welcome_banner( + state.model, + state.cwd, + session_id=session_id if isinstance(session_id, str) else None, + session_name=session_name if isinstance(session_name, str) else None, + ) + ) return "" diff --git a/src/iac_code/commands/memory.py b/src/iac_code/commands/memory.py new file mode 100644 index 0000000..402f14a --- /dev/null +++ b/src/iac_code/commands/memory.py @@ -0,0 +1,85 @@ +"""Memory command - view and manage persistent memories.""" + +from __future__ import annotations + +from typing import Any + +from iac_code.i18n import _ +from iac_code.memory.memory_manager import MemoryManager + +MEMORY_USAGE = _("Usage: /memory [|search |delete |help]") +_RESERVED_SUBCOMMANDS = {"search", "delete", "help"} + + +def _format_summary(title: str, memories: list[dict[str, Any]]) -> str: + if not memories: + return "" + + lines = [title] + for memory in sorted(memories, key=lambda item: str(item.get("name", ""))): + lines.append( + " - {name} - {description}".format( + name=memory.get("name", ""), + description=memory.get("description", ""), + ) + ) + return "\n".join(lines) + + +def _format_memory(memory: dict[str, Any]) -> str: + return "[{type}] {description}\n\n{content}".format( + type=memory.get("type", ""), + description=memory.get("description", ""), + content=memory.get("content", ""), + ) + + +def execute_memory_command(memory_manager: MemoryManager, args: list[str]) -> str: + if not args: + memories = memory_manager.list_memories() + return _format_summary(_("Saved memories:"), memories) or _("No memories saved yet.") + + action = args[0].lower() + if action == "help": + return MEMORY_USAGE + + if action == "search": + query = " ".join(args[1:]).strip() + if not query: + return MEMORY_USAGE + matches = memory_manager.search(query) + return _format_summary(_("Matching memories:"), matches) or _("No matching memories.") + + if action == "delete": + if len(args) != 2: + return MEMORY_USAGE + name = args[1] + try: + existing = memory_manager.load(name) + if existing is None: + return _("Memory '{name}' not found.").format(name=name) + memory_manager.delete(name) + except ValueError as exc: + return str(exc) + return _("Memory '{name}' deleted.").format(name=name) + + if len(args) != 1 or action in _RESERVED_SUBCOMMANDS: + return MEMORY_USAGE + + name = args[0] + try: + memory = memory_manager.load(name) + except ValueError as exc: + return str(exc) + if memory is None: + return _("Memory '{name}' not found.").format(name=name) + return _format_memory(memory) + + +async def memory_command(**kwargs) -> str: + context = kwargs.get("context") + repl = getattr(context, "repl", None) if context is not None else None + memory_manager = getattr(repl, "_memory_manager", None) + if memory_manager is None: + return _("Memory manager is unavailable.") + return execute_memory_command(memory_manager, kwargs.get("args") or []) diff --git a/src/iac_code/commands/registry.py b/src/iac_code/commands/registry.py index 0541c57..1d15115 100644 --- a/src/iac_code/commands/registry.py +++ b/src/iac_code/commands/registry.py @@ -50,6 +50,18 @@ class LocalCommand(Command): """ +@dataclass(frozen=True) +class CommandResult: + """Structured result for local commands that need UI metadata.""" + + message: str + is_error: bool = False + refresh_banner: bool = False + + def __bool__(self) -> bool: + return bool(self.message) + + @dataclass class PromptCommand(Command): """Skill-based command backed by a SkillDefinition. @@ -127,6 +139,12 @@ def register(self, command: Command) -> None: for alias in command.aliases: self._commands[alias] = command + def clear_prompt_commands(self) -> None: + """Remove all skill-backed commands while preserving local commands.""" + for name, command in list(self._commands.items()): + if isinstance(command, PromptCommand): + del self._commands[name] + def get(self, name: str) -> Command | None: """Get command by name or alias.""" return self._commands.get(name) diff --git a/src/iac_code/commands/rename.py b/src/iac_code/commands/rename.py new file mode 100644 index 0000000..a878585 --- /dev/null +++ b/src/iac_code/commands/rename.py @@ -0,0 +1,43 @@ +"""/rename command - rename the current session.""" + +from __future__ import annotations + +import inspect +from typing import Any + +from iac_code.commands.registry import CommandResult +from iac_code.i18n import _ +from iac_code.services.session_metadata import normalize_session_name + + +async def rename_command(context=None, args: list[str] | None = None, **_kwargs: Any) -> CommandResult: + """Rename the current interactive session.""" + if context is None or getattr(context, "repl", None) is None: + return CommandResult(_("Rename is only available in interactive mode."), is_error=True) + + repl = context.repl + args = args or [] + if len(args) > 1: + return CommandResult(_("Usage: /rename "), is_error=True) + + if args: + raw_name = args[0] + else: + prompt_for_session_name = getattr(repl, "prompt_for_session_name", None) + if prompt_for_session_name is None: + return CommandResult(_("Rename is only available in interactive mode."), is_error=True) + raw_name = await prompt_for_session_name() + if raw_name is None: + return CommandResult(_("Rename cancelled")) + + try: + name = normalize_session_name(raw_name) + result = repl.rename_current_session(name) + if inspect.isawaitable(result): + result = await result + except ValueError as exc: + return CommandResult(str(exc), is_error=True) + + if result == "unchanged": + return CommandResult(_("Session is already named {name}").format(name=name)) + return CommandResult(_("Renamed session to {name}").format(name=name), refresh_banner=True) diff --git a/src/iac_code/commands/resume.py b/src/iac_code/commands/resume.py index 4d36978..ba5737f 100644 --- a/src/iac_code/commands/resume.py +++ b/src/iac_code/commands/resume.py @@ -5,6 +5,7 @@ from typing import Any from iac_code.i18n import _ +from iac_code.services.session_resolver import ResolutionStatus, resolve_session_argument async def resume_command(context=None, args: list[str] | None = None, **_kwargs: Any) -> str: @@ -27,11 +28,31 @@ async def resume_command(context=None, args: list[str] | None = None, **_kwargs: return _("Resume is unavailable: session index not initialised.") if arg_str: - entry = index.find_by_id_or_prefix(arg_str) - if entry is None: + resolution = resolve_session_argument(index, repl._original_cwd, arg_str) + if resolution.status == ResolutionStatus.NOT_FOUND: return _("Session not found: {arg}").format(arg=arg_str) - await repl.swap_or_announce_session(entry) - return "" + if resolution.status == ResolutionStatus.FOUND: + if resolution.entry is None: + return _("Session not found: {arg}").format(arg=arg_str) + await repl.swap_or_announce_session(resolution.entry) + return "" + if resolution.status == ResolutionStatus.AMBIGUOUS_NAME: + from iac_code.ui.dialogs.resume_picker import ResumePicker + + picker = ResumePicker( + index=index, + current_cwd=repl._original_cwd, + current_session_id=repl.session_id, + keybinding_manager=getattr(repl, "_keybinding_manager", None), + renderer=getattr(repl, "renderer", None), + entries=resolution.candidates, + ) + selected = picker.run() + if selected is None: + return _("Resume cancelled") + await repl.swap_or_announce_session(selected) + return "" + return _("Unable to resolve session: {arg}").format(arg=arg_str) from iac_code.ui.dialogs.resume_picker import ResumePicker diff --git a/src/iac_code/commands/skills.py b/src/iac_code/commands/skills.py new file mode 100644 index 0000000..f28f99a --- /dev/null +++ b/src/iac_code/commands/skills.py @@ -0,0 +1,29 @@ +"""/skills command — manage discovered skills.""" + +from __future__ import annotations + +from typing import Any + +from iac_code.i18n import _ +from iac_code.skills.settings import save_disabled_skills + + +async def skills_command(context=None, args: list[str] | None = None, **_kwargs: Any) -> str: + """Open the interactive skills management UI.""" + if context is None or not hasattr(context, "repl"): + return _("Skills management is only available in interactive mode.") + + repl = context.repl + from iac_code.ui.dialogs.skills_picker import SkillsPicker + + picker = SkillsPicker( + list(getattr(repl, "skill_management_items", [])), + keybinding_manager=getattr(repl, "_keybinding_manager", None), + ) + disabled = picker.run() + if disabled is None: + return _("Skills update cancelled") + + save_disabled_skills(set(disabled), locked_skill_names=set(getattr(repl, "locked_skill_names", set()))) + repl.refresh_skills() + return _("Skills updated") diff --git a/src/iac_code/commands/status.py b/src/iac_code/commands/status.py new file mode 100644 index 0000000..6d14f8b --- /dev/null +++ b/src/iac_code/commands/status.py @@ -0,0 +1,98 @@ +"""Status command - show current session state and recorded API usage.""" + +from __future__ import annotations + +from typing import Any + +from rich.cells import cell_len +from rich.console import Group +from rich.panel import Panel +from rich.text import Text + +from iac_code.i18n import _ + +LABEL_COLUMN_WIDTH = 12 + + +async def status_command(context=None, **kwargs) -> str | None: + if context is None: + return _("Status command requires a context.") + repl = getattr(context, "repl", None) + if repl is None: + return _("Status command requires a REPL context.") + if not hasattr(repl, "get_status_snapshot"): + return _("Status is only available in interactive mode.") + + snapshot = repl.get_status_snapshot() + context.console.print(_render_status_panel(snapshot)) + return None + + +def _render_status_panel(snapshot: dict[str, Any]) -> Panel: + text = Text() + _append_line(text, _("Session"), _session_display(snapshot)) + _append_line(text, _("Provider"), snapshot.get("provider") or _("not configured")) + _append_line(text, _("Model"), snapshot.get("model") or _("not configured")) + _append_line(text, _("Region"), snapshot.get("region") or _("not configured")) + _append_line(text, _("CWD"), snapshot.get("cwd") or "") + text.append("\n") + + usage = snapshot.get("api_usage") + text.append(_("API Token Usage (recorded):"), style="bold") + text.append("\n") + if usage is not None and getattr(usage, "has_recorded_usage", False): + _append_line(text, _("Input"), _format_int(getattr(usage, "input_tokens", 0)), indent=2) + _append_line(text, _("Output"), _format_int(getattr(usage, "output_tokens", 0)), indent=2) + _append_line(text, _("Cache read"), _format_int(getattr(usage, "cache_read_input_tokens", 0)), indent=2) + _append_line(text, _("Total"), _format_int(getattr(usage, "total_tokens", 0)), indent=2) + else: + text.append(" ") + text.append(_("No recorded API usage for this session yet."), style="dim") + text.append("\n") + text.append("\n") + + _append_line(text, _("Turns"), "{} / {}".format(snapshot.get("turn_count", 0), snapshot.get("max_turns", 0))) + _append_line(text, _("Context"), _format_context(snapshot.get("context_usage") or {})) + + return Panel(Group(text), title=_("Session Status"), border_style="cyan", expand=False) + + +def _append_line(text: Text, label: str, value: str, *, indent: int = 0) -> None: + label_text = label + ":" + padding = max(0, LABEL_COLUMN_WIDTH - cell_len(label_text)) + text.append(" " * indent) + text.append(label_text, style="bold") + text.append(" " * (padding + 1)) + text.append(str(value)) + text.append("\n") + + +def _session_display(snapshot: dict[str, Any]) -> str: + session_id = snapshot.get("session_id") or "" + if snapshot.get("resumed"): + return _("{session_id} (resumed)").format(session_id=session_id) + return str(session_id) + + +def _format_context(context_usage: dict[str, Any]) -> str: + percent = float(context_usage.get("usage_percent") or 0) + total = int(context_usage.get("total_tokens") or 0) + window = int(context_usage.get("context_window") or 0) + return _("{percent} used ({total} / {window})").format( + percent=f"{percent:.0f}%", + total=_format_compact(total), + window=_format_compact(window), + ) + + +def _format_int(value: int) -> str: + return f"{int(value):,}" + + +def _format_compact(value: int) -> str: + value = int(value) + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}M" + if value >= 1_000: + return f"{value // 1000}k" + return str(value) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index ae5fdbb..9efef64 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -4,9 +4,9 @@ # msgid "" msgstr "" -"Project-Id-Version: iac-code 0.3.0\n" +"Project-Id-Version: iac-code 0.4.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-01 17:56+0800\n" +"POT-Creation-Date: 2026-06-03 13:32+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: de\n" @@ -32,7 +32,22 @@ msgstr "" "Unix-Domain-Socket-Transport wird unter Windows nicht unterstützt. " "Verwenden Sie stattdessen --transport http oder --transport stdio." -#: src/iac_code/acp/slash_registry.py:44 +#: src/iac_code/acp/server.py:413 src/iac_code/acp/server.py:429 +#: src/iac_code/acp/server.py:456 +msgid "Session not found" +msgstr "Sitzung nicht gefunden" + +#: src/iac_code/acp/server.py:416 +#, python-brace-format +msgid "Session name is ambiguous. Candidates: {candidates}" +msgstr "Der Sitzungsname ist mehrdeutig. Kandidaten: {candidates}" + +#: src/iac_code/acp/server.py:434 src/iac_code/acp/server.py:708 +#, python-brace-format +msgid "Session belongs to another project. Run: {hint}" +msgstr "Die Sitzung gehört zu einem anderen Projekt. Ausführen: {hint}" + +#: src/iac_code/acp/slash_registry.py:46 #, python-brace-format msgid "" "Command '/{cmd_name}' is not supported over ACP. Supported commands: " @@ -41,21 +56,21 @@ msgstr "" "Der Befehl '/{cmd_name}' wird über ACP nicht unterstützt. Unterstützte " "Befehle: {supported}" -#: src/iac_code/acp/slash_registry.py:56 +#: src/iac_code/acp/slash_registry.py:62 #, python-brace-format msgid "Command '/{cmd_name}' handler not implemented." msgstr "Der Handler für den Befehl '/{cmd_name}' ist nicht implementiert." -#: src/iac_code/acp/slash_registry.py:68 +#: src/iac_code/acp/slash_registry.py:74 #, python-brace-format msgid "Compaction failed: {error}" msgstr "Komprimierung fehlgeschlagen: {error}" -#: src/iac_code/acp/slash_registry.py:71 src/iac_code/commands/compact.py:24 +#: src/iac_code/acp/slash_registry.py:77 src/iac_code/commands/compact.py:24 msgid "Nothing to compact: conversation is empty." msgstr "Nichts zu komprimieren: Die Konversation ist leer." -#: src/iac_code/acp/slash_registry.py:74 src/iac_code/commands/compact.py:27 +#: src/iac_code/acp/slash_registry.py:80 src/iac_code/commands/compact.py:27 #, python-brace-format msgid "" "Conversation too short to compact: all messages are within the recent " @@ -64,11 +79,11 @@ msgstr "" "Konversation zu kurz zum Komprimieren: Alle Nachrichten liegen im " "Erhaltungsfenster der letzten {turns} Runden." -#: src/iac_code/acp/slash_registry.py:78 src/iac_code/commands/compact.py:30 +#: src/iac_code/acp/slash_registry.py:84 src/iac_code/commands/compact.py:30 msgid "Compaction failed. See logs for details." msgstr "Komprimierung fehlgeschlagen. Details finden Sie in den Protokollen." -#: src/iac_code/acp/slash_registry.py:83 +#: src/iac_code/acp/slash_registry.py:89 #, python-brace-format msgid "" "Context compacted: {original} → {compacted} tokens ({percent} reduction)." @@ -77,39 +92,61 @@ msgstr "" "Kontext komprimiert: {original} → {compacted} Tokens ({percent} " "Reduzierung). Kontextnutzung: {usage}" -#: src/iac_code/acp/slash_registry.py:97 +#: src/iac_code/acp/slash_registry.py:103 #, python-brace-format msgid "Clear failed: {error}" msgstr "Löschen fehlgeschlagen: {error}" -#: src/iac_code/acp/slash_registry.py:98 +#: src/iac_code/acp/slash_registry.py:104 msgid "Conversation history cleared." msgstr "Konversationsverlauf gelöscht." -#: src/iac_code/acp/slash_registry.py:114 src/iac_code/commands/debug.py:34 +#: src/iac_code/acp/slash_registry.py:120 src/iac_code/commands/debug.py:34 #, python-brace-format msgid "Debug logging is on. Log file: {path}" msgstr "Debug-Protokollierung ist aktiv. Protokolldatei: {path}" -#: src/iac_code/acp/slash_registry.py:115 src/iac_code/commands/debug.py:35 +#: src/iac_code/acp/slash_registry.py:121 src/iac_code/commands/debug.py:35 msgid "Debug logging is off." msgstr "Debug-Protokollierung ist inaktiv." -#: src/iac_code/acp/slash_registry.py:119 src/iac_code/commands/debug.py:39 +#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:39 #, python-brace-format msgid "Debug logging enabled. Log file: {path}" msgstr "Debug-Protokollierung aktiviert. Protokolldatei: {path}" -#: src/iac_code/acp/slash_registry.py:123 src/iac_code/commands/debug.py:43 +#: src/iac_code/acp/slash_registry.py:129 src/iac_code/commands/debug.py:43 msgid "Debug logging disabled." msgstr "Debug-Protokollierung deaktiviert." -#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:45 +#: src/iac_code/acp/slash_registry.py:131 src/iac_code/commands/debug.py:45 msgid "Usage: /debug [on|off]" msgstr "Verwendung: /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:404 src/iac_code/agent/agent_loop.py:419 -#: src/iac_code/ui/repl.py:757 src/iac_code/ui/repl.py:771 +#: src/iac_code/acp/slash_registry.py:136 src/iac_code/commands/memory.py:84 +msgid "Memory manager is unavailable." +msgstr "Der Speicher-Manager ist nicht verfügbar." + +#: src/iac_code/acp/slash_registry.py:146 src/iac_code/commands/rename.py:21 +msgid "Usage: /rename " +msgstr "Verwendung: /rename " + +#: src/iac_code/acp/slash_registry.py:152 +msgid "Rename is only available after a session is created." +msgstr "Umbenennen ist erst verfügbar, nachdem eine Sitzung erstellt wurde." + +#: src/iac_code/acp/slash_registry.py:163 src/iac_code/commands/rename.py:42 +#, python-brace-format +msgid "Session is already named {name}" +msgstr "Die Sitzung heißt bereits {name}" + +#: src/iac_code/acp/slash_registry.py:164 src/iac_code/commands/rename.py:43 +#, python-brace-format +msgid "Renamed session to {name}" +msgstr "Sitzung in {name} umbenannt" + +#: src/iac_code/agent/agent_loop.py:413 src/iac_code/agent/agent_loop.py:428 +#: src/iac_code/ui/repl.py:813 src/iac_code/ui/repl.py:827 msgid "Permission denied." msgstr "Zugriff verweigert." @@ -280,8 +317,8 @@ msgid "Show version and exit" msgstr "Version anzeigen und beenden" #: src/iac_code/cli/main.py:89 -msgid "Resume a session by ID" -msgstr "Eine Sitzung anhand der ID fortsetzen" +msgid "Resume a session by ID or name" +msgstr "Sitzung per ID oder Name fortsetzen" #: src/iac_code/cli/main.py:90 msgid "Resume the most recent session" @@ -610,270 +647,368 @@ msgstr "Verzeichnis für persistierte A2A-Routen" msgid "Save the provided routes as a route snapshot" msgstr "Speichert die angegebenen Routen als Routen-Snapshot" -#: src/iac_code/commands/__init__.py:22 +#: src/iac_code/commands/__init__.py:26 msgid "Show available commands" msgstr "Verfügbare Befehle anzeigen" -#: src/iac_code/commands/__init__.py:31 +#: src/iac_code/commands/__init__.py:35 msgid "Clear conversation history" msgstr "Konversationsverlauf löschen" -#: src/iac_code/commands/__init__.py:39 +#: src/iac_code/commands/__init__.py:43 msgid "Show or switch model" msgstr "Modell anzeigen oder wechseln" -#: src/iac_code/commands/__init__.py:48 +#: src/iac_code/commands/__init__.py:52 msgid "Show or switch thinking effort" msgstr "Thinking-Effort anzeigen oder wechseln" -#: src/iac_code/commands/__init__.py:57 +#: src/iac_code/commands/__init__.py:61 msgid "Compact conversation context" msgstr "Konversationskontext komprimieren" -#: src/iac_code/commands/__init__.py:59 +#: src/iac_code/commands/__init__.py:63 msgid "Compacting conversation" msgstr "Konversation wird komprimiert" -#: src/iac_code/commands/__init__.py:66 +#: src/iac_code/commands/__init__.py:70 msgid "Exit the application" msgstr "Anwendung beenden" -#: src/iac_code/commands/__init__.py:75 +#: src/iac_code/commands/__init__.py:79 msgid "Authenticate with LLM provider" msgstr "Beim LLM-Anbieter authentifizieren" -#: src/iac_code/commands/__init__.py:84 +#: src/iac_code/commands/__init__.py:88 msgid "Toggle debug logging" msgstr "Debug-Protokollierung umschalten" -#: src/iac_code/commands/__init__.py:93 +#: src/iac_code/commands/__init__.py:97 +msgid "View and manage persistent memories" +msgstr "Persistente Erinnerungen anzeigen und verwalten" + +#: src/iac_code/commands/__init__.py:99 +msgid "[|search |delete |help]" +msgstr "[|search |delete |help]" + +#: src/iac_code/commands/__init__.py:106 msgid "Resume a previous session" msgstr "Eine frühere Sitzung fortsetzen" -#: src/iac_code/commands/__init__.py:95 +#: src/iac_code/commands/__init__.py:108 msgid "[conversation id or search term]" msgstr "[Konversations-ID oder Suchbegriff]" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:1107 +#: src/iac_code/commands/__init__.py:115 +msgid "Rename the current session" +msgstr "Aktuelle Sitzung umbenennen" + +#: src/iac_code/commands/__init__.py:124 +msgid "Manage skills" +msgstr "Skills verwalten" + +#: src/iac_code/commands/__init__.py:132 +msgid "Show current session status" +msgstr "Aktuellen Sitzungsstatus anzeigen" + +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:1117 #: src/iac_code/ui/core/prompt_input.py:557 msgid "Navigate" msgstr "Navigieren" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:530 -#: src/iac_code/commands/auth.py:562 src/iac_code/commands/auth.py:569 -#: src/iac_code/commands/auth.py:604 src/iac_code/commands/auth.py:611 -#: src/iac_code/commands/auth.py:632 src/iac_code/commands/auth.py:1107 -#: src/iac_code/commands/auth.py:1319 src/iac_code/ui/core/prompt_input.py:557 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:538 +#: src/iac_code/commands/auth.py:570 src/iac_code/commands/auth.py:577 +#: src/iac_code/commands/auth.py:612 src/iac_code/commands/auth.py:619 +#: src/iac_code/commands/auth.py:640 src/iac_code/commands/auth.py:1117 +#: src/iac_code/commands/auth.py:1551 src/iac_code/ui/core/prompt_input.py:557 msgid "Confirm" msgstr "Bestätigen" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:528 -#: src/iac_code/commands/auth.py:530 src/iac_code/commands/auth.py:562 -#: src/iac_code/commands/auth.py:569 src/iac_code/commands/auth.py:604 -#: src/iac_code/commands/auth.py:611 src/iac_code/commands/auth.py:632 -#: src/iac_code/commands/auth.py:1107 src/iac_code/commands/auth.py:1217 -#: src/iac_code/commands/auth.py:1319 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:536 +#: src/iac_code/commands/auth.py:538 src/iac_code/commands/auth.py:570 +#: src/iac_code/commands/auth.py:577 src/iac_code/commands/auth.py:612 +#: src/iac_code/commands/auth.py:619 src/iac_code/commands/auth.py:640 +#: src/iac_code/commands/auth.py:1117 src/iac_code/commands/auth.py:1443 +#: src/iac_code/commands/auth.py:1551 msgid "Back" msgstr "Zurück" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Keep" msgstr "Behalten" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Re-enter" msgstr "Erneut eingeben" -#: src/iac_code/commands/auth.py:741 src/iac_code/commands/auth.py:853 -#: src/iac_code/commands/auth.py:911 src/iac_code/commands/auth.py:919 -#: src/iac_code/commands/auth.py:946 +#: src/iac_code/commands/auth.py:749 src/iac_code/commands/auth.py:863 +#: src/iac_code/commands/auth.py:921 src/iac_code/commands/auth.py:929 +#: src/iac_code/commands/auth.py:956 msgid " (current)" msgstr " (aktuell)" -#: src/iac_code/commands/auth.py:744 +#: src/iac_code/commands/auth.py:752 msgid "Custom model..." msgstr "Benutzerdefiniertes Modell …" -#: src/iac_code/commands/auth.py:747 +#: src/iac_code/commands/auth.py:755 #, python-brace-format msgid "Select model for {provider}" msgstr "Modell für {provider} auswählen" -#: src/iac_code/commands/auth.py:749 +#: src/iac_code/commands/auth.py:757 msgid "Select model" msgstr "Modell auswählen" -#: src/iac_code/commands/auth.py:757 +#: src/iac_code/commands/auth.py:765 msgid "Enter custom model name: " msgstr "Benutzerdefinierten Modellnamen eingeben: " -#: src/iac_code/commands/auth.py:783 +#: src/iac_code/commands/auth.py:791 msgid "Error: console not available" msgstr "Fehler: Konsole nicht verfügbar" -#: src/iac_code/commands/auth.py:810 +#: src/iac_code/commands/auth.py:820 msgid "Configure LLM Provider" msgstr "LLM-Anbieter konfigurieren" -#: src/iac_code/commands/auth.py:811 +#: src/iac_code/commands/auth.py:821 msgid "Configure IaC Cloud Service" msgstr "IaC-Cloud-Dienst konfigurieren" -#: src/iac_code/commands/auth.py:813 +#: src/iac_code/commands/auth.py:823 msgid "Select configuration type" msgstr "Konfigurationstyp auswählen" -#: src/iac_code/commands/auth.py:815 src/iac_code/commands/auth.py:971 -#: src/iac_code/commands/auth.py:987 src/iac_code/commands/auth.py:1066 -#: src/iac_code/commands/auth.py:1258 src/iac_code/commands/auth.py:1299 +#: src/iac_code/commands/auth.py:825 src/iac_code/commands/auth.py:981 +#: src/iac_code/commands/auth.py:997 src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1490 src/iac_code/commands/auth.py:1531 msgid "Auth cancelled" msgstr "Authentifizierung abgebrochen" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:951 -#: src/iac_code/commands/auth.py:1041 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:961 +#: src/iac_code/commands/auth.py:1051 #, python-brace-format msgid "Select provider — {group}" msgstr "Anbieter auswählen — {group}" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:909 -#: src/iac_code/commands/auth.py:1026 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:919 +#: src/iac_code/commands/auth.py:1036 msgid "Third-party" msgstr "Drittanbieter" -#: src/iac_code/commands/auth.py:867 +#: src/iac_code/commands/auth.py:877 #, python-brace-format msgid "{status}: {provider}" msgstr "{status}: {provider}" -#: src/iac_code/commands/auth.py:868 src/iac_code/commands/auth.py:1019 +#: src/iac_code/commands/auth.py:878 src/iac_code/commands/auth.py:1029 msgid "Configured" msgstr "Konfiguriert" -#: src/iac_code/commands/auth.py:923 +#: src/iac_code/commands/auth.py:933 msgid "Select provider" msgstr "Anbieter auswählen" -#: src/iac_code/commands/auth.py:964 +#: src/iac_code/commands/auth.py:974 #, python-brace-format msgid "Configure {provider}" msgstr "{provider} konfigurieren" -#: src/iac_code/commands/auth.py:980 +#: src/iac_code/commands/auth.py:990 #, python-brace-format msgid "Enter API key for {provider}" msgstr "API-Key für {provider} eingeben" -#: src/iac_code/commands/auth.py:1018 +#: src/iac_code/commands/auth.py:1028 #, python-brace-format msgid "{status}: {provider} / {model}" msgstr "{status}: {provider} / {model}" -#: src/iac_code/commands/auth.py:1027 src/iac_code/commands/auth.py:1048 +#: src/iac_code/commands/auth.py:1037 src/iac_code/commands/auth.py:1058 msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:1028 src/iac_code/providers/registry.py:426 +#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:427 msgid "ZhiPu AI" msgstr "ZhiPu AI" -#: src/iac_code/commands/auth.py:1029 +#: src/iac_code/commands/auth.py:1039 msgid "Kimi" msgstr "Kimi" -#: src/iac_code/commands/auth.py:1030 +#: src/iac_code/commands/auth.py:1040 msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:1031 src/iac_code/providers/registry.py:428 +#: src/iac_code/commands/auth.py:1041 src/iac_code/providers/registry.py:429 msgid "Volcengine" msgstr "Volcengine" -#: src/iac_code/commands/auth.py:1032 +#: src/iac_code/commands/auth.py:1042 msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:1033 src/iac_code/providers/registry.py:419 +#: src/iac_code/commands/auth.py:1043 src/iac_code/providers/registry.py:420 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:1034 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:1044 src/iac_code/providers/registry.py:418 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:1035 src/iac_code/providers/registry.py:418 +#: src/iac_code/commands/auth.py:1045 src/iac_code/providers/registry.py:419 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:1036 src/iac_code/providers/registry.py:421 +#: src/iac_code/commands/auth.py:1046 src/iac_code/providers/registry.py:422 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:1037 src/iac_code/providers/registry.py:434 +#: src/iac_code/commands/auth.py:1047 src/iac_code/providers/registry.py:435 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:433 +#: src/iac_code/commands/auth.py:1048 src/iac_code/providers/registry.py:434 msgid "OpenRouter" msgstr "OpenRouter" -#: src/iac_code/commands/auth.py:1039 +#: src/iac_code/commands/auth.py:1049 msgid "Local" msgstr "Lokal" -#: src/iac_code/commands/auth.py:1040 +#: src/iac_code/commands/auth.py:1050 msgid "Compatible" msgstr "Kompatibel" -#: src/iac_code/commands/auth.py:1057 +#: src/iac_code/commands/auth.py:1067 msgid "Select Cloud Provider" msgstr "Cloud-Anbieter auswählen" -#: src/iac_code/commands/auth.py:1073 +#: src/iac_code/commands/auth.py:1083 msgid "Credential" msgstr "Anmeldedaten" -#: src/iac_code/commands/auth.py:1074 src/iac_code/commands/auth.py:1190 -#: src/iac_code/commands/auth.py:1295 src/iac_code/ui/renderer.py:455 +#: src/iac_code/commands/auth.py:1084 src/iac_code/commands/auth.py:1199 +#: src/iac_code/commands/auth.py:1527 src/iac_code/commands/status.py:36 +#: src/iac_code/ui/renderer.py:455 msgid "Region" msgstr "Region" -#: src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1086 msgid "Configure Alibaba Cloud" msgstr "Alibaba Cloud konfigurieren" -#: src/iac_code/commands/auth.py:1178 +#: src/iac_code/commands/auth.py:1188 msgid "Current configuration" msgstr "Aktuelle Konfiguration" -#: src/iac_code/commands/auth.py:1180 +#: src/iac_code/commands/auth.py:1190 msgid "Mode" msgstr "Modus" -#: src/iac_code/commands/auth.py:1187 +#: src/iac_code/commands/auth.py:1196 msgid "(not set)" msgstr "(nicht gesetzt)" -#: src/iac_code/commands/auth.py:1204 +#: src/iac_code/commands/auth.py:1205 +msgid "AccessKey" +msgstr "AccessKey" + +#: src/iac_code/commands/auth.py:1207 src/iac_code/commands/auth.py:1219 +msgid "STS Token" +msgstr "STS-Token" + +#: src/iac_code/commands/auth.py:1209 +msgid "RAM Role" +msgstr "RAM-Rolle" + +#: src/iac_code/commands/auth.py:1211 +msgid "OAuth Login (Browser)" +msgstr "OAuth-Anmeldung (Browser)" + +#: src/iac_code/commands/auth.py:1217 +msgid "AccessKey ID" +msgstr "AccessKey-ID" + +#: src/iac_code/commands/auth.py:1218 +msgid "AccessKey Secret" +msgstr "AccessKey-Secret" + +#: src/iac_code/commands/auth.py:1220 +msgid "RAM Role ARN" +msgstr "RAM-Rollen-ARN" + +#: src/iac_code/commands/auth.py:1221 +msgid "Session Name" +msgstr "Sitzungsname" + +#: src/iac_code/commands/auth.py:1222 +msgid "OAuth Site Type" +msgstr "OAuth-Standorttyp" + +#: src/iac_code/commands/auth.py:1223 +msgid "OAuth Access Token" +msgstr "OAuth-Zugriffstoken" + +#: src/iac_code/commands/auth.py:1224 +msgid "OAuth Refresh Token" +msgstr "OAuth-Refresh-Token" + +#: src/iac_code/commands/auth.py:1225 +msgid "OAuth Access Token Expire" +msgstr "Ablaufzeit des OAuth-Zugriffstokens" + +#: src/iac_code/commands/auth.py:1226 +msgid "OAuth Refresh Token Expire" +msgstr "Ablaufzeit des OAuth-Refresh-Tokens" + +#: src/iac_code/commands/auth.py:1227 +msgid "STS Expiration" +msgstr "STS-Ablaufzeit" + +#: src/iac_code/commands/auth.py:1384 +msgid "China" +msgstr "China" + +#: src/iac_code/commands/auth.py:1385 +msgid "International" +msgstr "International" + +#: src/iac_code/commands/auth.py:1387 +msgid "Choose site type" +msgstr "Standorttyp auswählen" + +#: src/iac_code/commands/auth.py:1402 +#, python-brace-format +msgid "Alibaba Cloud OAuth login failed: {error}" +msgstr "Alibaba Cloud OAuth-Anmeldung fehlgeschlagen: {error}" + +#: src/iac_code/commands/auth.py:1418 +msgid "Configured: Alibaba Cloud OAuth credentials saved" +msgstr "Konfiguriert: Alibaba Cloud-OAuth-Anmeldedaten gespeichert" + +#: src/iac_code/commands/auth.py:1430 msgid "Configure Alibaba Cloud credentials" msgstr "Alibaba Cloud-Anmeldedaten konfigurieren" -#: src/iac_code/commands/auth.py:1217 +#: src/iac_code/commands/auth.py:1443 msgid "Reconfigure credential" msgstr "Anmeldedaten neu konfigurieren" -#: src/iac_code/commands/auth.py:1230 +#: src/iac_code/commands/auth.py:1456 msgid "Select credential type" msgstr "Anmeldedatentyp auswählen" -#: src/iac_code/commands/auth.py:1280 +#: src/iac_code/commands/auth.py:1512 msgid "Configured: Alibaba Cloud credentials saved to ~/.iac-code" msgstr "Konfiguriert: Alibaba Cloud-Anmeldedaten unter ~/.iac-code gespeichert" -#: src/iac_code/commands/auth.py:1287 +#: src/iac_code/commands/auth.py:1519 msgid "Configure Alibaba Cloud region" msgstr "Alibaba Cloud-Region konfigurieren" -#: src/iac_code/commands/auth.py:1313 +#: src/iac_code/commands/auth.py:1545 msgid "Configured: Alibaba Cloud region saved to ~/.iac-code" msgstr "Konfiguriert: Alibaba Cloud-Region unter ~/.iac-code gespeichert" @@ -969,6 +1104,36 @@ msgstr "Befehlsvorschläge anzeigen" msgid "Exit" msgstr "Beenden" +#: src/iac_code/commands/memory.py:10 +msgid "Usage: /memory [|search |delete |help]" +msgstr "Verwendung: /memory [|search |delete |help]" + +#: src/iac_code/commands/memory.py:40 +msgid "Saved memories:" +msgstr "Gespeicherte Erinnerungen:" + +#: src/iac_code/commands/memory.py:40 +msgid "No memories saved yet." +msgstr "Noch keine Erinnerungen gespeichert." + +#: src/iac_code/commands/memory.py:51 +msgid "Matching memories:" +msgstr "Passende Erinnerungen:" + +#: src/iac_code/commands/memory.py:51 +msgid "No matching memories." +msgstr "Keine passenden Erinnerungen." + +#: src/iac_code/commands/memory.py:60 src/iac_code/commands/memory.py:75 +#, python-brace-format +msgid "Memory '{name}' not found." +msgstr "Erinnerung '{name}' nicht gefunden." + +#: src/iac_code/commands/memory.py:64 +#, python-brace-format +msgid "Memory '{name}' deleted." +msgstr "Erinnerung '{name}' gelöscht." + #: src/iac_code/commands/model.py:57 #, python-brace-format msgid "" @@ -993,23 +1158,128 @@ msgstr "Aktuelles Modell: {model}" msgid "Kept model as {model}" msgstr "Modell beibehalten: {model}" -#: src/iac_code/commands/resume.py:21 +#: src/iac_code/commands/rename.py:16 src/iac_code/commands/rename.py:28 +msgid "Rename is only available in interactive mode." +msgstr "Umbenennen ist nur im interaktiven Modus verfügbar." + +#: src/iac_code/commands/rename.py:31 +msgid "Rename cancelled" +msgstr "Umbenennen abgebrochen" + +#: src/iac_code/commands/resume.py:22 msgid "Resume is only available in interactive mode." msgstr "/resume ist nur im interaktiven Modus verfügbar." -#: src/iac_code/commands/resume.py:27 +#: src/iac_code/commands/resume.py:28 msgid "Resume is unavailable: session index not initialised." msgstr "Fortsetzen nicht möglich: Sitzungsindex nicht initialisiert." -#: src/iac_code/commands/resume.py:32 +#: src/iac_code/commands/resume.py:33 src/iac_code/commands/resume.py:36 #, python-brace-format msgid "Session not found: {arg}" msgstr "Sitzung nicht gefunden: {arg}" -#: src/iac_code/commands/resume.py:47 +#: src/iac_code/commands/resume.py:52 src/iac_code/commands/resume.py:68 msgid "Resume cancelled" msgstr "Fortsetzen abgebrochen" +#: src/iac_code/commands/resume.py:55 +#, python-brace-format +msgid "Unable to resolve session: {arg}" +msgstr "Sitzung konnte nicht aufgelöst werden: {arg}" + +#: src/iac_code/commands/skills.py:14 +msgid "Skills management is only available in interactive mode." +msgstr "Skill-Verwaltung ist nur im interaktiven Modus verfügbar." + +#: src/iac_code/commands/skills.py:25 +msgid "Skills update cancelled" +msgstr "Skill-Aktualisierung abgebrochen" + +#: src/iac_code/commands/skills.py:29 +msgid "Skills updated" +msgstr "Skills aktualisiert" + +#: src/iac_code/commands/status.py:19 +msgid "Status command requires a context." +msgstr "Der Befehl status benötigt einen Kontext." + +#: src/iac_code/commands/status.py:22 +msgid "Status command requires a REPL context." +msgstr "Der Befehl status benötigt einen REPL-Kontext." + +#: src/iac_code/commands/status.py:24 +msgid "Status is only available in interactive mode." +msgstr "status ist nur im interaktiven Modus verfügbar." + +#: src/iac_code/commands/status.py:33 src/iac_code/ui/banner.py:136 +#: src/iac_code/ui/banner.py:138 +msgid "Session" +msgstr "Sitzung" + +#: src/iac_code/commands/status.py:34 +msgid "Provider" +msgstr "Anbieter" + +#: src/iac_code/commands/status.py:34 src/iac_code/commands/status.py:35 +#: src/iac_code/commands/status.py:36 +msgid "not configured" +msgstr "nicht konfiguriert" + +#: src/iac_code/commands/status.py:35 +msgid "Model" +msgstr "Modell" + +#: src/iac_code/commands/status.py:37 +msgid "CWD" +msgstr "Aktuelles Verzeichnis" + +#: src/iac_code/commands/status.py:41 +msgid "API Token Usage (recorded):" +msgstr "API-Token-Nutzung (aufgezeichnet):" + +#: src/iac_code/commands/status.py:44 +msgid "Input" +msgstr "Eingabe" + +#: src/iac_code/commands/status.py:45 +msgid "Output" +msgstr "Ausgabe" + +#: src/iac_code/commands/status.py:46 +msgid "Cache read" +msgstr "Cache-Lesezugriffe" + +#: src/iac_code/commands/status.py:47 +msgid "Total" +msgstr "Gesamt" + +#: src/iac_code/commands/status.py:50 +msgid "No recorded API usage for this session yet." +msgstr "Für diese Sitzung wurde noch keine API-Nutzung aufgezeichnet." + +#: src/iac_code/commands/status.py:54 +msgid "Turns" +msgstr "Runden" + +#: src/iac_code/commands/status.py:55 +msgid "Context" +msgstr "Kontext" + +#: src/iac_code/commands/status.py:57 +msgid "Session Status" +msgstr "Sitzungsstatus" + +#: src/iac_code/commands/status.py:73 +#, python-brace-format +msgid "{session_id} (resumed)" +msgstr "{session_id} (fortgesetzt)" + +#: src/iac_code/commands/status.py:81 +#, python-brace-format +msgid "{percent} used ({total} / {window})" +msgstr "{percent} verwendet ({total} / {window})" + # Typer/Click built-in strings #: src/iac_code/i18n/__init__.py:51 msgid "Options" @@ -1096,79 +1366,79 @@ msgstr "" " Base URL korrekt ist (aktuell: {base_url}). Viele OpenAI-kompatible " "Endpunkte erfordern ein /v1-Suffix (z. B. {base_url}/v1)." -#: src/iac_code/providers/registry.py:415 +#: src/iac_code/providers/registry.py:416 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "OpenAPI Compatible" msgstr "OpenAPI-kompatibel" -#: src/iac_code/providers/registry.py:422 +#: src/iac_code/providers/registry.py:423 msgid "Kimi (China)" msgstr "Kimi (China)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "Kimi (International)" msgstr "Kimi (International)" -#: src/iac_code/providers/registry.py:424 +#: src/iac_code/providers/registry.py:425 msgid "MiniMax (China)" msgstr "MiniMax (China)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "MiniMax (International)" msgstr "MiniMax (International)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (International)" -#: src/iac_code/providers/registry.py:429 +#: src/iac_code/providers/registry.py:430 msgid "SiliconFlow (China)" msgstr "SiliconFlow (China)" -#: src/iac_code/providers/registry.py:430 +#: src/iac_code/providers/registry.py:431 msgid "SiliconFlow (International)" msgstr "SiliconFlow (International)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "Ollama (Local)" msgstr "Ollama (Lokal)" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "LM Studio (Local)" msgstr "LM Studio (Lokal)" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (International)" -#: src/iac_code/providers/registry.py:438 +#: src/iac_code/providers/registry.py:439 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:439 +#: src/iac_code/providers/registry.py:440 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (International)" -#: src/iac_code/providers/registry.py:440 +#: src/iac_code/providers/registry.py:441 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:441 +#: src/iac_code/providers/registry.py:442 msgid "Anthropic Compatible" msgstr "Anthropic-kompatibel" @@ -1188,6 +1458,16 @@ msgstr "" "deaktivieren Sie den QwenPaw-Modus (entfernen Sie 'llm_source: qwenpaw' " "aus settings.yml)." +#: src/iac_code/services/session_metadata.py:52 +#, python-brace-format +msgid "Session name must match {pattern}" +msgstr "Der Sitzungsname muss {pattern} entsprechen" + +#: src/iac_code/services/session_storage.py:241 +#, python-brace-format +msgid "Session name already exists in this project: {name}" +msgstr "Der Sitzungsname ist in diesem Projekt bereits vorhanden: {name}" + #: src/iac_code/services/permissions/loader.py:50 #, python-brace-format msgid "Invalid --permission-mode {!r}. Valid values: {}" @@ -1199,15 +1479,145 @@ msgstr "Ungültiges --permission-mode {!r}. Gültige Werte: {}" msgid "Allow {}?" msgstr "{} erlauben?" -#: src/iac_code/skills/skill_tool.py:130 +#: src/iac_code/services/providers/aliyun.py:144 +msgid "Alibaba Cloud OAuth site is missing." +msgstr "Die Alibaba Cloud-OAuth-Site fehlt." + +#: src/iac_code/services/providers/aliyun.py:150 +msgid "Alibaba Cloud OAuth refresh token is missing." +msgstr "Das Alibaba Cloud-OAuth-Refresh-Token fehlt." + +#: src/iac_code/services/providers/aliyun.py:158 +msgid "Alibaba Cloud OAuth access token is missing." +msgstr "Das Alibaba Cloud-OAuth-Zugriffstoken fehlt." + +#: src/iac_code/services/providers/aliyun_oauth.py:83 +msgid "Run /auth and choose OAuth Login (Browser)." +msgstr "Führen Sie /auth aus und wählen Sie OAuth-Anmeldung (Browser)." + +#: src/iac_code/services/providers/aliyun_oauth.py:106 +#, python-brace-format +msgid "Unknown Aliyun OAuth site: {site_type}" +msgstr "Unbekannte Aliyun-OAuth-Site: {site_type}" + +#: src/iac_code/services/providers/aliyun_oauth.py:164 +msgid "Not found" +msgstr "Nicht gefunden" + +#: src/iac_code/services/providers/aliyun_oauth.py:170 +msgid "invalid state" +msgstr "ungültiger Status" + +#: src/iac_code/services/providers/aliyun_oauth.py:171 +msgid "Invalid state" +msgstr "Ungültiger Status" + +#: src/iac_code/services/providers/aliyun_oauth.py:176 +msgid "code not found" +msgstr "Autorisierungscode nicht gefunden" + +#: src/iac_code/services/providers/aliyun_oauth.py:177 +msgid "Authorization code not found" +msgstr "Autorisierungscode nicht gefunden" + +#: src/iac_code/services/providers/aliyun_oauth.py:181 +msgid "Authorization successful. You can close this window." +msgstr "Autorisierung erfolgreich. Sie können dieses Fenster schließen." + +#: src/iac_code/services/providers/aliyun_oauth.py:212 +#, python-brace-format +msgid "No available callback port in range {start}-{end}" +msgstr "Kein verfügbarer Callback-Port im Bereich {start}-{end}" + +#: src/iac_code/services/providers/aliyun_oauth.py:227 +msgid "OAuth login cancelled." +msgstr "OAuth-Anmeldung abgebrochen." + +#: src/iac_code/services/providers/aliyun_oauth.py:278 +msgid "Open in your browser:" +msgstr "Im Browser öffnen:" + +#: src/iac_code/services/providers/aliyun_oauth.py:299 +msgid "Waiting for browser authorization" +msgstr "Warten auf Browserautorisierung" + +#: src/iac_code/services/providers/aliyun_oauth.py:300 +msgid "" +"1. The browser may show official-cli; this is the Alibaba Cloud official " +"CLI OAuth application." +msgstr "" +"1. Der Browser kann official-cli anzeigen; dies ist die offizielle CLI-" +"OAuth-Anwendung von Alibaba Cloud." + +#: src/iac_code/services/providers/aliyun_oauth.py:302 +msgid "" +"2. If assignment is required, assign the RAM user or RAM role that is " +"signed in. User groups are not supported." +msgstr "" +"2. Falls eine Zuweisung erforderlich ist, weisen Sie den angemeldeten " +"RAM-Benutzer oder die RAM-Rolle zu. Benutzergruppen werden nicht " +"unterstützt." + +#: src/iac_code/services/providers/aliyun_oauth.py:306 +msgid "" +"3. After assignment, close the old authorization page and run OAuth Login" +" (Browser) again. If it still fails, sign out of Alibaba Cloud and sign " +"in again." +msgstr "" +"3. Schließen Sie nach der Zuweisung die alte Autorisierungsseite und " +"führen Sie OAuth-Anmeldung (Browser) erneut aus. Falls es weiterhin " +"fehlschlägt, melden Sie sich bei Alibaba Cloud ab und wieder an." + +#: src/iac_code/services/providers/aliyun_oauth.py:310 +msgid "" +"4. STS credentials refresh when possible until Alibaba Cloud expires " +"them. If refresh fails, run /auth again." +msgstr "" +"4. STS-Anmeldeinformationen werden nach Möglichkeit aktualisiert, bis " +"Alibaba Cloud sie ablaufen lässt. Wenn die Aktualisierung fehlschlägt, " +"führen Sie /auth erneut aus." + +#: src/iac_code/services/providers/aliyun_oauth.py:313 +msgid "Press Esc to cancel while waiting." +msgstr "Drücken Sie Esc, um das Warten abzubrechen." + +#: src/iac_code/services/providers/aliyun_oauth.py:321 +msgid "" +"Timed out waiting for OAuth callback. If Alibaba Cloud asked you to " +"assign the official-cli application, assign it to the exact RAM user or " +"RAM role currently signed in. User groups are not supported. Then close " +"the old authorization page, sign out of Alibaba Cloud and sign in again " +"if needed, and run /auth to choose OAuth Login (Browser) again." +msgstr "" +"Zeitüberschreitung beim Warten auf den OAuth-Callback. Wenn Alibaba Cloud" +" Sie aufgefordert hat, die Anwendung official-cli zuzuweisen, weisen Sie " +"sie genau dem aktuell angemeldeten RAM-Benutzer oder der RAM-Rolle zu. " +"Benutzergruppen werden nicht unterstützt. Schließen Sie danach die alte " +"Autorisierungsseite, melden Sie sich bei Alibaba Cloud ab und bei Bedarf " +"wieder an, und führen Sie /auth aus, um erneut OAuth-Anmeldung (Browser) " +"zu wählen." + +#: src/iac_code/skills/skill_tool.py:85 src/iac_code/ui/repl.py:842 +#, python-brace-format +msgid "Skill '{name}' is disabled. Run /skills to enable it." +msgstr "" +"Skill '{name}' ist deaktiviert. Führen Sie /skills aus, um ihn zu " +"aktivieren." + +#: src/iac_code/skills/skill_tool.py:137 #, python-brace-format msgid "Skill '{name}' loaded (inline)." msgstr "Skill '{name}' geladen (inline)." -#: src/iac_code/skills/skill_tool.py:214 +#: src/iac_code/skills/skill_tool.py:221 msgid "Skill" msgstr "Skill" +#: src/iac_code/skills/skill_tool.py:245 +#, python-brace-format +msgid "Skill disabled: {name}" +msgstr "Skill deaktiviert: {name}" + #: src/iac_code/skills/bundled/simplify.py:25 msgid "" "Review changed code for reuse, quality, and efficiency, then fix issues " @@ -1481,7 +1891,7 @@ msgstr "CloudAPI" msgid "Calling {action}..." msgstr "{action} wird aufgerufen …" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:390 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:400 #: src/iac_code/tools/cloud/base_api.py:123 msgid "Call succeeded" msgstr "Aufruf erfolgreich" @@ -1641,11 +2051,11 @@ msgstr "IMPORT ABGESCHLOSSEN" msgid "IMPORT_FAILED" msgstr "IMPORT FEHLGESCHLAGEN" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:171 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:172 msgid "Aliyun API" msgstr "Aliyun API" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:389 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:399 #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" msgstr "Aufruf erfolgreich (RequestId: {request_id})" @@ -1806,23 +2216,19 @@ msgstr "Versionshinweise" msgid "Run {} to update." msgstr "Führe {} aus, um zu aktualisieren." -#: src/iac_code/ui/banner.py:105 +#: src/iac_code/ui/banner.py:110 msgid "Your AI-powered Infrastructure as Code assistant" msgstr "Ihr KI-gestützter Infrastructure-as-Code-Assistent" -#: src/iac_code/ui/banner.py:131 +#: src/iac_code/ui/banner.py:144 msgid "Welcome back" msgstr "Willkommen zurück" -#: src/iac_code/ui/banner.py:138 -msgid "Session" -msgstr "Sitzung" - -#: src/iac_code/ui/banner.py:148 +#: src/iac_code/ui/banner.py:161 msgid "Debug mode" msgstr "Debug-Modus" -#: src/iac_code/ui/banner.py:149 +#: src/iac_code/ui/banner.py:162 msgid "Log file" msgstr "Protokolldatei" @@ -1919,103 +2325,115 @@ msgstr "Nein, immer \"{rule}\" ablehnen (diese Sitzung)" msgid "No, always reject this tool" msgstr "Nein, dieses Tool immer ablehnen" -#: src/iac_code/ui/repl.py:370 +#: src/iac_code/ui/repl.py:424 msgid "Press Ctrl+C again to exit." msgstr "Drücken Sie erneut Ctrl+C zum Beenden." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:449 msgid "Interrupted." msgstr "Unterbrochen." -#: src/iac_code/ui/repl.py:432 -msgid "Goodbye!" -msgstr "Auf Wiedersehen!" - -#: src/iac_code/ui/repl.py:433 -msgid "Resume this session with:" -msgstr "Diese Sitzung fortsetzen mit:" - -#: src/iac_code/ui/repl.py:458 +#: src/iac_code/ui/repl.py:508 msgid "Update now" msgstr "Jetzt aktualisieren" -#: src/iac_code/ui/repl.py:460 +#: src/iac_code/ui/repl.py:510 msgid "Run the shown update command and exit when it succeeds." msgstr "" "Führt den angezeigten Aktualisierungsbefehl aus und beendet das Programm " "bei Erfolg." -#: src/iac_code/ui/repl.py:463 +#: src/iac_code/ui/repl.py:513 msgid "Skip" msgstr "Überspringen" -#: src/iac_code/ui/repl.py:465 +#: src/iac_code/ui/repl.py:515 msgid "Continue with the current version for this session." msgstr "Für diese Sitzung mit der aktuellen Version fortfahren." -#: src/iac_code/ui/repl.py:468 +#: src/iac_code/ui/repl.py:518 msgid "Skip until next version" msgstr "Bis zur nächsten Version überspringen" -#: src/iac_code/ui/repl.py:470 +#: src/iac_code/ui/repl.py:520 msgid "Hide this update until a newer version is available." msgstr "Dieses Update ausblenden, bis eine neuere Version verfügbar ist." -#: src/iac_code/ui/repl.py:489 src/iac_code/ui/repl.py:501 +#: src/iac_code/ui/repl.py:539 src/iac_code/ui/repl.py:551 msgid "Update command failed. Continuing with the current version." msgstr "" "Der Aktualisierungsbefehl ist fehlgeschlagen. Es wird mit der aktuellen " "Version fortgefahren." -#: src/iac_code/ui/repl.py:494 +#: src/iac_code/ui/repl.py:544 msgid "Update completed. Restart iac-code to continue." msgstr "Update abgeschlossen. Starten Sie iac-code neu, um fortzufahren." -#: src/iac_code/ui/repl.py:532 +#: src/iac_code/ui/repl.py:582 msgid "No image in clipboard." msgstr "Kein Bild in der Zwischenablage." -#: src/iac_code/ui/repl.py:718 +#: src/iac_code/ui/repl.py:768 msgid "Usage: !" msgstr "Verwendung: !" -#: src/iac_code/ui/repl.py:723 +#: src/iac_code/ui/repl.py:775 msgid "Shell command support is unavailable." msgstr "Shell-Befehlsunterstützung ist nicht verfügbar." -#: src/iac_code/ui/repl.py:787 +#: src/iac_code/ui/repl.py:845 #, python-brace-format msgid "Unknown skill: ${name}. Type / to list commands and skills." msgstr "Unbekannter Skill: ${name}. Tippe /, um Befehle und Skills aufzulisten." -#: src/iac_code/ui/repl.py:789 +#: src/iac_code/ui/repl.py:847 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" "Unknown command: /{name}. Type /help for available commands.Unbekannter " "Befehl: /{name}. Geben Sie /help für verfügbare Befehle ein." -#: src/iac_code/ui/repl.py:794 +#: src/iac_code/ui/repl.py:852 #, python-brace-format msgid "$ only invokes skills. Use /{name} instead." msgstr "$ ruft nur Skills auf. Verwende stattdessen /{name}." -#: src/iac_code/ui/repl.py:816 src/iac_code/ui/repl.py:861 +#: src/iac_code/ui/repl.py:874 src/iac_code/ui/repl.py:922 #, python-brace-format msgid "Command error: {error}" msgstr "Befehlsfehler: {error}" -#: src/iac_code/ui/repl.py:823 +#: src/iac_code/ui/repl.py:881 #, python-brace-format msgid "Command has no handler: {name}" msgstr "Kein Handler für Befehl: {name}" -#: src/iac_code/ui/repl.py:1128 +#: src/iac_code/ui/repl.py:1156 +msgid "Goodbye!" +msgstr "Auf Wiedersehen!" + +#: src/iac_code/ui/repl.py:1157 +msgid "Resume this session with:" +msgstr "Diese Sitzung fortsetzen mit:" + +#: src/iac_code/ui/repl.py:1160 +msgid "Session ID" +msgstr "Sitzungs-ID" + +#: src/iac_code/ui/repl.py:1210 src/iac_code/ui/repl.py:1214 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Sitzung nicht gefunden: {session_id}" -#: src/iac_code/ui/repl.py:1147 +#: src/iac_code/ui/repl.py:1263 +msgid "Session name: " +msgstr "Sitzungsname: " + +#: src/iac_code/ui/repl.py:1269 +msgid "Session name cannot be empty." +msgstr "Der Sitzungsname darf nicht leer sein." + +#: src/iac_code/ui/repl.py:1279 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -2026,19 +2444,23 @@ msgstr "" "Zum Fortsetzen ausführen:\n" " {cmd}" -#: src/iac_code/ui/repl.py:1186 +#: src/iac_code/ui/repl.py:1283 +msgid "Multiple sessions match. Resume one by ID:" +msgstr "Mehrere Sitzungen passen. Setzen Sie eine per ID fort:" + +#: src/iac_code/ui/repl.py:1396 msgid "This conversation is from a different directory." msgstr "Diese Konversation stammt aus einem anderen Verzeichnis." -#: src/iac_code/ui/repl.py:1188 +#: src/iac_code/ui/repl.py:1398 msgid "To resume, run:" msgstr "Zum Fortsetzen ausführen:" -#: src/iac_code/ui/repl.py:1193 +#: src/iac_code/ui/repl.py:1403 msgid "(Command copied to clipboard)" msgstr "(Befehl in die Zwischenablage kopiert)" -#: src/iac_code/ui/repl.py:1350 +#: src/iac_code/ui/repl.py:1560 #, python-brace-format msgid "" "Current model {model} does not support image input. Use /model to switch " @@ -2047,12 +2469,12 @@ msgstr "" "Das aktuelle Modell {model} unterstützt keine Bildeingabe. Verwenden Sie " "/model, um zu einem Vision-fähigen Modell zu wechseln." -#: src/iac_code/ui/repl.py:1359 +#: src/iac_code/ui/repl.py:1569 #, python-brace-format msgid "Image error: {err}" msgstr "Bildfehler: {err}" -#: src/iac_code/ui/repl.py:1376 +#: src/iac_code/ui/repl.py:1586 msgid "" "Failed to persist image to cache; it will only exist in memory for this " "turn." @@ -2148,91 +2570,192 @@ msgstr "Tippen, um Dateien zu suchen …" msgid "No matching files" msgstr "Keine passenden Dateien" -#: src/iac_code/ui/dialogs/resume_picker.py:114 +#: src/iac_code/ui/dialogs/resume_picker.py:116 msgid "Search..." msgstr "Suchen …" -#: src/iac_code/ui/dialogs/resume_picker.py:359 +#: src/iac_code/ui/dialogs/resume_picker.py:374 msgid "Resume Session" msgstr "Sitzung fortsetzen" -#: src/iac_code/ui/dialogs/resume_picker.py:369 +#: src/iac_code/ui/dialogs/resume_picker.py:384 msgid "No sessions found" msgstr "Keine Sitzungen gefunden" -#: src/iac_code/ui/dialogs/resume_picker.py:427 +#: src/iac_code/ui/dialogs/resume_picker.py:444 msgid "show current dir" msgstr "aktuelles Verzeichnis anzeigen" -#: src/iac_code/ui/dialogs/resume_picker.py:429 +#: src/iac_code/ui/dialogs/resume_picker.py:446 msgid "show all projects" msgstr "alle Projekte anzeigen" -#: src/iac_code/ui/dialogs/resume_picker.py:432 +#: src/iac_code/ui/dialogs/resume_picker.py:449 msgid "show all branches" msgstr "alle Branches anzeigen" -#: src/iac_code/ui/dialogs/resume_picker.py:434 +#: src/iac_code/ui/dialogs/resume_picker.py:451 msgid "only show current branch" msgstr "nur aktuellen Branch anzeigen" -#: src/iac_code/ui/dialogs/resume_picker.py:435 +#: src/iac_code/ui/dialogs/resume_picker.py:452 msgid "preview" msgstr "Vorschau" -#: src/iac_code/ui/dialogs/resume_picker.py:436 +#: src/iac_code/ui/dialogs/resume_picker.py:453 msgid "Type to search" msgstr "Tippen, um zu suchen" -#: src/iac_code/ui/dialogs/resume_picker.py:437 +#: src/iac_code/ui/dialogs/resume_picker.py:454 msgid "cancel" msgstr "abbrechen" -#: src/iac_code/ui/dialogs/resume_picker.py:552 +#: src/iac_code/ui/dialogs/resume_picker.py:569 #, python-brace-format msgid "{n} more line{s}" msgstr "{n} weitere Zeile{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:566 +#: src/iac_code/ui/dialogs/resume_picker.py:583 #, python-brace-format msgid "{n} message{s}" msgstr "{n} Nachricht{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:580 +#: src/iac_code/ui/dialogs/resume_picker.py:597 msgid "resume" msgstr "fortsetzen" -#: src/iac_code/ui/dialogs/resume_picker.py:584 +#: src/iac_code/ui/dialogs/resume_picker.py:601 msgid "back" msgstr "zurück" -#: src/iac_code/ui/dialogs/resume_picker.py:589 +#: src/iac_code/ui/dialogs/resume_picker.py:606 msgid "scroll" msgstr "scrollen" -#: src/iac_code/ui/dialogs/resume_picker.py:608 +#: src/iac_code/ui/dialogs/resume_picker.py:625 msgid "(empty session)" msgstr "(leere Sitzung)" -#: src/iac_code/ui/dialogs/resume_picker.py:728 +#: src/iac_code/ui/dialogs/resume_picker.py:745 msgid "just now" msgstr "gerade eben" -#: src/iac_code/ui/dialogs/resume_picker.py:731 +#: src/iac_code/ui/dialogs/resume_picker.py:748 #, python-brace-format msgid "{n} minute{s} ago" msgstr "vor {n} Minute{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:734 +#: src/iac_code/ui/dialogs/resume_picker.py:751 #, python-brace-format msgid "{n} hour{s} ago" msgstr "vor {n} Stunde{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:736 +#: src/iac_code/ui/dialogs/resume_picker.py:753 #, python-brace-format msgid "{n} day{s} ago" msgstr "vor {n} Tag{s}" +#: src/iac_code/ui/dialogs/skills_picker.py:52 +msgid "Search skills..." +msgstr "Skills suchen..." + +#: src/iac_code/ui/dialogs/skills_picker.py:159 +msgid "Skills" +msgstr "Skills" + +#: src/iac_code/ui/dialogs/skills_picker.py:161 +#, python-brace-format +msgid "{current} of {total}" +msgstr "{current} von {total}" + +#: src/iac_code/ui/dialogs/skills_picker.py:165 +#, python-brace-format +msgid "" +"{count} skills - Space to toggle, Enter to save, Tab to sort, Esc to " +"cancel" +msgstr "" +"{count} Skills - Leertaste zum Umschalten, Enter zum Speichern, Tab zum " +"Sortieren, Esc zum Abbrechen" + +#: src/iac_code/ui/dialogs/skills_picker.py:171 +#, python-brace-format +msgid "Sort: {mode}" +msgstr "Sortieren: {mode}" + +#: src/iac_code/ui/dialogs/skills_picker.py:176 +msgid "No skills found" +msgstr "Keine Skills gefunden" + +#: src/iac_code/ui/dialogs/skills_picker.py:245 +msgid "Bundled skills cannot be disabled." +msgstr "Gebündelte Skills können nicht deaktiviert werden." + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "on" +msgstr "aktiviert" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "off" +msgstr "deaktiviert" + +#: src/iac_code/ui/dialogs/skills_picker.py:265 +msgid "locked" +msgstr "gesperrt" + +#: src/iac_code/ui/dialogs/skills_picker.py:268 +msgid "matched description" +msgstr "Beschreibung stimmt überein" + +#: src/iac_code/ui/dialogs/skills_picker.py:277 +msgid "source" +msgstr "Quelle" + +#: src/iac_code/ui/dialogs/skills_picker.py:279 +msgid "size" +msgstr "Größe" + +#: src/iac_code/ui/dialogs/skills_picker.py:280 +msgid "name" +msgstr "Name" + +#: src/iac_code/ui/dialogs/skills_picker.py:285 +msgid "bundled" +msgstr "gebündelt" + +#: src/iac_code/ui/dialogs/skills_picker.py:287 +msgid "project" +msgstr "Projekt" + +#: src/iac_code/ui/dialogs/skills_picker.py:289 +msgid "user" +msgstr "Benutzer" + +#: src/iac_code/ui/dialogs/skills_picker.py:296 +#, python-brace-format +msgid "~{count}k tokens" +msgstr "~{count}k Tokens" + +#: src/iac_code/ui/dialogs/skills_picker.py:297 +#, python-brace-format +msgid "~{count} tokens" +msgstr "~{count} Tokens" + +#: src/iac_code/ui/suggestions/command_provider.py:79 +msgid "Search saved memories" +msgstr "Gespeicherte Erinnerungen durchsuchen" + +#: src/iac_code/ui/suggestions/command_provider.py:80 +msgid "Delete a saved memory" +msgstr "Eine gespeicherte Erinnerung löschen" + +#: src/iac_code/ui/suggestions/command_provider.py:81 +msgid "Show memory command help" +msgstr "Hilfe zum memory-Befehl anzeigen" + +#: src/iac_code/ui/suggestions/command_provider.py:116 +msgid "Saved memory" +msgstr "Gespeicherte Erinnerung" + #: src/iac_code/utils/platform.py:39 msgid "iac-code on Windows requires Git for Windows." msgstr "iac-code unter Windows erfordert Git for Windows." @@ -2303,3 +2826,19 @@ msgstr "" #~ msgid " Option 2 - npmmirror (China-friendly mirror):" #~ msgstr " Option 2 - npmmirror (China-freundlicher Spiegel):" +#~ msgid "Cache create" +#~ msgstr "Cache-Erstellung" + +#~ msgid "" +#~ "{count} skills - Space to toggle, " +#~ "Enter to save, / to search, t " +#~ "to sort, Esc to cancel" +#~ msgstr "" +#~ "{count} Skills - Leertaste zum " +#~ "Umschalten, Enter zum Speichern, / zum" +#~ " Suchen, t zum Sortieren, Esc zum " +#~ "Abbrechen" + +#~ msgid "Resume a session by ID" +#~ msgstr "Eine Sitzung anhand der ID fortsetzen" + diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 8483d4a..5e591b9 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -4,9 +4,9 @@ # msgid "" msgstr "" -"Project-Id-Version: iac-code 0.3.0\n" +"Project-Id-Version: iac-code 0.4.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-01 17:56+0800\n" +"POT-Creation-Date: 2026-06-03 13:32+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: es\n" @@ -32,7 +32,22 @@ msgstr "" "El transporte de socket de dominio Unix no es compatible con Windows. Use" " --transport http o --transport stdio en su lugar." -#: src/iac_code/acp/slash_registry.py:44 +#: src/iac_code/acp/server.py:413 src/iac_code/acp/server.py:429 +#: src/iac_code/acp/server.py:456 +msgid "Session not found" +msgstr "Sesión no encontrada" + +#: src/iac_code/acp/server.py:416 +#, python-brace-format +msgid "Session name is ambiguous. Candidates: {candidates}" +msgstr "El nombre de sesión es ambiguo. Sesiones candidatas: {candidates}" + +#: src/iac_code/acp/server.py:434 src/iac_code/acp/server.py:708 +#, python-brace-format +msgid "Session belongs to another project. Run: {hint}" +msgstr "La sesión pertenece a otro proyecto. Ejecuta: {hint}" + +#: src/iac_code/acp/slash_registry.py:46 #, python-brace-format msgid "" "Command '/{cmd_name}' is not supported over ACP. Supported commands: " @@ -41,21 +56,21 @@ msgstr "" "El comando '/{cmd_name}' no está admitido en ACP. Comandos admitidos: " "{supported}" -#: src/iac_code/acp/slash_registry.py:56 +#: src/iac_code/acp/slash_registry.py:62 #, python-brace-format msgid "Command '/{cmd_name}' handler not implemented." msgstr "El controlador del comando '/{cmd_name}' no está implementado." -#: src/iac_code/acp/slash_registry.py:68 +#: src/iac_code/acp/slash_registry.py:74 #, python-brace-format msgid "Compaction failed: {error}" msgstr "Error al compactar: {error}" -#: src/iac_code/acp/slash_registry.py:71 src/iac_code/commands/compact.py:24 +#: src/iac_code/acp/slash_registry.py:77 src/iac_code/commands/compact.py:24 msgid "Nothing to compact: conversation is empty." msgstr "Nada que compactar: la conversación está vacía." -#: src/iac_code/acp/slash_registry.py:74 src/iac_code/commands/compact.py:27 +#: src/iac_code/acp/slash_registry.py:80 src/iac_code/commands/compact.py:27 #, python-brace-format msgid "" "Conversation too short to compact: all messages are within the recent " @@ -64,14 +79,14 @@ msgstr "" "Conversación demasiado corta para compactar: todos los mensajes están " "dentro de la ventana de retención de las últimas {turns} interacciones." -#: src/iac_code/acp/slash_registry.py:78 src/iac_code/commands/compact.py:30 +#: src/iac_code/acp/slash_registry.py:84 src/iac_code/commands/compact.py:30 msgid "Compaction failed. See logs for details." msgstr "" "Compaction failed. See logs for details.Compaction failed. See logs for " "details.La compactación falló. Consulte los registros para obtener más " "información." -#: src/iac_code/acp/slash_registry.py:83 +#: src/iac_code/acp/slash_registry.py:89 #, python-brace-format msgid "" "Context compacted: {original} → {compacted} tokens ({percent} reduction)." @@ -80,39 +95,61 @@ msgstr "" "Contexto compactado: {original} → {compacted} tokens (reducción del " "{percent}). Uso del contexto: {usage}" -#: src/iac_code/acp/slash_registry.py:97 +#: src/iac_code/acp/slash_registry.py:103 #, python-brace-format msgid "Clear failed: {error}" msgstr "Error al borrar: {error}" -#: src/iac_code/acp/slash_registry.py:98 +#: src/iac_code/acp/slash_registry.py:104 msgid "Conversation history cleared." msgstr "Historial de conversación borrado." -#: src/iac_code/acp/slash_registry.py:114 src/iac_code/commands/debug.py:34 +#: src/iac_code/acp/slash_registry.py:120 src/iac_code/commands/debug.py:34 #, python-brace-format msgid "Debug logging is on. Log file: {path}" msgstr "El registro de depuración está activado. Archivo de registro: {path}" -#: src/iac_code/acp/slash_registry.py:115 src/iac_code/commands/debug.py:35 +#: src/iac_code/acp/slash_registry.py:121 src/iac_code/commands/debug.py:35 msgid "Debug logging is off." msgstr "El registro de depuración está desactivado." -#: src/iac_code/acp/slash_registry.py:119 src/iac_code/commands/debug.py:39 +#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:39 #, python-brace-format msgid "Debug logging enabled. Log file: {path}" msgstr "Registro de depuración habilitado. Archivo de registro: {path}" -#: src/iac_code/acp/slash_registry.py:123 src/iac_code/commands/debug.py:43 +#: src/iac_code/acp/slash_registry.py:129 src/iac_code/commands/debug.py:43 msgid "Debug logging disabled." msgstr "Registro de depuración deshabilitado." -#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:45 +#: src/iac_code/acp/slash_registry.py:131 src/iac_code/commands/debug.py:45 msgid "Usage: /debug [on|off]" msgstr "Uso: /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:404 src/iac_code/agent/agent_loop.py:419 -#: src/iac_code/ui/repl.py:757 src/iac_code/ui/repl.py:771 +#: src/iac_code/acp/slash_registry.py:136 src/iac_code/commands/memory.py:84 +msgid "Memory manager is unavailable." +msgstr "El gestor de memoria no está disponible." + +#: src/iac_code/acp/slash_registry.py:146 src/iac_code/commands/rename.py:21 +msgid "Usage: /rename " +msgstr "Uso: /rename " + +#: src/iac_code/acp/slash_registry.py:152 +msgid "Rename is only available after a session is created." +msgstr "El cambio de nombre solo está disponible después de crear una sesión." + +#: src/iac_code/acp/slash_registry.py:163 src/iac_code/commands/rename.py:42 +#, python-brace-format +msgid "Session is already named {name}" +msgstr "La sesión ya se llama {name}" + +#: src/iac_code/acp/slash_registry.py:164 src/iac_code/commands/rename.py:43 +#, python-brace-format +msgid "Renamed session to {name}" +msgstr "Sesión renombrada a {name}" + +#: src/iac_code/agent/agent_loop.py:413 src/iac_code/agent/agent_loop.py:428 +#: src/iac_code/ui/repl.py:813 src/iac_code/ui/repl.py:827 msgid "Permission denied." msgstr "Permiso denegado." @@ -282,8 +319,8 @@ msgid "Show version and exit" msgstr "Mostrar la versión y salir" #: src/iac_code/cli/main.py:89 -msgid "Resume a session by ID" -msgstr "Reanudar una sesión por ID" +msgid "Resume a session by ID or name" +msgstr "Reanudar una sesión por ID o nombre" #: src/iac_code/cli/main.py:90 msgid "Resume the most recent session" @@ -609,270 +646,368 @@ msgstr "Directorio para rutas A2A persistentes" msgid "Save the provided routes as a route snapshot" msgstr "Guarda las rutas proporcionadas como una instantánea de rutas" -#: src/iac_code/commands/__init__.py:22 +#: src/iac_code/commands/__init__.py:26 msgid "Show available commands" msgstr "Mostrar los comandos disponibles" -#: src/iac_code/commands/__init__.py:31 +#: src/iac_code/commands/__init__.py:35 msgid "Clear conversation history" msgstr "Borrar el historial de conversación" -#: src/iac_code/commands/__init__.py:39 +#: src/iac_code/commands/__init__.py:43 msgid "Show or switch model" msgstr "Mostrar o cambiar de modelo" -#: src/iac_code/commands/__init__.py:48 +#: src/iac_code/commands/__init__.py:52 msgid "Show or switch thinking effort" msgstr "Mostrar o cambiar el nivel de razonamiento (effort)" -#: src/iac_code/commands/__init__.py:57 +#: src/iac_code/commands/__init__.py:61 msgid "Compact conversation context" msgstr "Compactar el contexto de la conversación" -#: src/iac_code/commands/__init__.py:59 +#: src/iac_code/commands/__init__.py:63 msgid "Compacting conversation" msgstr "Compactando la conversación" -#: src/iac_code/commands/__init__.py:66 +#: src/iac_code/commands/__init__.py:70 msgid "Exit the application" msgstr "Salir de la aplicación" -#: src/iac_code/commands/__init__.py:75 +#: src/iac_code/commands/__init__.py:79 msgid "Authenticate with LLM provider" msgstr "Autenticar con el proveedor LLM" -#: src/iac_code/commands/__init__.py:84 +#: src/iac_code/commands/__init__.py:88 msgid "Toggle debug logging" msgstr "Activar o desactivar el registro de depuración" -#: src/iac_code/commands/__init__.py:93 +#: src/iac_code/commands/__init__.py:97 +msgid "View and manage persistent memories" +msgstr "Ver y administrar memorias persistentes" + +#: src/iac_code/commands/__init__.py:99 +msgid "[|search |delete |help]" +msgstr "[|search |delete |help]" + +#: src/iac_code/commands/__init__.py:106 msgid "Resume a previous session" msgstr "Reanudar una sesión anterior" -#: src/iac_code/commands/__init__.py:95 +#: src/iac_code/commands/__init__.py:108 msgid "[conversation id or search term]" msgstr "[id de conversación o término de búsqueda]" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:1107 +#: src/iac_code/commands/__init__.py:115 +msgid "Rename the current session" +msgstr "Renombrar la sesión actual" + +#: src/iac_code/commands/__init__.py:124 +msgid "Manage skills" +msgstr "Gestionar habilidades" + +#: src/iac_code/commands/__init__.py:132 +msgid "Show current session status" +msgstr "Mostrar el estado actual de la sesión" + +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:1117 #: src/iac_code/ui/core/prompt_input.py:557 msgid "Navigate" msgstr "Navegar" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:530 -#: src/iac_code/commands/auth.py:562 src/iac_code/commands/auth.py:569 -#: src/iac_code/commands/auth.py:604 src/iac_code/commands/auth.py:611 -#: src/iac_code/commands/auth.py:632 src/iac_code/commands/auth.py:1107 -#: src/iac_code/commands/auth.py:1319 src/iac_code/ui/core/prompt_input.py:557 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:538 +#: src/iac_code/commands/auth.py:570 src/iac_code/commands/auth.py:577 +#: src/iac_code/commands/auth.py:612 src/iac_code/commands/auth.py:619 +#: src/iac_code/commands/auth.py:640 src/iac_code/commands/auth.py:1117 +#: src/iac_code/commands/auth.py:1551 src/iac_code/ui/core/prompt_input.py:557 msgid "Confirm" msgstr "Confirmar" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:528 -#: src/iac_code/commands/auth.py:530 src/iac_code/commands/auth.py:562 -#: src/iac_code/commands/auth.py:569 src/iac_code/commands/auth.py:604 -#: src/iac_code/commands/auth.py:611 src/iac_code/commands/auth.py:632 -#: src/iac_code/commands/auth.py:1107 src/iac_code/commands/auth.py:1217 -#: src/iac_code/commands/auth.py:1319 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:536 +#: src/iac_code/commands/auth.py:538 src/iac_code/commands/auth.py:570 +#: src/iac_code/commands/auth.py:577 src/iac_code/commands/auth.py:612 +#: src/iac_code/commands/auth.py:619 src/iac_code/commands/auth.py:640 +#: src/iac_code/commands/auth.py:1117 src/iac_code/commands/auth.py:1443 +#: src/iac_code/commands/auth.py:1551 msgid "Back" msgstr "Atrás" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Keep" msgstr "Conservar" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Re-enter" msgstr "Volver a introducir" -#: src/iac_code/commands/auth.py:741 src/iac_code/commands/auth.py:853 -#: src/iac_code/commands/auth.py:911 src/iac_code/commands/auth.py:919 -#: src/iac_code/commands/auth.py:946 +#: src/iac_code/commands/auth.py:749 src/iac_code/commands/auth.py:863 +#: src/iac_code/commands/auth.py:921 src/iac_code/commands/auth.py:929 +#: src/iac_code/commands/auth.py:956 msgid " (current)" msgstr " (actual)" -#: src/iac_code/commands/auth.py:744 +#: src/iac_code/commands/auth.py:752 msgid "Custom model..." msgstr "Modelo personalizado..." -#: src/iac_code/commands/auth.py:747 +#: src/iac_code/commands/auth.py:755 #, python-brace-format msgid "Select model for {provider}" msgstr "Seleccionar modelo para {provider}" -#: src/iac_code/commands/auth.py:749 +#: src/iac_code/commands/auth.py:757 msgid "Select model" msgstr "Seleccionar modelo" -#: src/iac_code/commands/auth.py:757 +#: src/iac_code/commands/auth.py:765 msgid "Enter custom model name: " msgstr "Introduzca el nombre del modelo personalizado: " -#: src/iac_code/commands/auth.py:783 +#: src/iac_code/commands/auth.py:791 msgid "Error: console not available" msgstr "Error: la consola no está disponible" -#: src/iac_code/commands/auth.py:810 +#: src/iac_code/commands/auth.py:820 msgid "Configure LLM Provider" msgstr "Configurar proveedor LLM" -#: src/iac_code/commands/auth.py:811 +#: src/iac_code/commands/auth.py:821 msgid "Configure IaC Cloud Service" msgstr "Configurar servicio cloud IaC" -#: src/iac_code/commands/auth.py:813 +#: src/iac_code/commands/auth.py:823 msgid "Select configuration type" msgstr "Seleccionar tipo de configuración" -#: src/iac_code/commands/auth.py:815 src/iac_code/commands/auth.py:971 -#: src/iac_code/commands/auth.py:987 src/iac_code/commands/auth.py:1066 -#: src/iac_code/commands/auth.py:1258 src/iac_code/commands/auth.py:1299 +#: src/iac_code/commands/auth.py:825 src/iac_code/commands/auth.py:981 +#: src/iac_code/commands/auth.py:997 src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1490 src/iac_code/commands/auth.py:1531 msgid "Auth cancelled" msgstr "Autenticación cancelada" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:951 -#: src/iac_code/commands/auth.py:1041 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:961 +#: src/iac_code/commands/auth.py:1051 #, python-brace-format msgid "Select provider — {group}" msgstr "Seleccionar proveedor — {group}" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:909 -#: src/iac_code/commands/auth.py:1026 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:919 +#: src/iac_code/commands/auth.py:1036 msgid "Third-party" msgstr "Terceros" -#: src/iac_code/commands/auth.py:867 +#: src/iac_code/commands/auth.py:877 #, python-brace-format msgid "{status}: {provider}" msgstr "{status}: {provider}" -#: src/iac_code/commands/auth.py:868 src/iac_code/commands/auth.py:1019 +#: src/iac_code/commands/auth.py:878 src/iac_code/commands/auth.py:1029 msgid "Configured" msgstr "Configurado" -#: src/iac_code/commands/auth.py:923 +#: src/iac_code/commands/auth.py:933 msgid "Select provider" msgstr "Seleccionar proveedor" -#: src/iac_code/commands/auth.py:964 +#: src/iac_code/commands/auth.py:974 #, python-brace-format msgid "Configure {provider}" msgstr "Configurar {provider}" -#: src/iac_code/commands/auth.py:980 +#: src/iac_code/commands/auth.py:990 #, python-brace-format msgid "Enter API key for {provider}" msgstr "Introduzca la API key para {provider}" -#: src/iac_code/commands/auth.py:1018 +#: src/iac_code/commands/auth.py:1028 #, python-brace-format msgid "{status}: {provider} / {model}" msgstr "{status}: {provider} / {model}" -#: src/iac_code/commands/auth.py:1027 src/iac_code/commands/auth.py:1048 +#: src/iac_code/commands/auth.py:1037 src/iac_code/commands/auth.py:1058 msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:1028 src/iac_code/providers/registry.py:426 +#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:427 msgid "ZhiPu AI" msgstr "ZhiPu AI" -#: src/iac_code/commands/auth.py:1029 +#: src/iac_code/commands/auth.py:1039 msgid "Kimi" msgstr "Kimi" -#: src/iac_code/commands/auth.py:1030 +#: src/iac_code/commands/auth.py:1040 msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:1031 src/iac_code/providers/registry.py:428 +#: src/iac_code/commands/auth.py:1041 src/iac_code/providers/registry.py:429 msgid "Volcengine" msgstr "Volcengine" -#: src/iac_code/commands/auth.py:1032 +#: src/iac_code/commands/auth.py:1042 msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:1033 src/iac_code/providers/registry.py:419 +#: src/iac_code/commands/auth.py:1043 src/iac_code/providers/registry.py:420 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:1034 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:1044 src/iac_code/providers/registry.py:418 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:1035 src/iac_code/providers/registry.py:418 +#: src/iac_code/commands/auth.py:1045 src/iac_code/providers/registry.py:419 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:1036 src/iac_code/providers/registry.py:421 +#: src/iac_code/commands/auth.py:1046 src/iac_code/providers/registry.py:422 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:1037 src/iac_code/providers/registry.py:434 +#: src/iac_code/commands/auth.py:1047 src/iac_code/providers/registry.py:435 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:433 +#: src/iac_code/commands/auth.py:1048 src/iac_code/providers/registry.py:434 msgid "OpenRouter" msgstr "OpenRouter" -#: src/iac_code/commands/auth.py:1039 +#: src/iac_code/commands/auth.py:1049 msgid "Local" msgstr "Local" -#: src/iac_code/commands/auth.py:1040 +#: src/iac_code/commands/auth.py:1050 msgid "Compatible" msgstr "Compatible" -#: src/iac_code/commands/auth.py:1057 +#: src/iac_code/commands/auth.py:1067 msgid "Select Cloud Provider" msgstr "Seleccionar proveedor de cloud" -#: src/iac_code/commands/auth.py:1073 +#: src/iac_code/commands/auth.py:1083 msgid "Credential" msgstr "Credencial" -#: src/iac_code/commands/auth.py:1074 src/iac_code/commands/auth.py:1190 -#: src/iac_code/commands/auth.py:1295 src/iac_code/ui/renderer.py:455 +#: src/iac_code/commands/auth.py:1084 src/iac_code/commands/auth.py:1199 +#: src/iac_code/commands/auth.py:1527 src/iac_code/commands/status.py:36 +#: src/iac_code/ui/renderer.py:455 msgid "Region" msgstr "Región" -#: src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1086 msgid "Configure Alibaba Cloud" msgstr "Configurar Alibaba Cloud" -#: src/iac_code/commands/auth.py:1178 +#: src/iac_code/commands/auth.py:1188 msgid "Current configuration" msgstr "Configuración actual" -#: src/iac_code/commands/auth.py:1180 +#: src/iac_code/commands/auth.py:1190 msgid "Mode" msgstr "Modo" -#: src/iac_code/commands/auth.py:1187 +#: src/iac_code/commands/auth.py:1196 msgid "(not set)" msgstr "(sin definir)" -#: src/iac_code/commands/auth.py:1204 +#: src/iac_code/commands/auth.py:1205 +msgid "AccessKey" +msgstr "AccessKey" + +#: src/iac_code/commands/auth.py:1207 src/iac_code/commands/auth.py:1219 +msgid "STS Token" +msgstr "Token STS" + +#: src/iac_code/commands/auth.py:1209 +msgid "RAM Role" +msgstr "Rol RAM" + +#: src/iac_code/commands/auth.py:1211 +msgid "OAuth Login (Browser)" +msgstr "Inicio de sesión OAuth (navegador)" + +#: src/iac_code/commands/auth.py:1217 +msgid "AccessKey ID" +msgstr "ID de AccessKey" + +#: src/iac_code/commands/auth.py:1218 +msgid "AccessKey Secret" +msgstr "Secreto de AccessKey" + +#: src/iac_code/commands/auth.py:1220 +msgid "RAM Role ARN" +msgstr "ARN del rol RAM" + +#: src/iac_code/commands/auth.py:1221 +msgid "Session Name" +msgstr "Nombre de sesión" + +#: src/iac_code/commands/auth.py:1222 +msgid "OAuth Site Type" +msgstr "Tipo de sitio OAuth" + +#: src/iac_code/commands/auth.py:1223 +msgid "OAuth Access Token" +msgstr "Token de acceso OAuth" + +#: src/iac_code/commands/auth.py:1224 +msgid "OAuth Refresh Token" +msgstr "Token de actualización OAuth" + +#: src/iac_code/commands/auth.py:1225 +msgid "OAuth Access Token Expire" +msgstr "Expiración del token de acceso OAuth" + +#: src/iac_code/commands/auth.py:1226 +msgid "OAuth Refresh Token Expire" +msgstr "Expiración del token de actualización OAuth" + +#: src/iac_code/commands/auth.py:1227 +msgid "STS Expiration" +msgstr "Expiración STS" + +#: src/iac_code/commands/auth.py:1384 +msgid "China" +msgstr "China" + +#: src/iac_code/commands/auth.py:1385 +msgid "International" +msgstr "Internacional" + +#: src/iac_code/commands/auth.py:1387 +msgid "Choose site type" +msgstr "Elegir tipo de sitio" + +#: src/iac_code/commands/auth.py:1402 +#, python-brace-format +msgid "Alibaba Cloud OAuth login failed: {error}" +msgstr "Error de inicio de sesión OAuth de Alibaba Cloud: {error}" + +#: src/iac_code/commands/auth.py:1418 +msgid "Configured: Alibaba Cloud OAuth credentials saved" +msgstr "Configurado: credenciales OAuth de Alibaba Cloud guardadas" + +#: src/iac_code/commands/auth.py:1430 msgid "Configure Alibaba Cloud credentials" msgstr "Configurar credenciales de Alibaba Cloud" -#: src/iac_code/commands/auth.py:1217 +#: src/iac_code/commands/auth.py:1443 msgid "Reconfigure credential" msgstr "Reconfigurar la credencial" -#: src/iac_code/commands/auth.py:1230 +#: src/iac_code/commands/auth.py:1456 msgid "Select credential type" msgstr "Seleccionar tipo de credencial" -#: src/iac_code/commands/auth.py:1280 +#: src/iac_code/commands/auth.py:1512 msgid "Configured: Alibaba Cloud credentials saved to ~/.iac-code" msgstr "Configurado: credenciales de Alibaba Cloud guardadas en ~/.iac-code" -#: src/iac_code/commands/auth.py:1287 +#: src/iac_code/commands/auth.py:1519 msgid "Configure Alibaba Cloud region" msgstr "Configurar la región de Alibaba Cloud" -#: src/iac_code/commands/auth.py:1313 +#: src/iac_code/commands/auth.py:1545 msgid "Configured: Alibaba Cloud region saved to ~/.iac-code" msgstr "Configurado: región de Alibaba Cloud guardada en ~/.iac-code" @@ -968,6 +1103,36 @@ msgstr "Mostrar sugerencias de comandos" msgid "Exit" msgstr "Salir" +#: src/iac_code/commands/memory.py:10 +msgid "Usage: /memory [|search |delete |help]" +msgstr "Uso: /memory [|search |delete |help]" + +#: src/iac_code/commands/memory.py:40 +msgid "Saved memories:" +msgstr "Memorias guardadas:" + +#: src/iac_code/commands/memory.py:40 +msgid "No memories saved yet." +msgstr "Todavía no hay memorias guardadas." + +#: src/iac_code/commands/memory.py:51 +msgid "Matching memories:" +msgstr "Memorias coincidentes:" + +#: src/iac_code/commands/memory.py:51 +msgid "No matching memories." +msgstr "No hay memorias coincidentes." + +#: src/iac_code/commands/memory.py:60 src/iac_code/commands/memory.py:75 +#, python-brace-format +msgid "Memory '{name}' not found." +msgstr "No se encontró la memoria '{name}'." + +#: src/iac_code/commands/memory.py:64 +#, python-brace-format +msgid "Memory '{name}' deleted." +msgstr "Memoria '{name}' eliminada." + #: src/iac_code/commands/model.py:57 #, python-brace-format msgid "" @@ -992,25 +1157,130 @@ msgstr "Modelo actual: {model}" msgid "Kept model as {model}" msgstr "Se mantiene el modelo como {model}" -#: src/iac_code/commands/resume.py:21 +#: src/iac_code/commands/rename.py:16 src/iac_code/commands/rename.py:28 +msgid "Rename is only available in interactive mode." +msgstr "El cambio de nombre solo está disponible en modo interactivo." + +#: src/iac_code/commands/rename.py:31 +msgid "Rename cancelled" +msgstr "Cambio de nombre cancelado" + +#: src/iac_code/commands/resume.py:22 msgid "Resume is only available in interactive mode." msgstr "Reanudar solo está disponible en modo interactivo." -#: src/iac_code/commands/resume.py:27 +#: src/iac_code/commands/resume.py:28 msgid "Resume is unavailable: session index not initialised." msgstr "" "Resume is unavailable: session index not initialised.Reanudar no está " "disponible: el índice de sesiones no se ha inicializado." -#: src/iac_code/commands/resume.py:32 +#: src/iac_code/commands/resume.py:33 src/iac_code/commands/resume.py:36 #, python-brace-format msgid "Session not found: {arg}" msgstr "Sesión no encontrada: {arg}" -#: src/iac_code/commands/resume.py:47 +#: src/iac_code/commands/resume.py:52 src/iac_code/commands/resume.py:68 msgid "Resume cancelled" msgstr "Reanudación cancelada" +#: src/iac_code/commands/resume.py:55 +#, python-brace-format +msgid "Unable to resolve session: {arg}" +msgstr "No se pudo resolver la sesión: {arg}" + +#: src/iac_code/commands/skills.py:14 +msgid "Skills management is only available in interactive mode." +msgstr "La gestión de habilidades solo está disponible en modo interactivo." + +#: src/iac_code/commands/skills.py:25 +msgid "Skills update cancelled" +msgstr "Actualización de habilidades cancelada" + +#: src/iac_code/commands/skills.py:29 +msgid "Skills updated" +msgstr "Habilidades actualizadas" + +#: src/iac_code/commands/status.py:19 +msgid "Status command requires a context." +msgstr "El comando status requiere un contexto." + +#: src/iac_code/commands/status.py:22 +msgid "Status command requires a REPL context." +msgstr "El comando status requiere un contexto REPL." + +#: src/iac_code/commands/status.py:24 +msgid "Status is only available in interactive mode." +msgstr "status solo está disponible en modo interactivo." + +#: src/iac_code/commands/status.py:33 src/iac_code/ui/banner.py:136 +#: src/iac_code/ui/banner.py:138 +msgid "Session" +msgstr "Sesión" + +#: src/iac_code/commands/status.py:34 +msgid "Provider" +msgstr "Proveedor" + +#: src/iac_code/commands/status.py:34 src/iac_code/commands/status.py:35 +#: src/iac_code/commands/status.py:36 +msgid "not configured" +msgstr "no configurado" + +#: src/iac_code/commands/status.py:35 +msgid "Model" +msgstr "Modelo" + +#: src/iac_code/commands/status.py:37 +msgid "CWD" +msgstr "Directorio actual" + +#: src/iac_code/commands/status.py:41 +msgid "API Token Usage (recorded):" +msgstr "Uso de tokens de API (registrado):" + +#: src/iac_code/commands/status.py:44 +msgid "Input" +msgstr "Entrada" + +#: src/iac_code/commands/status.py:45 +msgid "Output" +msgstr "Salida" + +#: src/iac_code/commands/status.py:46 +msgid "Cache read" +msgstr "Lectura de caché" + +#: src/iac_code/commands/status.py:47 +msgid "Total" +msgstr "Total" + +#: src/iac_code/commands/status.py:50 +msgid "No recorded API usage for this session yet." +msgstr "Aún no hay uso de API registrado para esta sesión." + +#: src/iac_code/commands/status.py:54 +msgid "Turns" +msgstr "Turnos" + +#: src/iac_code/commands/status.py:55 +msgid "Context" +msgstr "Contexto" + +#: src/iac_code/commands/status.py:57 +msgid "Session Status" +msgstr "Estado de la sesión" + +#: src/iac_code/commands/status.py:73 +#, python-brace-format +msgid "{session_id} (resumed)" +msgstr "{session_id} (reanudada)" + +#: src/iac_code/commands/status.py:81 +#, python-brace-format +msgid "{percent} used ({total} / {window})" +msgstr "{percent} usado ({total} / {window})" + # Typer/Click built-in strings #: src/iac_code/i18n/__init__.py:51 msgid "Options" @@ -1095,79 +1365,79 @@ msgstr "" " correcta (actual: {base_url}). Muchos endpoints compatibles con OpenAI " "requieren el sufijo /v1 (p. ej., {base_url}/v1)." -#: src/iac_code/providers/registry.py:415 +#: src/iac_code/providers/registry.py:416 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "OpenAPI Compatible" msgstr "Compatible con OpenAPI" -#: src/iac_code/providers/registry.py:422 +#: src/iac_code/providers/registry.py:423 msgid "Kimi (China)" msgstr "Kimi (China)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "Kimi (International)" msgstr "Kimi (Internacional)" -#: src/iac_code/providers/registry.py:424 +#: src/iac_code/providers/registry.py:425 msgid "MiniMax (China)" msgstr "MiniMax (China)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "MiniMax (International)" msgstr "MiniMax (Internacional)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (Internacional)" -#: src/iac_code/providers/registry.py:429 +#: src/iac_code/providers/registry.py:430 msgid "SiliconFlow (China)" msgstr "SiliconFlow (China)" -#: src/iac_code/providers/registry.py:430 +#: src/iac_code/providers/registry.py:431 msgid "SiliconFlow (International)" msgstr "SiliconFlow (Internacional)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "Ollama (Local)" msgstr "Ollama (Local)" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "LM Studio (Local)" msgstr "LM Studio (Local)" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:438 +#: src/iac_code/providers/registry.py:439 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:439 +#: src/iac_code/providers/registry.py:440 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:440 +#: src/iac_code/providers/registry.py:441 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:441 +#: src/iac_code/providers/registry.py:442 msgid "Anthropic Compatible" msgstr "Compatible con Anthropic" @@ -1186,6 +1456,16 @@ msgstr "" "Solución: cambie a un proveedor soportado en QwenPaw, o desactive el modo" " QwenPaw (elimine 'llm_source: qwenpaw' de settings.yml)." +#: src/iac_code/services/session_metadata.py:52 +#, python-brace-format +msgid "Session name must match {pattern}" +msgstr "El nombre de sesión debe coincidir con {pattern}" + +#: src/iac_code/services/session_storage.py:241 +#, python-brace-format +msgid "Session name already exists in this project: {name}" +msgstr "El nombre de sesión ya existe en este proyecto: {name}" + #: src/iac_code/services/permissions/loader.py:50 #, python-brace-format msgid "Invalid --permission-mode {!r}. Valid values: {}" @@ -1197,15 +1477,142 @@ msgstr "Modo --permission-mode no válido: {!r}. Valores válidos: {}" msgid "Allow {}?" msgstr "¿Permitir {}?" -#: src/iac_code/skills/skill_tool.py:130 +#: src/iac_code/services/providers/aliyun.py:144 +msgid "Alibaba Cloud OAuth site is missing." +msgstr "Falta el sitio OAuth de Alibaba Cloud." + +#: src/iac_code/services/providers/aliyun.py:150 +msgid "Alibaba Cloud OAuth refresh token is missing." +msgstr "Falta el token de actualización OAuth de Alibaba Cloud." + +#: src/iac_code/services/providers/aliyun.py:158 +msgid "Alibaba Cloud OAuth access token is missing." +msgstr "Falta el token de acceso OAuth de Alibaba Cloud." + +#: src/iac_code/services/providers/aliyun_oauth.py:83 +msgid "Run /auth and choose OAuth Login (Browser)." +msgstr "Ejecute /auth y elija Inicio de sesión OAuth (navegador)." + +#: src/iac_code/services/providers/aliyun_oauth.py:106 +#, python-brace-format +msgid "Unknown Aliyun OAuth site: {site_type}" +msgstr "Sitio OAuth de Aliyun desconocido: {site_type}" + +#: src/iac_code/services/providers/aliyun_oauth.py:164 +msgid "Not found" +msgstr "No encontrado" + +#: src/iac_code/services/providers/aliyun_oauth.py:170 +msgid "invalid state" +msgstr "estado inválido" + +#: src/iac_code/services/providers/aliyun_oauth.py:171 +msgid "Invalid state" +msgstr "Estado inválido" + +#: src/iac_code/services/providers/aliyun_oauth.py:176 +msgid "code not found" +msgstr "código de autorización no encontrado" + +#: src/iac_code/services/providers/aliyun_oauth.py:177 +msgid "Authorization code not found" +msgstr "Código de autorización no encontrado" + +#: src/iac_code/services/providers/aliyun_oauth.py:181 +msgid "Authorization successful. You can close this window." +msgstr "Autorización correcta. Puede cerrar esta ventana." + +#: src/iac_code/services/providers/aliyun_oauth.py:212 +#, python-brace-format +msgid "No available callback port in range {start}-{end}" +msgstr "No hay ningún puerto de callback disponible en el intervalo {start}-{end}" + +#: src/iac_code/services/providers/aliyun_oauth.py:227 +msgid "OAuth login cancelled." +msgstr "Inicio de sesión OAuth cancelado." + +#: src/iac_code/services/providers/aliyun_oauth.py:278 +msgid "Open in your browser:" +msgstr "Abrir en el navegador:" + +#: src/iac_code/services/providers/aliyun_oauth.py:299 +msgid "Waiting for browser authorization" +msgstr "Esperando la autorización del navegador" + +#: src/iac_code/services/providers/aliyun_oauth.py:300 +msgid "" +"1. The browser may show official-cli; this is the Alibaba Cloud official " +"CLI OAuth application." +msgstr "" +"1. El navegador puede mostrar official-cli; es la aplicación OAuth " +"oficial de la CLI de Alibaba Cloud." + +#: src/iac_code/services/providers/aliyun_oauth.py:302 +msgid "" +"2. If assignment is required, assign the RAM user or RAM role that is " +"signed in. User groups are not supported." +msgstr "" +"2. Si se requiere asignación, asigne el usuario RAM o el rol RAM que " +"tiene la sesión iniciada. Los grupos de usuarios no son compatibles." + +#: src/iac_code/services/providers/aliyun_oauth.py:306 +msgid "" +"3. After assignment, close the old authorization page and run OAuth Login" +" (Browser) again. If it still fails, sign out of Alibaba Cloud and sign " +"in again." +msgstr "" +"3. Después de la asignación, cierre la página de autorización antigua y " +"ejecute de nuevo Inicio de sesión OAuth (navegador). Si sigue fallando, " +"cierre sesión en Alibaba Cloud e iníciela de nuevo." + +#: src/iac_code/services/providers/aliyun_oauth.py:310 +msgid "" +"4. STS credentials refresh when possible until Alibaba Cloud expires " +"them. If refresh fails, run /auth again." +msgstr "" +"4. Las credenciales STS se actualizan cuando es posible hasta que Alibaba" +" Cloud las caduque. Si la actualización falla, ejecute /auth de nuevo." + +#: src/iac_code/services/providers/aliyun_oauth.py:313 +msgid "Press Esc to cancel while waiting." +msgstr "Pulse Esc para cancelar la espera." + +#: src/iac_code/services/providers/aliyun_oauth.py:321 +msgid "" +"Timed out waiting for OAuth callback. If Alibaba Cloud asked you to " +"assign the official-cli application, assign it to the exact RAM user or " +"RAM role currently signed in. User groups are not supported. Then close " +"the old authorization page, sign out of Alibaba Cloud and sign in again " +"if needed, and run /auth to choose OAuth Login (Browser) again." +msgstr "" +"Se agotó el tiempo esperando el callback OAuth. Si Alibaba Cloud le pidió" +" asignar la aplicación official-cli, asígnela al usuario RAM o al rol RAM" +" exacto que tiene la sesión iniciada. Los grupos de usuarios no son " +"compatibles. Después cierre la página de autorización antigua, cierre la " +"sesión de Alibaba Cloud e iníciela de nuevo si es necesario, y ejecute " +"/auth para elegir de nuevo Inicio de sesión OAuth (navegador)." + +#: src/iac_code/skills/skill_tool.py:85 src/iac_code/ui/repl.py:842 +#, python-brace-format +msgid "Skill '{name}' is disabled. Run /skills to enable it." +msgstr "" +"La habilidad '{name}' está deshabilitada. Ejecute /skills para " +"habilitarla." + +#: src/iac_code/skills/skill_tool.py:137 #, python-brace-format msgid "Skill '{name}' loaded (inline)." msgstr "Skill '{name}' cargado (en línea)." -#: src/iac_code/skills/skill_tool.py:214 +#: src/iac_code/skills/skill_tool.py:221 msgid "Skill" msgstr "Skill" +#: src/iac_code/skills/skill_tool.py:245 +#, python-brace-format +msgid "Skill disabled: {name}" +msgstr "Habilidad deshabilitada: {name}" + #: src/iac_code/skills/bundled/simplify.py:25 msgid "" "Review changed code for reuse, quality, and efficiency, then fix issues " @@ -1479,7 +1886,7 @@ msgstr "CloudAPI" msgid "Calling {action}..." msgstr "Llamando a {action}..." -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:390 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:400 #: src/iac_code/tools/cloud/base_api.py:123 msgid "Call succeeded" msgstr "Llamada correcta" @@ -1639,11 +2046,11 @@ msgstr "Importación completada" msgid "IMPORT_FAILED" msgstr "Importación fallida" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:171 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:172 msgid "Aliyun API" msgstr "Aliyun API" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:389 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:399 #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" msgstr "Llamada correcta (RequestId: {request_id})" @@ -1811,23 +2218,19 @@ msgstr "Notas de la versión" msgid "Run {} to update." msgstr "Ejecuta {} para actualizar." -#: src/iac_code/ui/banner.py:105 +#: src/iac_code/ui/banner.py:110 msgid "Your AI-powered Infrastructure as Code assistant" msgstr "Su asistente de infraestructura como código con IA" -#: src/iac_code/ui/banner.py:131 +#: src/iac_code/ui/banner.py:144 msgid "Welcome back" msgstr "Bienvenido de nuevo" -#: src/iac_code/ui/banner.py:138 -msgid "Session" -msgstr "Sesión" - -#: src/iac_code/ui/banner.py:148 +#: src/iac_code/ui/banner.py:161 msgid "Debug mode" msgstr "Modo depuración" -#: src/iac_code/ui/banner.py:149 +#: src/iac_code/ui/banner.py:162 msgid "Log file" msgstr "Archivo de registro" @@ -1924,78 +2327,70 @@ msgstr "No, siempre denegar \"{rule}\" (esta sesión)" msgid "No, always reject this tool" msgstr "No, rechazar siempre esta herramienta" -#: src/iac_code/ui/repl.py:370 +#: src/iac_code/ui/repl.py:424 msgid "Press Ctrl+C again to exit." msgstr "Pulse Ctrl+C de nuevo para salir." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:449 msgid "Interrupted." msgstr "Interrumpido." -#: src/iac_code/ui/repl.py:432 -msgid "Goodbye!" -msgstr "¡Hasta luego!" - -#: src/iac_code/ui/repl.py:433 -msgid "Resume this session with:" -msgstr "Para reanudar esta sesión, ejecute:" - -#: src/iac_code/ui/repl.py:458 +#: src/iac_code/ui/repl.py:508 msgid "Update now" msgstr "Actualizar ahora" -#: src/iac_code/ui/repl.py:460 +#: src/iac_code/ui/repl.py:510 msgid "Run the shown update command and exit when it succeeds." msgstr "" "Ejecuta el comando de actualización mostrado y sale cuando finalice " "correctamente." -#: src/iac_code/ui/repl.py:463 +#: src/iac_code/ui/repl.py:513 msgid "Skip" msgstr "Omitir" -#: src/iac_code/ui/repl.py:465 +#: src/iac_code/ui/repl.py:515 msgid "Continue with the current version for this session." msgstr "Continuar con la versión actual durante esta sesión." -#: src/iac_code/ui/repl.py:468 +#: src/iac_code/ui/repl.py:518 msgid "Skip until next version" msgstr "Omitir hasta la siguiente versión" -#: src/iac_code/ui/repl.py:470 +#: src/iac_code/ui/repl.py:520 msgid "Hide this update until a newer version is available." msgstr "" "Ocultar esta actualización hasta que haya una versión más nueva " "disponible." -#: src/iac_code/ui/repl.py:489 src/iac_code/ui/repl.py:501 +#: src/iac_code/ui/repl.py:539 src/iac_code/ui/repl.py:551 msgid "Update command failed. Continuing with the current version." msgstr "El comando de actualización falló. Se continuará con la versión actual." -#: src/iac_code/ui/repl.py:494 +#: src/iac_code/ui/repl.py:544 msgid "Update completed. Restart iac-code to continue." msgstr "Actualización completada. Reinicia iac-code para continuar." -#: src/iac_code/ui/repl.py:532 +#: src/iac_code/ui/repl.py:582 msgid "No image in clipboard." msgstr "No hay ninguna imagen en el portapapeles." -#: src/iac_code/ui/repl.py:718 +#: src/iac_code/ui/repl.py:768 msgid "Usage: !" msgstr "Uso: !" -#: src/iac_code/ui/repl.py:723 +#: src/iac_code/ui/repl.py:775 msgid "Shell command support is unavailable." msgstr "La compatibilidad con comandos de shell no está disponible." -#: src/iac_code/ui/repl.py:787 +#: src/iac_code/ui/repl.py:845 #, python-brace-format msgid "Unknown skill: ${name}. Type / to list commands and skills." msgstr "" "Habilidad desconocida: ${name}. Escribe / para listar comandos y " "habilidades." -#: src/iac_code/ui/repl.py:789 +#: src/iac_code/ui/repl.py:847 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" @@ -2003,27 +2398,47 @@ msgstr "" "command: /{name}. Type /help for available commands.Comando desconocido: " "/{name}. Escriba /help para ver los comandos disponibles." -#: src/iac_code/ui/repl.py:794 +#: src/iac_code/ui/repl.py:852 #, python-brace-format msgid "$ only invokes skills. Use /{name} instead." msgstr "$ solo invoca habilidades. Usa /{name} en su lugar." -#: src/iac_code/ui/repl.py:816 src/iac_code/ui/repl.py:861 +#: src/iac_code/ui/repl.py:874 src/iac_code/ui/repl.py:922 #, python-brace-format msgid "Command error: {error}" msgstr "Error de comando: {error}" -#: src/iac_code/ui/repl.py:823 +#: src/iac_code/ui/repl.py:881 #, python-brace-format msgid "Command has no handler: {name}" msgstr "El comando no tiene controlador: {name}" -#: src/iac_code/ui/repl.py:1128 +#: src/iac_code/ui/repl.py:1156 +msgid "Goodbye!" +msgstr "¡Hasta luego!" + +#: src/iac_code/ui/repl.py:1157 +msgid "Resume this session with:" +msgstr "Para reanudar esta sesión, ejecute:" + +#: src/iac_code/ui/repl.py:1160 +msgid "Session ID" +msgstr "ID de sesión" + +#: src/iac_code/ui/repl.py:1210 src/iac_code/ui/repl.py:1214 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Sesión no encontrada: {session_id}" -#: src/iac_code/ui/repl.py:1147 +#: src/iac_code/ui/repl.py:1263 +msgid "Session name: " +msgstr "Nombre de sesión: " + +#: src/iac_code/ui/repl.py:1269 +msgid "Session name cannot be empty." +msgstr "El nombre de sesión no puede estar vacío." + +#: src/iac_code/ui/repl.py:1279 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -2034,19 +2449,23 @@ msgstr "" "Para reanudar, ejecute:\n" " {cmd}" -#: src/iac_code/ui/repl.py:1186 +#: src/iac_code/ui/repl.py:1283 +msgid "Multiple sessions match. Resume one by ID:" +msgstr "Varias sesiones coinciden. Reanuda una por ID:" + +#: src/iac_code/ui/repl.py:1396 msgid "This conversation is from a different directory." msgstr "Esta conversación procede de otro directorio." -#: src/iac_code/ui/repl.py:1188 +#: src/iac_code/ui/repl.py:1398 msgid "To resume, run:" msgstr "Para reanudar, ejecute:" -#: src/iac_code/ui/repl.py:1193 +#: src/iac_code/ui/repl.py:1403 msgid "(Command copied to clipboard)" msgstr "(Comando copiado al portapapeles)" -#: src/iac_code/ui/repl.py:1350 +#: src/iac_code/ui/repl.py:1560 #, python-brace-format msgid "" "Current model {model} does not support image input. Use /model to switch " @@ -2055,12 +2474,12 @@ msgstr "" "El modelo actual {model} no admite entrada de imágenes. Usa /model para " "cambiar a un modelo con capacidad de visión." -#: src/iac_code/ui/repl.py:1359 +#: src/iac_code/ui/repl.py:1569 #, python-brace-format msgid "Image error: {err}" msgstr "Error de imagen: {err}" -#: src/iac_code/ui/repl.py:1376 +#: src/iac_code/ui/repl.py:1586 msgid "" "Failed to persist image to cache; it will only exist in memory for this " "turn." @@ -2156,91 +2575,192 @@ msgstr "Escriba para buscar archivos..." msgid "No matching files" msgstr "No hay archivos coincidentes" -#: src/iac_code/ui/dialogs/resume_picker.py:114 +#: src/iac_code/ui/dialogs/resume_picker.py:116 msgid "Search..." msgstr "Buscar…" -#: src/iac_code/ui/dialogs/resume_picker.py:359 +#: src/iac_code/ui/dialogs/resume_picker.py:374 msgid "Resume Session" msgstr "Reanudar sesión" -#: src/iac_code/ui/dialogs/resume_picker.py:369 +#: src/iac_code/ui/dialogs/resume_picker.py:384 msgid "No sessions found" msgstr "No se encontraron sesiones" -#: src/iac_code/ui/dialogs/resume_picker.py:427 +#: src/iac_code/ui/dialogs/resume_picker.py:444 msgid "show current dir" msgstr "mostrar directorio actual" -#: src/iac_code/ui/dialogs/resume_picker.py:429 +#: src/iac_code/ui/dialogs/resume_picker.py:446 msgid "show all projects" msgstr "mostrar todos los proyectos" -#: src/iac_code/ui/dialogs/resume_picker.py:432 +#: src/iac_code/ui/dialogs/resume_picker.py:449 msgid "show all branches" msgstr "mostrar todas las ramas" -#: src/iac_code/ui/dialogs/resume_picker.py:434 +#: src/iac_code/ui/dialogs/resume_picker.py:451 msgid "only show current branch" msgstr "mostrar solo la rama actual" -#: src/iac_code/ui/dialogs/resume_picker.py:435 +#: src/iac_code/ui/dialogs/resume_picker.py:452 msgid "preview" msgstr "vista previa" -#: src/iac_code/ui/dialogs/resume_picker.py:436 +#: src/iac_code/ui/dialogs/resume_picker.py:453 msgid "Type to search" msgstr "Escriba para buscar" -#: src/iac_code/ui/dialogs/resume_picker.py:437 +#: src/iac_code/ui/dialogs/resume_picker.py:454 msgid "cancel" msgstr "cancelar" -#: src/iac_code/ui/dialogs/resume_picker.py:552 +#: src/iac_code/ui/dialogs/resume_picker.py:569 #, python-brace-format msgid "{n} more line{s}" msgstr "{n} línea{s} más" -#: src/iac_code/ui/dialogs/resume_picker.py:566 +#: src/iac_code/ui/dialogs/resume_picker.py:583 #, python-brace-format msgid "{n} message{s}" msgstr "{n} mensaje{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:580 +#: src/iac_code/ui/dialogs/resume_picker.py:597 msgid "resume" msgstr "reanudar" -#: src/iac_code/ui/dialogs/resume_picker.py:584 +#: src/iac_code/ui/dialogs/resume_picker.py:601 msgid "back" msgstr "atrás" -#: src/iac_code/ui/dialogs/resume_picker.py:589 +#: src/iac_code/ui/dialogs/resume_picker.py:606 msgid "scroll" msgstr "desplazar" -#: src/iac_code/ui/dialogs/resume_picker.py:608 +#: src/iac_code/ui/dialogs/resume_picker.py:625 msgid "(empty session)" msgstr "(sesión vacía)" -#: src/iac_code/ui/dialogs/resume_picker.py:728 +#: src/iac_code/ui/dialogs/resume_picker.py:745 msgid "just now" msgstr "ahora mismo" -#: src/iac_code/ui/dialogs/resume_picker.py:731 +#: src/iac_code/ui/dialogs/resume_picker.py:748 #, python-brace-format msgid "{n} minute{s} ago" msgstr "hace {n} minuto{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:734 +#: src/iac_code/ui/dialogs/resume_picker.py:751 #, python-brace-format msgid "{n} hour{s} ago" msgstr "hace {n} hora{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:736 +#: src/iac_code/ui/dialogs/resume_picker.py:753 #, python-brace-format msgid "{n} day{s} ago" msgstr "hace {n} día{s}" +#: src/iac_code/ui/dialogs/skills_picker.py:52 +msgid "Search skills..." +msgstr "Buscar habilidades..." + +#: src/iac_code/ui/dialogs/skills_picker.py:159 +msgid "Skills" +msgstr "Habilidades" + +#: src/iac_code/ui/dialogs/skills_picker.py:161 +#, python-brace-format +msgid "{current} of {total}" +msgstr "{current} de {total}" + +#: src/iac_code/ui/dialogs/skills_picker.py:165 +#, python-brace-format +msgid "" +"{count} skills - Space to toggle, Enter to save, Tab to sort, Esc to " +"cancel" +msgstr "" +"{count} habilidades - Espacio para alternar, Enter para guardar, Tab para" +" ordenar, Esc para cancelar" + +#: src/iac_code/ui/dialogs/skills_picker.py:171 +#, python-brace-format +msgid "Sort: {mode}" +msgstr "Ordenar: {mode}" + +#: src/iac_code/ui/dialogs/skills_picker.py:176 +msgid "No skills found" +msgstr "No se encontraron habilidades" + +#: src/iac_code/ui/dialogs/skills_picker.py:245 +msgid "Bundled skills cannot be disabled." +msgstr "Las habilidades integradas no se pueden deshabilitar." + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "on" +msgstr "activada" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "off" +msgstr "desactivada" + +#: src/iac_code/ui/dialogs/skills_picker.py:265 +msgid "locked" +msgstr "bloqueada" + +#: src/iac_code/ui/dialogs/skills_picker.py:268 +msgid "matched description" +msgstr "coincide con la descripción" + +#: src/iac_code/ui/dialogs/skills_picker.py:277 +msgid "source" +msgstr "origen" + +#: src/iac_code/ui/dialogs/skills_picker.py:279 +msgid "size" +msgstr "tamaño" + +#: src/iac_code/ui/dialogs/skills_picker.py:280 +msgid "name" +msgstr "nombre" + +#: src/iac_code/ui/dialogs/skills_picker.py:285 +msgid "bundled" +msgstr "integrada" + +#: src/iac_code/ui/dialogs/skills_picker.py:287 +msgid "project" +msgstr "proyecto" + +#: src/iac_code/ui/dialogs/skills_picker.py:289 +msgid "user" +msgstr "usuario" + +#: src/iac_code/ui/dialogs/skills_picker.py:296 +#, python-brace-format +msgid "~{count}k tokens" +msgstr "~{count}k tokens" + +#: src/iac_code/ui/dialogs/skills_picker.py:297 +#, python-brace-format +msgid "~{count} tokens" +msgstr "~{count} tokens" + +#: src/iac_code/ui/suggestions/command_provider.py:79 +msgid "Search saved memories" +msgstr "Buscar memorias guardadas" + +#: src/iac_code/ui/suggestions/command_provider.py:80 +msgid "Delete a saved memory" +msgstr "Eliminar una memoria guardada" + +#: src/iac_code/ui/suggestions/command_provider.py:81 +msgid "Show memory command help" +msgstr "Mostrar la ayuda del comando memory" + +#: src/iac_code/ui/suggestions/command_provider.py:116 +msgid "Saved memory" +msgstr "Memoria guardada" + #: src/iac_code/utils/platform.py:39 msgid "iac-code on Windows requires Git for Windows." msgstr "iac-code en Windows requiere Git for Windows." @@ -2311,3 +2831,19 @@ msgstr "" #~ msgid " Option 2 - npmmirror (China-friendly mirror):" #~ msgstr " Opción 2 - npmmirror (espejo para China):" +#~ msgid "Cache create" +#~ msgstr "Creación de caché" + +#~ msgid "" +#~ "{count} skills - Space to toggle, " +#~ "Enter to save, / to search, t " +#~ "to sort, Esc to cancel" +#~ msgstr "" +#~ "{count} habilidades - Espacio para " +#~ "alternar, Enter para guardar, / para " +#~ "buscar, t para ordenar, Esc para " +#~ "cancelar" + +#~ msgid "Resume a session by ID" +#~ msgstr "Reanudar una sesión por ID" + diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index cb37d6e..5f8800a 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -4,9 +4,9 @@ # msgid "" msgstr "" -"Project-Id-Version: iac-code 0.3.0\n" +"Project-Id-Version: iac-code 0.4.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-01 17:56+0800\n" +"POT-Creation-Date: 2026-06-03 13:32+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: fr\n" @@ -32,7 +32,22 @@ msgstr "" "Le transport par socket de domaine Unix n'est pas pris en charge sous " "Windows. Utilisez --transport http ou --transport stdio à la place." -#: src/iac_code/acp/slash_registry.py:44 +#: src/iac_code/acp/server.py:413 src/iac_code/acp/server.py:429 +#: src/iac_code/acp/server.py:456 +msgid "Session not found" +msgstr "Session introuvable" + +#: src/iac_code/acp/server.py:416 +#, python-brace-format +msgid "Session name is ambiguous. Candidates: {candidates}" +msgstr "Le nom de session est ambigu. Candidats : {candidates}" + +#: src/iac_code/acp/server.py:434 src/iac_code/acp/server.py:708 +#, python-brace-format +msgid "Session belongs to another project. Run: {hint}" +msgstr "La session appartient à un autre projet. Exécutez : {hint}" + +#: src/iac_code/acp/slash_registry.py:46 #, python-brace-format msgid "" "Command '/{cmd_name}' is not supported over ACP. Supported commands: " @@ -41,21 +56,21 @@ msgstr "" "La commande « /{cmd_name} » n’est pas prise en charge via ACP. Commandes " "prises en charge : {supported}" -#: src/iac_code/acp/slash_registry.py:56 +#: src/iac_code/acp/slash_registry.py:62 #, python-brace-format msgid "Command '/{cmd_name}' handler not implemented." msgstr "Gestionnaire de la commande « /{cmd_name} » non implémenté." -#: src/iac_code/acp/slash_registry.py:68 +#: src/iac_code/acp/slash_registry.py:74 #, python-brace-format msgid "Compaction failed: {error}" msgstr "Échec de la compaction : {error}" -#: src/iac_code/acp/slash_registry.py:71 src/iac_code/commands/compact.py:24 +#: src/iac_code/acp/slash_registry.py:77 src/iac_code/commands/compact.py:24 msgid "Nothing to compact: conversation is empty." msgstr "Rien à compacter : la conversation est vide." -#: src/iac_code/acp/slash_registry.py:74 src/iac_code/commands/compact.py:27 +#: src/iac_code/acp/slash_registry.py:80 src/iac_code/commands/compact.py:27 #, python-brace-format msgid "" "Conversation too short to compact: all messages are within the recent " @@ -64,11 +79,11 @@ msgstr "" "Conversation trop courte pour être compactée : tous les messages se " "situent dans la fenêtre de conservation des {turns} derniers tours." -#: src/iac_code/acp/slash_registry.py:78 src/iac_code/commands/compact.py:30 +#: src/iac_code/acp/slash_registry.py:84 src/iac_code/commands/compact.py:30 msgid "Compaction failed. See logs for details." msgstr "Échec de la compaction. Consultez les journaux pour plus de détails." -#: src/iac_code/acp/slash_registry.py:83 +#: src/iac_code/acp/slash_registry.py:89 #, python-brace-format msgid "" "Context compacted: {original} → {compacted} tokens ({percent} reduction)." @@ -77,39 +92,61 @@ msgstr "" "Contexte compacté : {original} → {compacted} tokens (réduction " "{percent}). Utilisation du contexte : {usage}" -#: src/iac_code/acp/slash_registry.py:97 +#: src/iac_code/acp/slash_registry.py:103 #, python-brace-format msgid "Clear failed: {error}" msgstr "Échec de l’effacement : {error}" -#: src/iac_code/acp/slash_registry.py:98 +#: src/iac_code/acp/slash_registry.py:104 msgid "Conversation history cleared." msgstr "Historique de conversation effacé." -#: src/iac_code/acp/slash_registry.py:114 src/iac_code/commands/debug.py:34 +#: src/iac_code/acp/slash_registry.py:120 src/iac_code/commands/debug.py:34 #, python-brace-format msgid "Debug logging is on. Log file: {path}" msgstr "Journalisation debug activée. Fichier de journal : {path}" -#: src/iac_code/acp/slash_registry.py:115 src/iac_code/commands/debug.py:35 +#: src/iac_code/acp/slash_registry.py:121 src/iac_code/commands/debug.py:35 msgid "Debug logging is off." msgstr "Journalisation debug désactivée." -#: src/iac_code/acp/slash_registry.py:119 src/iac_code/commands/debug.py:39 +#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:39 #, python-brace-format msgid "Debug logging enabled. Log file: {path}" msgstr "Journalisation debug activée. Fichier de journal : {path}" -#: src/iac_code/acp/slash_registry.py:123 src/iac_code/commands/debug.py:43 +#: src/iac_code/acp/slash_registry.py:129 src/iac_code/commands/debug.py:43 msgid "Debug logging disabled." msgstr "Journalisation debug désactivée." -#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:45 +#: src/iac_code/acp/slash_registry.py:131 src/iac_code/commands/debug.py:45 msgid "Usage: /debug [on|off]" msgstr "Utilisation : /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:404 src/iac_code/agent/agent_loop.py:419 -#: src/iac_code/ui/repl.py:757 src/iac_code/ui/repl.py:771 +#: src/iac_code/acp/slash_registry.py:136 src/iac_code/commands/memory.py:84 +msgid "Memory manager is unavailable." +msgstr "Le gestionnaire de mémoire est indisponible." + +#: src/iac_code/acp/slash_registry.py:146 src/iac_code/commands/rename.py:21 +msgid "Usage: /rename " +msgstr "Utilisation : /rename " + +#: src/iac_code/acp/slash_registry.py:152 +msgid "Rename is only available after a session is created." +msgstr "Le renommage n'est disponible qu'après la création d'une session." + +#: src/iac_code/acp/slash_registry.py:163 src/iac_code/commands/rename.py:42 +#, python-brace-format +msgid "Session is already named {name}" +msgstr "La session porte déjà le nom {name}" + +#: src/iac_code/acp/slash_registry.py:164 src/iac_code/commands/rename.py:43 +#, python-brace-format +msgid "Renamed session to {name}" +msgstr "Session renommée en {name}" + +#: src/iac_code/agent/agent_loop.py:413 src/iac_code/agent/agent_loop.py:428 +#: src/iac_code/ui/repl.py:813 src/iac_code/ui/repl.py:827 msgid "Permission denied." msgstr "Permission refusée." @@ -280,8 +317,8 @@ msgid "Show version and exit" msgstr "Afficher la version et quitter" #: src/iac_code/cli/main.py:89 -msgid "Resume a session by ID" -msgstr "Reprendre une session par identifiant" +msgid "Resume a session by ID or name" +msgstr "Reprendre une session par ID ou nom" #: src/iac_code/cli/main.py:90 msgid "Resume the most recent session" @@ -607,273 +644,371 @@ msgstr "Répertoire pour les routes A2A persistées" msgid "Save the provided routes as a route snapshot" msgstr "Enregistre les routes fournies sous forme d'instantané de routes" -#: src/iac_code/commands/__init__.py:22 +#: src/iac_code/commands/__init__.py:26 msgid "Show available commands" msgstr "Afficher les commandes disponibles" -#: src/iac_code/commands/__init__.py:31 +#: src/iac_code/commands/__init__.py:35 msgid "Clear conversation history" msgstr "Effacer l’historique de conversation" -#: src/iac_code/commands/__init__.py:39 +#: src/iac_code/commands/__init__.py:43 msgid "Show or switch model" msgstr "Afficher ou changer de modèle" -#: src/iac_code/commands/__init__.py:48 +#: src/iac_code/commands/__init__.py:52 msgid "Show or switch thinking effort" msgstr "Afficher ou modifier l’effort de raisonnement" -#: src/iac_code/commands/__init__.py:57 +#: src/iac_code/commands/__init__.py:61 msgid "Compact conversation context" msgstr "Compacter le contexte de conversation" -#: src/iac_code/commands/__init__.py:59 +#: src/iac_code/commands/__init__.py:63 msgid "Compacting conversation" msgstr "Compactage de la conversation" -#: src/iac_code/commands/__init__.py:66 +#: src/iac_code/commands/__init__.py:70 msgid "Exit the application" msgstr "Quitter l’application" -#: src/iac_code/commands/__init__.py:75 +#: src/iac_code/commands/__init__.py:79 msgid "Authenticate with LLM provider" msgstr "S’authentifier auprès du fournisseur LLM" -#: src/iac_code/commands/__init__.py:84 +#: src/iac_code/commands/__init__.py:88 msgid "Toggle debug logging" msgstr "Activer ou désactiver la journalisation debug" -#: src/iac_code/commands/__init__.py:93 +#: src/iac_code/commands/__init__.py:97 +msgid "View and manage persistent memories" +msgstr "Afficher et gérer les mémoires persistantes" + +#: src/iac_code/commands/__init__.py:99 +msgid "[|search |delete |help]" +msgstr "[|search |delete |help]" + +#: src/iac_code/commands/__init__.py:106 msgid "Resume a previous session" msgstr "Reprendre une session précédente" -#: src/iac_code/commands/__init__.py:95 +#: src/iac_code/commands/__init__.py:108 msgid "[conversation id or search term]" msgstr "[identifiant de conversation ou terme de recherche]" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:1107 +#: src/iac_code/commands/__init__.py:115 +msgid "Rename the current session" +msgstr "Renommer la session actuelle" + +#: src/iac_code/commands/__init__.py:124 +msgid "Manage skills" +msgstr "Gérer les compétences" + +#: src/iac_code/commands/__init__.py:132 +msgid "Show current session status" +msgstr "Afficher l’état actuel de la session" + +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:1117 #: src/iac_code/ui/core/prompt_input.py:557 msgid "Navigate" msgstr "Naviguer" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:530 -#: src/iac_code/commands/auth.py:562 src/iac_code/commands/auth.py:569 -#: src/iac_code/commands/auth.py:604 src/iac_code/commands/auth.py:611 -#: src/iac_code/commands/auth.py:632 src/iac_code/commands/auth.py:1107 -#: src/iac_code/commands/auth.py:1319 src/iac_code/ui/core/prompt_input.py:557 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:538 +#: src/iac_code/commands/auth.py:570 src/iac_code/commands/auth.py:577 +#: src/iac_code/commands/auth.py:612 src/iac_code/commands/auth.py:619 +#: src/iac_code/commands/auth.py:640 src/iac_code/commands/auth.py:1117 +#: src/iac_code/commands/auth.py:1551 src/iac_code/ui/core/prompt_input.py:557 msgid "Confirm" msgstr "Confirmer" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:528 -#: src/iac_code/commands/auth.py:530 src/iac_code/commands/auth.py:562 -#: src/iac_code/commands/auth.py:569 src/iac_code/commands/auth.py:604 -#: src/iac_code/commands/auth.py:611 src/iac_code/commands/auth.py:632 -#: src/iac_code/commands/auth.py:1107 src/iac_code/commands/auth.py:1217 -#: src/iac_code/commands/auth.py:1319 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:536 +#: src/iac_code/commands/auth.py:538 src/iac_code/commands/auth.py:570 +#: src/iac_code/commands/auth.py:577 src/iac_code/commands/auth.py:612 +#: src/iac_code/commands/auth.py:619 src/iac_code/commands/auth.py:640 +#: src/iac_code/commands/auth.py:1117 src/iac_code/commands/auth.py:1443 +#: src/iac_code/commands/auth.py:1551 msgid "Back" msgstr "Retour" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Keep" msgstr "Conserver" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Re-enter" msgstr "Saisir à nouveau" -#: src/iac_code/commands/auth.py:741 src/iac_code/commands/auth.py:853 -#: src/iac_code/commands/auth.py:911 src/iac_code/commands/auth.py:919 -#: src/iac_code/commands/auth.py:946 +#: src/iac_code/commands/auth.py:749 src/iac_code/commands/auth.py:863 +#: src/iac_code/commands/auth.py:921 src/iac_code/commands/auth.py:929 +#: src/iac_code/commands/auth.py:956 msgid " (current)" msgstr " (actuel)" -#: src/iac_code/commands/auth.py:744 +#: src/iac_code/commands/auth.py:752 msgid "Custom model..." msgstr "Modèle personnalisé…" -#: src/iac_code/commands/auth.py:747 +#: src/iac_code/commands/auth.py:755 #, python-brace-format msgid "Select model for {provider}" msgstr "Sélectionner le modèle pour {provider}" -#: src/iac_code/commands/auth.py:749 +#: src/iac_code/commands/auth.py:757 msgid "Select model" msgstr "Sélectionner le modèle" -#: src/iac_code/commands/auth.py:757 +#: src/iac_code/commands/auth.py:765 msgid "Enter custom model name: " msgstr "Saisir le nom du modèle personnalisé : " -#: src/iac_code/commands/auth.py:783 +#: src/iac_code/commands/auth.py:791 msgid "Error: console not available" msgstr "Erreur : console indisponible" -#: src/iac_code/commands/auth.py:810 +#: src/iac_code/commands/auth.py:820 msgid "Configure LLM Provider" msgstr "Configurer le fournisseur LLM" -#: src/iac_code/commands/auth.py:811 +#: src/iac_code/commands/auth.py:821 msgid "Configure IaC Cloud Service" msgstr "Configurer le service cloud IaC" -#: src/iac_code/commands/auth.py:813 +#: src/iac_code/commands/auth.py:823 msgid "Select configuration type" msgstr "Sélectionner le type de configuration" -#: src/iac_code/commands/auth.py:815 src/iac_code/commands/auth.py:971 -#: src/iac_code/commands/auth.py:987 src/iac_code/commands/auth.py:1066 -#: src/iac_code/commands/auth.py:1258 src/iac_code/commands/auth.py:1299 +#: src/iac_code/commands/auth.py:825 src/iac_code/commands/auth.py:981 +#: src/iac_code/commands/auth.py:997 src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1490 src/iac_code/commands/auth.py:1531 msgid "Auth cancelled" msgstr "Authentification annulée" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:951 -#: src/iac_code/commands/auth.py:1041 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:961 +#: src/iac_code/commands/auth.py:1051 #, python-brace-format msgid "Select provider — {group}" msgstr "Sélectionner le fournisseur — {group}" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:909 -#: src/iac_code/commands/auth.py:1026 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:919 +#: src/iac_code/commands/auth.py:1036 msgid "Third-party" msgstr "Tiers" -#: src/iac_code/commands/auth.py:867 +#: src/iac_code/commands/auth.py:877 #, python-brace-format msgid "{status}: {provider}" msgstr "{status} : {provider}" -#: src/iac_code/commands/auth.py:868 src/iac_code/commands/auth.py:1019 +#: src/iac_code/commands/auth.py:878 src/iac_code/commands/auth.py:1029 msgid "Configured" msgstr "Configuré" -#: src/iac_code/commands/auth.py:923 +#: src/iac_code/commands/auth.py:933 msgid "Select provider" msgstr "Sélectionner le fournisseur" -#: src/iac_code/commands/auth.py:964 +#: src/iac_code/commands/auth.py:974 #, python-brace-format msgid "Configure {provider}" msgstr "Configurer {provider}" -#: src/iac_code/commands/auth.py:980 +#: src/iac_code/commands/auth.py:990 #, python-brace-format msgid "Enter API key for {provider}" msgstr "Saisir la clé API pour {provider}" -#: src/iac_code/commands/auth.py:1018 +#: src/iac_code/commands/auth.py:1028 #, python-brace-format msgid "{status}: {provider} / {model}" msgstr "{status} : {provider} / {model}" -#: src/iac_code/commands/auth.py:1027 src/iac_code/commands/auth.py:1048 +#: src/iac_code/commands/auth.py:1037 src/iac_code/commands/auth.py:1058 msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:1028 src/iac_code/providers/registry.py:426 +#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:427 msgid "ZhiPu AI" msgstr "ZhiPu AI" -#: src/iac_code/commands/auth.py:1029 +#: src/iac_code/commands/auth.py:1039 msgid "Kimi" msgstr "Kimi" -#: src/iac_code/commands/auth.py:1030 +#: src/iac_code/commands/auth.py:1040 msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:1031 src/iac_code/providers/registry.py:428 +#: src/iac_code/commands/auth.py:1041 src/iac_code/providers/registry.py:429 msgid "Volcengine" msgstr "Volcengine" -#: src/iac_code/commands/auth.py:1032 +#: src/iac_code/commands/auth.py:1042 msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:1033 src/iac_code/providers/registry.py:419 +#: src/iac_code/commands/auth.py:1043 src/iac_code/providers/registry.py:420 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:1034 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:1044 src/iac_code/providers/registry.py:418 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:1035 src/iac_code/providers/registry.py:418 +#: src/iac_code/commands/auth.py:1045 src/iac_code/providers/registry.py:419 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:1036 src/iac_code/providers/registry.py:421 +#: src/iac_code/commands/auth.py:1046 src/iac_code/providers/registry.py:422 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:1037 src/iac_code/providers/registry.py:434 +#: src/iac_code/commands/auth.py:1047 src/iac_code/providers/registry.py:435 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:433 +#: src/iac_code/commands/auth.py:1048 src/iac_code/providers/registry.py:434 msgid "OpenRouter" msgstr "OpenRouter" -#: src/iac_code/commands/auth.py:1039 +#: src/iac_code/commands/auth.py:1049 msgid "Local" msgstr "Local" -#: src/iac_code/commands/auth.py:1040 +#: src/iac_code/commands/auth.py:1050 msgid "Compatible" msgstr "Compatible" -#: src/iac_code/commands/auth.py:1057 +#: src/iac_code/commands/auth.py:1067 msgid "Select Cloud Provider" msgstr "Sélectionner le fournisseur cloud" -#: src/iac_code/commands/auth.py:1073 +#: src/iac_code/commands/auth.py:1083 msgid "Credential" msgstr "Identifiants" -#: src/iac_code/commands/auth.py:1074 src/iac_code/commands/auth.py:1190 -#: src/iac_code/commands/auth.py:1295 src/iac_code/ui/renderer.py:455 +#: src/iac_code/commands/auth.py:1084 src/iac_code/commands/auth.py:1199 +#: src/iac_code/commands/auth.py:1527 src/iac_code/commands/status.py:36 +#: src/iac_code/ui/renderer.py:455 msgid "Region" msgstr "Région" -#: src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1086 msgid "Configure Alibaba Cloud" msgstr "Configurer Alibaba Cloud" -#: src/iac_code/commands/auth.py:1178 +#: src/iac_code/commands/auth.py:1188 msgid "Current configuration" msgstr "Configuration actuelle" -#: src/iac_code/commands/auth.py:1180 +#: src/iac_code/commands/auth.py:1190 msgid "Mode" msgstr "Mode" -#: src/iac_code/commands/auth.py:1187 +#: src/iac_code/commands/auth.py:1196 msgid "(not set)" msgstr "(non défini)" -#: src/iac_code/commands/auth.py:1204 +#: src/iac_code/commands/auth.py:1205 +msgid "AccessKey" +msgstr "AccessKey" + +#: src/iac_code/commands/auth.py:1207 src/iac_code/commands/auth.py:1219 +msgid "STS Token" +msgstr "Jeton STS" + +#: src/iac_code/commands/auth.py:1209 +msgid "RAM Role" +msgstr "Rôle RAM" + +#: src/iac_code/commands/auth.py:1211 +msgid "OAuth Login (Browser)" +msgstr "Connexion OAuth (navigateur)" + +#: src/iac_code/commands/auth.py:1217 +msgid "AccessKey ID" +msgstr "ID AccessKey" + +#: src/iac_code/commands/auth.py:1218 +msgid "AccessKey Secret" +msgstr "Secret AccessKey" + +#: src/iac_code/commands/auth.py:1220 +msgid "RAM Role ARN" +msgstr "ARN du rôle RAM" + +#: src/iac_code/commands/auth.py:1221 +msgid "Session Name" +msgstr "Nom de session" + +#: src/iac_code/commands/auth.py:1222 +msgid "OAuth Site Type" +msgstr "Type de site OAuth" + +#: src/iac_code/commands/auth.py:1223 +msgid "OAuth Access Token" +msgstr "Jeton d'accès OAuth" + +#: src/iac_code/commands/auth.py:1224 +msgid "OAuth Refresh Token" +msgstr "Jeton de rafraîchissement OAuth" + +#: src/iac_code/commands/auth.py:1225 +msgid "OAuth Access Token Expire" +msgstr "Expiration du jeton d'accès OAuth" + +#: src/iac_code/commands/auth.py:1226 +msgid "OAuth Refresh Token Expire" +msgstr "Expiration du jeton de rafraîchissement OAuth" + +#: src/iac_code/commands/auth.py:1227 +msgid "STS Expiration" +msgstr "Expiration STS" + +#: src/iac_code/commands/auth.py:1384 +msgid "China" +msgstr "Chine" + +#: src/iac_code/commands/auth.py:1385 +msgid "International" +msgstr "International" + +#: src/iac_code/commands/auth.py:1387 +msgid "Choose site type" +msgstr "Choisir le type de site" + +#: src/iac_code/commands/auth.py:1402 +#, python-brace-format +msgid "Alibaba Cloud OAuth login failed: {error}" +msgstr "Échec de la connexion OAuth Alibaba Cloud : {error}" + +#: src/iac_code/commands/auth.py:1418 +msgid "Configured: Alibaba Cloud OAuth credentials saved" +msgstr "Configuré : identifiants OAuth Alibaba Cloud enregistrés" + +#: src/iac_code/commands/auth.py:1430 msgid "Configure Alibaba Cloud credentials" msgstr "Configurer les identifiants Alibaba Cloud" -#: src/iac_code/commands/auth.py:1217 +#: src/iac_code/commands/auth.py:1443 msgid "Reconfigure credential" msgstr "Reconfigurer les identifiants" -#: src/iac_code/commands/auth.py:1230 +#: src/iac_code/commands/auth.py:1456 msgid "Select credential type" msgstr "Sélectionner le type d’identifiants" -#: src/iac_code/commands/auth.py:1280 +#: src/iac_code/commands/auth.py:1512 msgid "Configured: Alibaba Cloud credentials saved to ~/.iac-code" msgstr "" "Configured: Alibaba Cloud credentials saved to ~/.iac-codeConfigured: " "Alibaba Cloud credentials saved to ~/.iac-codeConfiguration effectuée : " "identifiants Alibaba Cloud enregistrés dans ~/.iac-code" -#: src/iac_code/commands/auth.py:1287 +#: src/iac_code/commands/auth.py:1519 msgid "Configure Alibaba Cloud region" msgstr "Configurer la région Alibaba Cloud" -#: src/iac_code/commands/auth.py:1313 +#: src/iac_code/commands/auth.py:1545 msgid "Configured: Alibaba Cloud region saved to ~/.iac-code" msgstr "" "Configured: Alibaba Cloud region saved to ~/.iac-codeConfigured: Alibaba " @@ -972,6 +1107,36 @@ msgstr "Afficher les suggestions de commandes" msgid "Exit" msgstr "Quitter" +#: src/iac_code/commands/memory.py:10 +msgid "Usage: /memory [|search |delete |help]" +msgstr "Utilisation : /memory [|search |delete |help]" + +#: src/iac_code/commands/memory.py:40 +msgid "Saved memories:" +msgstr "Mémoires enregistrées :" + +#: src/iac_code/commands/memory.py:40 +msgid "No memories saved yet." +msgstr "Aucune mémoire enregistrée pour le moment." + +#: src/iac_code/commands/memory.py:51 +msgid "Matching memories:" +msgstr "Mémoires correspondantes :" + +#: src/iac_code/commands/memory.py:51 +msgid "No matching memories." +msgstr "Aucune mémoire correspondante." + +#: src/iac_code/commands/memory.py:60 src/iac_code/commands/memory.py:75 +#, python-brace-format +msgid "Memory '{name}' not found." +msgstr "Mémoire '{name}' introuvable." + +#: src/iac_code/commands/memory.py:64 +#, python-brace-format +msgid "Memory '{name}' deleted." +msgstr "Mémoire '{name}' supprimée." + #: src/iac_code/commands/model.py:57 #, python-brace-format msgid "" @@ -996,23 +1161,128 @@ msgstr "Modèle actuel : {model}" msgid "Kept model as {model}" msgstr "Modèle conservé : {model}" -#: src/iac_code/commands/resume.py:21 +#: src/iac_code/commands/rename.py:16 src/iac_code/commands/rename.py:28 +msgid "Rename is only available in interactive mode." +msgstr "Le renommage n'est disponible qu'en mode interactif." + +#: src/iac_code/commands/rename.py:31 +msgid "Rename cancelled" +msgstr "Renommage annulé" + +#: src/iac_code/commands/resume.py:22 msgid "Resume is only available in interactive mode." msgstr "/resume n’est disponible qu’en mode interactif." -#: src/iac_code/commands/resume.py:27 +#: src/iac_code/commands/resume.py:28 msgid "Resume is unavailable: session index not initialised." msgstr "Reprise indisponible : l’index des sessions n’est pas initialisé." -#: src/iac_code/commands/resume.py:32 +#: src/iac_code/commands/resume.py:33 src/iac_code/commands/resume.py:36 #, python-brace-format msgid "Session not found: {arg}" msgstr "Session introuvable : {arg}" -#: src/iac_code/commands/resume.py:47 +#: src/iac_code/commands/resume.py:52 src/iac_code/commands/resume.py:68 msgid "Resume cancelled" msgstr "Reprise annulée" +#: src/iac_code/commands/resume.py:55 +#, python-brace-format +msgid "Unable to resolve session: {arg}" +msgstr "Impossible de résoudre la session : {arg}" + +#: src/iac_code/commands/skills.py:14 +msgid "Skills management is only available in interactive mode." +msgstr "La gestion des compétences n'est disponible qu'en mode interactif." + +#: src/iac_code/commands/skills.py:25 +msgid "Skills update cancelled" +msgstr "Mise à jour des compétences annulée" + +#: src/iac_code/commands/skills.py:29 +msgid "Skills updated" +msgstr "Compétences mises à jour" + +#: src/iac_code/commands/status.py:19 +msgid "Status command requires a context." +msgstr "La commande status nécessite un contexte." + +#: src/iac_code/commands/status.py:22 +msgid "Status command requires a REPL context." +msgstr "La commande status nécessite un contexte REPL." + +#: src/iac_code/commands/status.py:24 +msgid "Status is only available in interactive mode." +msgstr "status n’est disponible qu’en mode interactif." + +#: src/iac_code/commands/status.py:33 src/iac_code/ui/banner.py:136 +#: src/iac_code/ui/banner.py:138 +msgid "Session" +msgstr "Session" + +#: src/iac_code/commands/status.py:34 +msgid "Provider" +msgstr "Fournisseur" + +#: src/iac_code/commands/status.py:34 src/iac_code/commands/status.py:35 +#: src/iac_code/commands/status.py:36 +msgid "not configured" +msgstr "non configuré" + +#: src/iac_code/commands/status.py:35 +msgid "Model" +msgstr "Modèle" + +#: src/iac_code/commands/status.py:37 +msgid "CWD" +msgstr "Répertoire courant" + +#: src/iac_code/commands/status.py:41 +msgid "API Token Usage (recorded):" +msgstr "Utilisation des tokens API (enregistrée) :" + +#: src/iac_code/commands/status.py:44 +msgid "Input" +msgstr "Entrée" + +#: src/iac_code/commands/status.py:45 +msgid "Output" +msgstr "Sortie" + +#: src/iac_code/commands/status.py:46 +msgid "Cache read" +msgstr "Lecture du cache" + +#: src/iac_code/commands/status.py:47 +msgid "Total" +msgstr "Total" + +#: src/iac_code/commands/status.py:50 +msgid "No recorded API usage for this session yet." +msgstr "Aucune utilisation d’API enregistrée pour cette session pour le moment." + +#: src/iac_code/commands/status.py:54 +msgid "Turns" +msgstr "Tours" + +#: src/iac_code/commands/status.py:55 +msgid "Context" +msgstr "Contexte" + +#: src/iac_code/commands/status.py:57 +msgid "Session Status" +msgstr "État de la session" + +#: src/iac_code/commands/status.py:73 +#, python-brace-format +msgid "{session_id} (resumed)" +msgstr "{session_id} (reprise)" + +#: src/iac_code/commands/status.py:81 +#, python-brace-format +msgid "{percent} used ({total} / {window})" +msgstr "{percent} utilisé ({total} / {window})" + # Typer/Click built-in strings #: src/iac_code/i18n/__init__.py:51 msgid "Options" @@ -1097,79 +1367,79 @@ msgstr "" " correcte (actuelle : {base_url}). De nombreux points de terminaison " "compatibles OpenAI exigent le suffixe /v1 (p. ex. {base_url}/v1)." -#: src/iac_code/providers/registry.py:415 +#: src/iac_code/providers/registry.py:416 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "OpenAPI Compatible" msgstr "Compatible OpenAPI" -#: src/iac_code/providers/registry.py:422 +#: src/iac_code/providers/registry.py:423 msgid "Kimi (China)" msgstr "Kimi (Chine)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "Kimi (International)" msgstr "Kimi (International)" -#: src/iac_code/providers/registry.py:424 +#: src/iac_code/providers/registry.py:425 msgid "MiniMax (China)" msgstr "MiniMax (Chine)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "MiniMax (International)" msgstr "MiniMax (International)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (International)" -#: src/iac_code/providers/registry.py:429 +#: src/iac_code/providers/registry.py:430 msgid "SiliconFlow (China)" msgstr "SiliconFlow (Chine)" -#: src/iac_code/providers/registry.py:430 +#: src/iac_code/providers/registry.py:431 msgid "SiliconFlow (International)" msgstr "SiliconFlow (International)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "Ollama (Local)" msgstr "Ollama (Local)" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "LM Studio (Local)" msgstr "LM Studio (Local)" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (International)" -#: src/iac_code/providers/registry.py:438 +#: src/iac_code/providers/registry.py:439 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:439 +#: src/iac_code/providers/registry.py:440 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (International)" -#: src/iac_code/providers/registry.py:440 +#: src/iac_code/providers/registry.py:441 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:441 +#: src/iac_code/providers/registry.py:442 msgid "Anthropic Compatible" msgstr "Compatible Anthropic" @@ -1189,6 +1459,16 @@ msgstr "" "désactivez le mode QwenPaw (supprimez 'llm_source: qwenpaw' de " "settings.yml)." +#: src/iac_code/services/session_metadata.py:52 +#, python-brace-format +msgid "Session name must match {pattern}" +msgstr "Le nom de session doit correspondre à {pattern}" + +#: src/iac_code/services/session_storage.py:241 +#, python-brace-format +msgid "Session name already exists in this project: {name}" +msgstr "Le nom de session existe déjà dans ce projet : {name}" + #: src/iac_code/services/permissions/loader.py:50 #, python-brace-format msgid "Invalid --permission-mode {!r}. Valid values: {}" @@ -1200,15 +1480,143 @@ msgstr "--permission-mode invalide : {!r}. Valeurs valides : {}" msgid "Allow {}?" msgstr "Autoriser {} ?" -#: src/iac_code/skills/skill_tool.py:130 +#: src/iac_code/services/providers/aliyun.py:144 +msgid "Alibaba Cloud OAuth site is missing." +msgstr "Le site OAuth Alibaba Cloud est manquant." + +#: src/iac_code/services/providers/aliyun.py:150 +msgid "Alibaba Cloud OAuth refresh token is missing." +msgstr "Le jeton de rafraîchissement OAuth Alibaba Cloud est manquant." + +#: src/iac_code/services/providers/aliyun.py:158 +msgid "Alibaba Cloud OAuth access token is missing." +msgstr "Le jeton d'accès OAuth Alibaba Cloud est manquant." + +#: src/iac_code/services/providers/aliyun_oauth.py:83 +msgid "Run /auth and choose OAuth Login (Browser)." +msgstr "Exécutez /auth et choisissez Connexion OAuth (navigateur)." + +#: src/iac_code/services/providers/aliyun_oauth.py:106 +#, python-brace-format +msgid "Unknown Aliyun OAuth site: {site_type}" +msgstr "Site OAuth Aliyun inconnu : {site_type}" + +#: src/iac_code/services/providers/aliyun_oauth.py:164 +msgid "Not found" +msgstr "Introuvable" + +#: src/iac_code/services/providers/aliyun_oauth.py:170 +msgid "invalid state" +msgstr "état invalide" + +#: src/iac_code/services/providers/aliyun_oauth.py:171 +msgid "Invalid state" +msgstr "État invalide" + +#: src/iac_code/services/providers/aliyun_oauth.py:176 +msgid "code not found" +msgstr "code d'autorisation introuvable" + +#: src/iac_code/services/providers/aliyun_oauth.py:177 +msgid "Authorization code not found" +msgstr "Code d'autorisation introuvable" + +#: src/iac_code/services/providers/aliyun_oauth.py:181 +msgid "Authorization successful. You can close this window." +msgstr "Autorisation réussie. Vous pouvez fermer cette fenêtre." + +#: src/iac_code/services/providers/aliyun_oauth.py:212 +#, python-brace-format +msgid "No available callback port in range {start}-{end}" +msgstr "Aucun port de callback disponible dans la plage {start}-{end}" + +#: src/iac_code/services/providers/aliyun_oauth.py:227 +msgid "OAuth login cancelled." +msgstr "Connexion OAuth annulée." + +#: src/iac_code/services/providers/aliyun_oauth.py:278 +msgid "Open in your browser:" +msgstr "Ouvrir dans le navigateur :" + +#: src/iac_code/services/providers/aliyun_oauth.py:299 +msgid "Waiting for browser authorization" +msgstr "En attente de l'autorisation du navigateur" + +#: src/iac_code/services/providers/aliyun_oauth.py:300 +msgid "" +"1. The browser may show official-cli; this is the Alibaba Cloud official " +"CLI OAuth application." +msgstr "" +"1. Le navigateur peut afficher official-cli ; il s'agit de l'application " +"OAuth CLI officielle d'Alibaba Cloud." + +#: src/iac_code/services/providers/aliyun_oauth.py:302 +msgid "" +"2. If assignment is required, assign the RAM user or RAM role that is " +"signed in. User groups are not supported." +msgstr "" +"2. Si une assignation est requise, assignez l'utilisateur RAM ou le rôle " +"RAM actuellement connecté. Les groupes d'utilisateurs ne sont pas pris en" +" charge." + +#: src/iac_code/services/providers/aliyun_oauth.py:306 +msgid "" +"3. After assignment, close the old authorization page and run OAuth Login" +" (Browser) again. If it still fails, sign out of Alibaba Cloud and sign " +"in again." +msgstr "" +"3. Après l'assignation, fermez l'ancienne page d'autorisation et relancez" +" Connexion OAuth (navigateur). Si l'échec persiste, déconnectez-vous " +"d'Alibaba Cloud puis reconnectez-vous." + +#: src/iac_code/services/providers/aliyun_oauth.py:310 +msgid "" +"4. STS credentials refresh when possible until Alibaba Cloud expires " +"them. If refresh fails, run /auth again." +msgstr "" +"4. Les identifiants STS sont actualisés lorsque c'est possible jusqu'à " +"leur expiration par Alibaba Cloud. Si l'actualisation échoue, exécutez de" +" nouveau /auth." + +#: src/iac_code/services/providers/aliyun_oauth.py:313 +msgid "Press Esc to cancel while waiting." +msgstr "Appuyez sur Échap pour annuler l'attente." + +#: src/iac_code/services/providers/aliyun_oauth.py:321 +msgid "" +"Timed out waiting for OAuth callback. If Alibaba Cloud asked you to " +"assign the official-cli application, assign it to the exact RAM user or " +"RAM role currently signed in. User groups are not supported. Then close " +"the old authorization page, sign out of Alibaba Cloud and sign in again " +"if needed, and run /auth to choose OAuth Login (Browser) again." +msgstr "" +"Délai d'attente dépassé pour le callback OAuth. Si Alibaba Cloud vous a " +"demandé d'assigner l'application official-cli, assignez-la à " +"l'utilisateur RAM ou au rôle RAM exact actuellement connecté. Les groupes" +" d'utilisateurs ne sont pas pris en charge. Fermez ensuite l'ancienne " +"page d'autorisation, déconnectez-vous d'Alibaba Cloud et reconnectez-vous" +" si nécessaire, puis exécutez /auth pour choisir de nouveau Connexion " +"OAuth (navigateur)." + +#: src/iac_code/skills/skill_tool.py:85 src/iac_code/ui/repl.py:842 +#, python-brace-format +msgid "Skill '{name}' is disabled. Run /skills to enable it." +msgstr "La compétence « {name} » est désactivée. Exécutez /skills pour l'activer." + +#: src/iac_code/skills/skill_tool.py:137 #, python-brace-format msgid "Skill '{name}' loaded (inline)." msgstr "Skill « {name} » chargé (inline)." -#: src/iac_code/skills/skill_tool.py:214 +#: src/iac_code/skills/skill_tool.py:221 msgid "Skill" msgstr "Skill" +#: src/iac_code/skills/skill_tool.py:245 +#, python-brace-format +msgid "Skill disabled: {name}" +msgstr "Compétence désactivée : {name}" + #: src/iac_code/skills/bundled/simplify.py:25 msgid "" "Review changed code for reuse, quality, and efficiency, then fix issues " @@ -1482,7 +1890,7 @@ msgstr "CloudAPI" msgid "Calling {action}..." msgstr "Appel de {action}…" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:390 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:400 #: src/iac_code/tools/cloud/base_api.py:123 msgid "Call succeeded" msgstr "Appel réussi" @@ -1642,11 +2050,11 @@ msgstr "IMPORT TERMINÉ" msgid "IMPORT_FAILED" msgstr "ÉCHEC DE L’IMPORT" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:171 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:172 msgid "Aliyun API" msgstr "Aliyun API" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:389 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:399 #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" msgstr "Appel réussi (RequestId : {request_id})" @@ -1813,23 +2221,19 @@ msgstr "Notes de version" msgid "Run {} to update." msgstr "Exécutez {} pour mettre à jour." -#: src/iac_code/ui/banner.py:105 +#: src/iac_code/ui/banner.py:110 msgid "Your AI-powered Infrastructure as Code assistant" msgstr "Votre assistant Infrastructure as Code assisté par IA" -#: src/iac_code/ui/banner.py:131 +#: src/iac_code/ui/banner.py:144 msgid "Welcome back" msgstr "Bon retour" -#: src/iac_code/ui/banner.py:138 -msgid "Session" -msgstr "Session" - -#: src/iac_code/ui/banner.py:148 +#: src/iac_code/ui/banner.py:161 msgid "Debug mode" msgstr "Mode debug" -#: src/iac_code/ui/banner.py:149 +#: src/iac_code/ui/banner.py:162 msgid "Log file" msgstr "Fichier journal" @@ -1928,103 +2332,115 @@ msgstr "Non, toujours refuser \"{rule}\" (cette session)" msgid "No, always reject this tool" msgstr "Non, toujours refuser cet outil" -#: src/iac_code/ui/repl.py:370 +#: src/iac_code/ui/repl.py:424 msgid "Press Ctrl+C again to exit." msgstr "Appuyez de nouveau sur Ctrl+C pour quitter." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:449 msgid "Interrupted." msgstr "Interrompu." -#: src/iac_code/ui/repl.py:432 -msgid "Goodbye!" -msgstr "Au revoir !" - -#: src/iac_code/ui/repl.py:433 -msgid "Resume this session with:" -msgstr "Pour reprendre cette session :" - -#: src/iac_code/ui/repl.py:458 +#: src/iac_code/ui/repl.py:508 msgid "Update now" msgstr "Mettre à jour maintenant" -#: src/iac_code/ui/repl.py:460 +#: src/iac_code/ui/repl.py:510 msgid "Run the shown update command and exit when it succeeds." msgstr "Exécute la commande de mise à jour affichée et quitte en cas de succès." -#: src/iac_code/ui/repl.py:463 +#: src/iac_code/ui/repl.py:513 msgid "Skip" msgstr "Ignorer" -#: src/iac_code/ui/repl.py:465 +#: src/iac_code/ui/repl.py:515 msgid "Continue with the current version for this session." msgstr "Continuer avec la version actuelle pour cette session." -#: src/iac_code/ui/repl.py:468 +#: src/iac_code/ui/repl.py:518 msgid "Skip until next version" msgstr "Ignorer jusqu’à la prochaine version" -#: src/iac_code/ui/repl.py:470 +#: src/iac_code/ui/repl.py:520 msgid "Hide this update until a newer version is available." msgstr "" "Masquer cette mise à jour jusqu’à ce qu’une version plus récente soit " "disponible." -#: src/iac_code/ui/repl.py:489 src/iac_code/ui/repl.py:501 +#: src/iac_code/ui/repl.py:539 src/iac_code/ui/repl.py:551 msgid "Update command failed. Continuing with the current version." msgstr "La commande de mise à jour a échoué. La version actuelle sera conservée." -#: src/iac_code/ui/repl.py:494 +#: src/iac_code/ui/repl.py:544 msgid "Update completed. Restart iac-code to continue." msgstr "Mise à jour terminée. Redémarrez iac-code pour continuer." -#: src/iac_code/ui/repl.py:532 +#: src/iac_code/ui/repl.py:582 msgid "No image in clipboard." msgstr "Aucune image dans le presse-papiers." -#: src/iac_code/ui/repl.py:718 +#: src/iac_code/ui/repl.py:768 msgid "Usage: !" msgstr "Utilisation : !" -#: src/iac_code/ui/repl.py:723 +#: src/iac_code/ui/repl.py:775 msgid "Shell command support is unavailable." msgstr "La prise en charge des commandes shell n'est pas disponible." -#: src/iac_code/ui/repl.py:787 +#: src/iac_code/ui/repl.py:845 #, python-brace-format msgid "Unknown skill: ${name}. Type / to list commands and skills." msgstr "" "Compétence inconnue : ${name}. Tapez / pour lister les commandes et les " "compétences." -#: src/iac_code/ui/repl.py:789 +#: src/iac_code/ui/repl.py:847 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" "Unknown command: /{name}. Type /help for available commands.Commande " "inconnue : /{name}. Saisissez /help pour la liste des commandes." -#: src/iac_code/ui/repl.py:794 +#: src/iac_code/ui/repl.py:852 #, python-brace-format msgid "$ only invokes skills. Use /{name} instead." msgstr "$ n'invoque que des compétences. Utilisez plutôt /{name}." -#: src/iac_code/ui/repl.py:816 src/iac_code/ui/repl.py:861 +#: src/iac_code/ui/repl.py:874 src/iac_code/ui/repl.py:922 #, python-brace-format msgid "Command error: {error}" msgstr "Erreur de commande : {error}" -#: src/iac_code/ui/repl.py:823 +#: src/iac_code/ui/repl.py:881 #, python-brace-format msgid "Command has no handler: {name}" msgstr "Aucun gestionnaire pour la commande : {name}" -#: src/iac_code/ui/repl.py:1128 +#: src/iac_code/ui/repl.py:1156 +msgid "Goodbye!" +msgstr "Au revoir !" + +#: src/iac_code/ui/repl.py:1157 +msgid "Resume this session with:" +msgstr "Pour reprendre cette session :" + +#: src/iac_code/ui/repl.py:1160 +msgid "Session ID" +msgstr "ID de session" + +#: src/iac_code/ui/repl.py:1210 src/iac_code/ui/repl.py:1214 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Session introuvable : {session_id}" -#: src/iac_code/ui/repl.py:1147 +#: src/iac_code/ui/repl.py:1263 +msgid "Session name: " +msgstr "Nom de session : " + +#: src/iac_code/ui/repl.py:1269 +msgid "Session name cannot be empty." +msgstr "Le nom de session ne peut pas être vide." + +#: src/iac_code/ui/repl.py:1279 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -2035,19 +2451,23 @@ msgstr "" "Pour la reprendre, exécutez :\n" " {cmd}" -#: src/iac_code/ui/repl.py:1186 +#: src/iac_code/ui/repl.py:1283 +msgid "Multiple sessions match. Resume one by ID:" +msgstr "Plusieurs sessions correspondent. Reprenez-en une par ID :" + +#: src/iac_code/ui/repl.py:1396 msgid "This conversation is from a different directory." msgstr "Cette conversation provient d’un autre répertoire." -#: src/iac_code/ui/repl.py:1188 +#: src/iac_code/ui/repl.py:1398 msgid "To resume, run:" msgstr "Pour reprendre, exécutez :" -#: src/iac_code/ui/repl.py:1193 +#: src/iac_code/ui/repl.py:1403 msgid "(Command copied to clipboard)" msgstr "(Commande copiée dans le presse-papiers)" -#: src/iac_code/ui/repl.py:1350 +#: src/iac_code/ui/repl.py:1560 #, python-brace-format msgid "" "Current model {model} does not support image input. Use /model to switch " @@ -2056,12 +2476,12 @@ msgstr "" "Le modèle actuel {model} ne prend pas en charge l’entrée d’image. " "Utilisez /model pour passer à un modèle compatible vision." -#: src/iac_code/ui/repl.py:1359 +#: src/iac_code/ui/repl.py:1569 #, python-brace-format msgid "Image error: {err}" msgstr "Erreur d’image : {err}" -#: src/iac_code/ui/repl.py:1376 +#: src/iac_code/ui/repl.py:1586 msgid "" "Failed to persist image to cache; it will only exist in memory for this " "turn." @@ -2157,91 +2577,192 @@ msgstr "Saisissez pour rechercher des fichiers…" msgid "No matching files" msgstr "Aucun fichier correspondant" -#: src/iac_code/ui/dialogs/resume_picker.py:114 +#: src/iac_code/ui/dialogs/resume_picker.py:116 msgid "Search..." msgstr "Rechercher…" -#: src/iac_code/ui/dialogs/resume_picker.py:359 +#: src/iac_code/ui/dialogs/resume_picker.py:374 msgid "Resume Session" msgstr "Reprendre la session" -#: src/iac_code/ui/dialogs/resume_picker.py:369 +#: src/iac_code/ui/dialogs/resume_picker.py:384 msgid "No sessions found" msgstr "Aucune session trouvée" -#: src/iac_code/ui/dialogs/resume_picker.py:427 +#: src/iac_code/ui/dialogs/resume_picker.py:444 msgid "show current dir" msgstr "afficher le répertoire actuel" -#: src/iac_code/ui/dialogs/resume_picker.py:429 +#: src/iac_code/ui/dialogs/resume_picker.py:446 msgid "show all projects" msgstr "afficher tous les projets" -#: src/iac_code/ui/dialogs/resume_picker.py:432 +#: src/iac_code/ui/dialogs/resume_picker.py:449 msgid "show all branches" msgstr "afficher toutes les branches" -#: src/iac_code/ui/dialogs/resume_picker.py:434 +#: src/iac_code/ui/dialogs/resume_picker.py:451 msgid "only show current branch" msgstr "afficher uniquement la branche actuelle" -#: src/iac_code/ui/dialogs/resume_picker.py:435 +#: src/iac_code/ui/dialogs/resume_picker.py:452 msgid "preview" msgstr "aperçu" -#: src/iac_code/ui/dialogs/resume_picker.py:436 +#: src/iac_code/ui/dialogs/resume_picker.py:453 msgid "Type to search" msgstr "Saisir pour rechercher" -#: src/iac_code/ui/dialogs/resume_picker.py:437 +#: src/iac_code/ui/dialogs/resume_picker.py:454 msgid "cancel" msgstr "annuler" -#: src/iac_code/ui/dialogs/resume_picker.py:552 +#: src/iac_code/ui/dialogs/resume_picker.py:569 #, python-brace-format msgid "{n} more line{s}" msgstr "{n} ligne{s} supplémentaire{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:566 +#: src/iac_code/ui/dialogs/resume_picker.py:583 #, python-brace-format msgid "{n} message{s}" msgstr "{n} message{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:580 +#: src/iac_code/ui/dialogs/resume_picker.py:597 msgid "resume" msgstr "reprendre" -#: src/iac_code/ui/dialogs/resume_picker.py:584 +#: src/iac_code/ui/dialogs/resume_picker.py:601 msgid "back" msgstr "retour" -#: src/iac_code/ui/dialogs/resume_picker.py:589 +#: src/iac_code/ui/dialogs/resume_picker.py:606 msgid "scroll" msgstr "défiler" -#: src/iac_code/ui/dialogs/resume_picker.py:608 +#: src/iac_code/ui/dialogs/resume_picker.py:625 msgid "(empty session)" msgstr "(session vide)" -#: src/iac_code/ui/dialogs/resume_picker.py:728 +#: src/iac_code/ui/dialogs/resume_picker.py:745 msgid "just now" msgstr "à l’instant" -#: src/iac_code/ui/dialogs/resume_picker.py:731 +#: src/iac_code/ui/dialogs/resume_picker.py:748 #, python-brace-format msgid "{n} minute{s} ago" msgstr "il y a {n} minute{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:734 +#: src/iac_code/ui/dialogs/resume_picker.py:751 #, python-brace-format msgid "{n} hour{s} ago" msgstr "il y a {n} heure{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:736 +#: src/iac_code/ui/dialogs/resume_picker.py:753 #, python-brace-format msgid "{n} day{s} ago" msgstr "il y a {n} jour{s}" +#: src/iac_code/ui/dialogs/skills_picker.py:52 +msgid "Search skills..." +msgstr "Rechercher des compétences..." + +#: src/iac_code/ui/dialogs/skills_picker.py:159 +msgid "Skills" +msgstr "Compétences" + +#: src/iac_code/ui/dialogs/skills_picker.py:161 +#, python-brace-format +msgid "{current} of {total}" +msgstr "{current} sur {total}" + +#: src/iac_code/ui/dialogs/skills_picker.py:165 +#, python-brace-format +msgid "" +"{count} skills - Space to toggle, Enter to save, Tab to sort, Esc to " +"cancel" +msgstr "" +"{count} compétences - Espace pour activer/désactiver, Entrée pour " +"enregistrer, Tab pour trier, Échap pour annuler" + +#: src/iac_code/ui/dialogs/skills_picker.py:171 +#, python-brace-format +msgid "Sort: {mode}" +msgstr "Tri : {mode}" + +#: src/iac_code/ui/dialogs/skills_picker.py:176 +msgid "No skills found" +msgstr "Aucune compétence trouvée" + +#: src/iac_code/ui/dialogs/skills_picker.py:245 +msgid "Bundled skills cannot be disabled." +msgstr "Les compétences intégrées ne peuvent pas être désactivées." + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "on" +msgstr "activée" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "off" +msgstr "désactivée" + +#: src/iac_code/ui/dialogs/skills_picker.py:265 +msgid "locked" +msgstr "verrouillée" + +#: src/iac_code/ui/dialogs/skills_picker.py:268 +msgid "matched description" +msgstr "correspond à la description" + +#: src/iac_code/ui/dialogs/skills_picker.py:277 +msgid "source" +msgstr "source" + +#: src/iac_code/ui/dialogs/skills_picker.py:279 +msgid "size" +msgstr "taille" + +#: src/iac_code/ui/dialogs/skills_picker.py:280 +msgid "name" +msgstr "nom" + +#: src/iac_code/ui/dialogs/skills_picker.py:285 +msgid "bundled" +msgstr "intégrée" + +#: src/iac_code/ui/dialogs/skills_picker.py:287 +msgid "project" +msgstr "projet" + +#: src/iac_code/ui/dialogs/skills_picker.py:289 +msgid "user" +msgstr "utilisateur" + +#: src/iac_code/ui/dialogs/skills_picker.py:296 +#, python-brace-format +msgid "~{count}k tokens" +msgstr "~{count}k jetons" + +#: src/iac_code/ui/dialogs/skills_picker.py:297 +#, python-brace-format +msgid "~{count} tokens" +msgstr "~{count} jetons" + +#: src/iac_code/ui/suggestions/command_provider.py:79 +msgid "Search saved memories" +msgstr "Rechercher dans les mémoires enregistrées" + +#: src/iac_code/ui/suggestions/command_provider.py:80 +msgid "Delete a saved memory" +msgstr "Supprimer une mémoire enregistrée" + +#: src/iac_code/ui/suggestions/command_provider.py:81 +msgid "Show memory command help" +msgstr "Afficher l'aide de la commande memory" + +#: src/iac_code/ui/suggestions/command_provider.py:116 +msgid "Saved memory" +msgstr "Mémoire enregistrée" + #: src/iac_code/utils/platform.py:39 msgid "iac-code on Windows requires Git for Windows." msgstr "iac-code sous Windows nécessite Git for Windows." @@ -2297,3 +2818,19 @@ msgstr "" #~ msgid " Option 2 - npmmirror (China-friendly mirror):" #~ msgstr " Option 2 - npmmirror (miroir pour la Chine) :" +#~ msgid "Cache create" +#~ msgstr "Création du cache" + +#~ msgid "" +#~ "{count} skills - Space to toggle, " +#~ "Enter to save, / to search, t " +#~ "to sort, Esc to cancel" +#~ msgstr "" +#~ "{count} compétences - Espace pour " +#~ "activer/désactiver, Entrée pour enregistrer, /" +#~ " pour rechercher, t pour trier, Échap" +#~ " pour annuler" + +#~ msgid "Resume a session by ID" +#~ msgstr "Reprendre une session par identifiant" + diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index fab60c6..105e84f 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -4,9 +4,9 @@ # msgid "" msgstr "" -"Project-Id-Version: iac-code 0.3.0\n" +"Project-Id-Version: iac-code 0.4.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-01 17:56+0800\n" +"POT-Creation-Date: 2026-06-03 13:32+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: ja\n" @@ -30,39 +30,54 @@ msgstr "" "Unix ドメインソケットトランスポートは Windows ではサポートされていません。--transport http または " "--transport stdio を使用してください。" -#: src/iac_code/acp/slash_registry.py:44 +#: src/iac_code/acp/server.py:413 src/iac_code/acp/server.py:429 +#: src/iac_code/acp/server.py:456 +msgid "Session not found" +msgstr "セッションが見つかりません" + +#: src/iac_code/acp/server.py:416 +#, python-brace-format +msgid "Session name is ambiguous. Candidates: {candidates}" +msgstr "セッション名があいまいです。候補: {candidates}" + +#: src/iac_code/acp/server.py:434 src/iac_code/acp/server.py:708 +#, python-brace-format +msgid "Session belongs to another project. Run: {hint}" +msgstr "セッションは別のプロジェクトに属しています。実行: {hint}" + +#: src/iac_code/acp/slash_registry.py:46 #, python-brace-format msgid "" "Command '/{cmd_name}' is not supported over ACP. Supported commands: " "{supported}" msgstr "コマンド '/{cmd_name}' は ACP 上ではサポートされていません。サポートされているコマンド:{supported}" -#: src/iac_code/acp/slash_registry.py:56 +#: src/iac_code/acp/slash_registry.py:62 #, python-brace-format msgid "Command '/{cmd_name}' handler not implemented." msgstr "コマンド '/{cmd_name}' のハンドラーは未実装です。" -#: src/iac_code/acp/slash_registry.py:68 +#: src/iac_code/acp/slash_registry.py:74 #, python-brace-format msgid "Compaction failed: {error}" msgstr "圧縮に失敗しました:{error}" -#: src/iac_code/acp/slash_registry.py:71 src/iac_code/commands/compact.py:24 +#: src/iac_code/acp/slash_registry.py:77 src/iac_code/commands/compact.py:24 msgid "Nothing to compact: conversation is empty." msgstr "圧縮できる内容がありません:会話が空です。" -#: src/iac_code/acp/slash_registry.py:74 src/iac_code/commands/compact.py:27 +#: src/iac_code/acp/slash_registry.py:80 src/iac_code/commands/compact.py:27 #, python-brace-format msgid "" "Conversation too short to compact: all messages are within the recent " "{turns}-turn preservation window." msgstr "会話が短すぎて圧縮できません:すべてのメッセージが直近 {turns} ターンの保持ウィンドウ内にあります。" -#: src/iac_code/acp/slash_registry.py:78 src/iac_code/commands/compact.py:30 +#: src/iac_code/acp/slash_registry.py:84 src/iac_code/commands/compact.py:30 msgid "Compaction failed. See logs for details." msgstr "圧縮に失敗しました。詳細はログをご確認ください。" -#: src/iac_code/acp/slash_registry.py:83 +#: src/iac_code/acp/slash_registry.py:89 #, python-brace-format msgid "" "Context compacted: {original} → {compacted} tokens ({percent} reduction)." @@ -71,39 +86,61 @@ msgstr "" "コンテキストを圧縮しました:{original} → {compacted} tokens({percent} 削減)。 " "コンテキスト使用量:{usage}" -#: src/iac_code/acp/slash_registry.py:97 +#: src/iac_code/acp/slash_registry.py:103 #, python-brace-format msgid "Clear failed: {error}" msgstr "クリアに失敗しました:{error}" -#: src/iac_code/acp/slash_registry.py:98 +#: src/iac_code/acp/slash_registry.py:104 msgid "Conversation history cleared." msgstr "会話履歴をクリアしました。" -#: src/iac_code/acp/slash_registry.py:114 src/iac_code/commands/debug.py:34 +#: src/iac_code/acp/slash_registry.py:120 src/iac_code/commands/debug.py:34 #, python-brace-format msgid "Debug logging is on. Log file: {path}" msgstr "デバッグログはオンです。ログファイル:{path}" -#: src/iac_code/acp/slash_registry.py:115 src/iac_code/commands/debug.py:35 +#: src/iac_code/acp/slash_registry.py:121 src/iac_code/commands/debug.py:35 msgid "Debug logging is off." msgstr "デバッグログはオフです。" -#: src/iac_code/acp/slash_registry.py:119 src/iac_code/commands/debug.py:39 +#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:39 #, python-brace-format msgid "Debug logging enabled. Log file: {path}" msgstr "デバッグログを有効にしました。ログファイル:{path}" -#: src/iac_code/acp/slash_registry.py:123 src/iac_code/commands/debug.py:43 +#: src/iac_code/acp/slash_registry.py:129 src/iac_code/commands/debug.py:43 msgid "Debug logging disabled." msgstr "デバッグログを無効にしました。" -#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:45 +#: src/iac_code/acp/slash_registry.py:131 src/iac_code/commands/debug.py:45 msgid "Usage: /debug [on|off]" msgstr "使用方法:/debug [on|off]" -#: src/iac_code/agent/agent_loop.py:404 src/iac_code/agent/agent_loop.py:419 -#: src/iac_code/ui/repl.py:757 src/iac_code/ui/repl.py:771 +#: src/iac_code/acp/slash_registry.py:136 src/iac_code/commands/memory.py:84 +msgid "Memory manager is unavailable." +msgstr "メモリマネージャーを利用できません。" + +#: src/iac_code/acp/slash_registry.py:146 src/iac_code/commands/rename.py:21 +msgid "Usage: /rename " +msgstr "使用法: /rename <名前>" + +#: src/iac_code/acp/slash_registry.py:152 +msgid "Rename is only available after a session is created." +msgstr "セッション作成後にのみ名前を変更できます。" + +#: src/iac_code/acp/slash_registry.py:163 src/iac_code/commands/rename.py:42 +#, python-brace-format +msgid "Session is already named {name}" +msgstr "セッション名はすでに {name} です" + +#: src/iac_code/acp/slash_registry.py:164 src/iac_code/commands/rename.py:43 +#, python-brace-format +msgid "Renamed session to {name}" +msgstr "セッション名を {name} に変更しました" + +#: src/iac_code/agent/agent_loop.py:413 src/iac_code/agent/agent_loop.py:428 +#: src/iac_code/ui/repl.py:813 src/iac_code/ui/repl.py:827 msgid "Permission denied." msgstr "権限が拒否されました。" @@ -268,8 +305,8 @@ msgid "Show version and exit" msgstr "バージョンを表示して終了する" #: src/iac_code/cli/main.py:89 -msgid "Resume a session by ID" -msgstr "ID でセッションを再開する" +msgid "Resume a session by ID or name" +msgstr "ID または名前でセッションを再開" #: src/iac_code/cli/main.py:90 msgid "Resume the most recent session" @@ -583,272 +620,370 @@ msgstr "永続化された A2A ルート用のディレクトリ" msgid "Save the provided routes as a route snapshot" msgstr "指定されたルートをルートスナップショットとして保存します" -#: src/iac_code/commands/__init__.py:22 +#: src/iac_code/commands/__init__.py:26 msgid "Show available commands" msgstr "利用可能なコマンドを表示します" -#: src/iac_code/commands/__init__.py:31 +#: src/iac_code/commands/__init__.py:35 msgid "Clear conversation history" msgstr "会話履歴をクリアします" -#: src/iac_code/commands/__init__.py:39 +#: src/iac_code/commands/__init__.py:43 msgid "Show or switch model" msgstr "モデルを表示または切り替えます" -#: src/iac_code/commands/__init__.py:48 +#: src/iac_code/commands/__init__.py:52 msgid "Show or switch thinking effort" msgstr "思考の負荷(effort)を表示または切り替えます" -#: src/iac_code/commands/__init__.py:57 +#: src/iac_code/commands/__init__.py:61 msgid "Compact conversation context" msgstr "会話コンテキストを圧縮します" -#: src/iac_code/commands/__init__.py:59 +#: src/iac_code/commands/__init__.py:63 msgid "Compacting conversation" msgstr "会話を圧縮しています" -#: src/iac_code/commands/__init__.py:66 +#: src/iac_code/commands/__init__.py:70 msgid "Exit the application" msgstr "アプリケーションを終了します" -#: src/iac_code/commands/__init__.py:75 +#: src/iac_code/commands/__init__.py:79 msgid "Authenticate with LLM provider" msgstr "LLM プロバイダーで認証を行います" -#: src/iac_code/commands/__init__.py:84 +#: src/iac_code/commands/__init__.py:88 msgid "Toggle debug logging" msgstr "デバッグログのオン/オフを切り替えます" -#: src/iac_code/commands/__init__.py:93 +#: src/iac_code/commands/__init__.py:97 +msgid "View and manage persistent memories" +msgstr "永続メモリを表示および管理" + +#: src/iac_code/commands/__init__.py:99 +msgid "[|search |delete |help]" +msgstr "[<名前>|search <検索語>|delete <名前>|help]" + +#: src/iac_code/commands/__init__.py:106 msgid "Resume a previous session" msgstr "以前のセッションを再開します" -#: src/iac_code/commands/__init__.py:95 +#: src/iac_code/commands/__init__.py:108 msgid "[conversation id or search term]" msgstr "[会話 ID または検索語]" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:1107 +#: src/iac_code/commands/__init__.py:115 +msgid "Rename the current session" +msgstr "現在のセッション名を変更" + +#: src/iac_code/commands/__init__.py:124 +msgid "Manage skills" +msgstr "スキルを管理" + +#: src/iac_code/commands/__init__.py:132 +msgid "Show current session status" +msgstr "現在のセッション状態を表示" + +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:1117 #: src/iac_code/ui/core/prompt_input.py:557 msgid "Navigate" msgstr "移動" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:530 -#: src/iac_code/commands/auth.py:562 src/iac_code/commands/auth.py:569 -#: src/iac_code/commands/auth.py:604 src/iac_code/commands/auth.py:611 -#: src/iac_code/commands/auth.py:632 src/iac_code/commands/auth.py:1107 -#: src/iac_code/commands/auth.py:1319 src/iac_code/ui/core/prompt_input.py:557 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:538 +#: src/iac_code/commands/auth.py:570 src/iac_code/commands/auth.py:577 +#: src/iac_code/commands/auth.py:612 src/iac_code/commands/auth.py:619 +#: src/iac_code/commands/auth.py:640 src/iac_code/commands/auth.py:1117 +#: src/iac_code/commands/auth.py:1551 src/iac_code/ui/core/prompt_input.py:557 msgid "Confirm" msgstr "確認" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:528 -#: src/iac_code/commands/auth.py:530 src/iac_code/commands/auth.py:562 -#: src/iac_code/commands/auth.py:569 src/iac_code/commands/auth.py:604 -#: src/iac_code/commands/auth.py:611 src/iac_code/commands/auth.py:632 -#: src/iac_code/commands/auth.py:1107 src/iac_code/commands/auth.py:1217 -#: src/iac_code/commands/auth.py:1319 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:536 +#: src/iac_code/commands/auth.py:538 src/iac_code/commands/auth.py:570 +#: src/iac_code/commands/auth.py:577 src/iac_code/commands/auth.py:612 +#: src/iac_code/commands/auth.py:619 src/iac_code/commands/auth.py:640 +#: src/iac_code/commands/auth.py:1117 src/iac_code/commands/auth.py:1443 +#: src/iac_code/commands/auth.py:1551 msgid "Back" msgstr "戻る" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Keep" msgstr "維持" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Re-enter" msgstr "再入力" -#: src/iac_code/commands/auth.py:741 src/iac_code/commands/auth.py:853 -#: src/iac_code/commands/auth.py:911 src/iac_code/commands/auth.py:919 -#: src/iac_code/commands/auth.py:946 +#: src/iac_code/commands/auth.py:749 src/iac_code/commands/auth.py:863 +#: src/iac_code/commands/auth.py:921 src/iac_code/commands/auth.py:929 +#: src/iac_code/commands/auth.py:956 msgid " (current)" msgstr " (現在)" -#: src/iac_code/commands/auth.py:744 +#: src/iac_code/commands/auth.py:752 msgid "Custom model..." msgstr "カスタムモデル…" -#: src/iac_code/commands/auth.py:747 +#: src/iac_code/commands/auth.py:755 #, python-brace-format msgid "Select model for {provider}" msgstr "{provider} のモデルを選択してください" -#: src/iac_code/commands/auth.py:749 +#: src/iac_code/commands/auth.py:757 msgid "Select model" msgstr "モデルを選択" -#: src/iac_code/commands/auth.py:757 +#: src/iac_code/commands/auth.py:765 msgid "Enter custom model name: " msgstr "カスタムモデル名を入力してください:" -#: src/iac_code/commands/auth.py:783 +#: src/iac_code/commands/auth.py:791 msgid "Error: console not available" msgstr "エラー:コンソールを使用できません" -#: src/iac_code/commands/auth.py:810 +#: src/iac_code/commands/auth.py:820 msgid "Configure LLM Provider" msgstr "LLM プロバイダーを設定" -#: src/iac_code/commands/auth.py:811 +#: src/iac_code/commands/auth.py:821 msgid "Configure IaC Cloud Service" msgstr "IaC クラウドサービスを設定" -#: src/iac_code/commands/auth.py:813 +#: src/iac_code/commands/auth.py:823 msgid "Select configuration type" msgstr "設定の種類を選択" -#: src/iac_code/commands/auth.py:815 src/iac_code/commands/auth.py:971 -#: src/iac_code/commands/auth.py:987 src/iac_code/commands/auth.py:1066 -#: src/iac_code/commands/auth.py:1258 src/iac_code/commands/auth.py:1299 +#: src/iac_code/commands/auth.py:825 src/iac_code/commands/auth.py:981 +#: src/iac_code/commands/auth.py:997 src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1490 src/iac_code/commands/auth.py:1531 msgid "Auth cancelled" msgstr "認証をキャンセルしました" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:951 -#: src/iac_code/commands/auth.py:1041 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:961 +#: src/iac_code/commands/auth.py:1051 #, python-brace-format msgid "Select provider — {group}" msgstr "プロバイダーを選択 — {group}" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:909 -#: src/iac_code/commands/auth.py:1026 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:919 +#: src/iac_code/commands/auth.py:1036 msgid "Third-party" msgstr "サードパーティ" -#: src/iac_code/commands/auth.py:867 +#: src/iac_code/commands/auth.py:877 #, python-brace-format msgid "{status}: {provider}" msgstr "{status}:{provider}" -#: src/iac_code/commands/auth.py:868 src/iac_code/commands/auth.py:1019 +#: src/iac_code/commands/auth.py:878 src/iac_code/commands/auth.py:1029 msgid "Configured" msgstr "設定済み" -#: src/iac_code/commands/auth.py:923 +#: src/iac_code/commands/auth.py:933 msgid "Select provider" msgstr "プロバイダーを選択" -#: src/iac_code/commands/auth.py:964 +#: src/iac_code/commands/auth.py:974 #, python-brace-format msgid "Configure {provider}" msgstr "{provider} を設定" -#: src/iac_code/commands/auth.py:980 +#: src/iac_code/commands/auth.py:990 #, python-brace-format msgid "Enter API key for {provider}" msgstr "{provider} の API key を入力してください" -#: src/iac_code/commands/auth.py:1018 +#: src/iac_code/commands/auth.py:1028 #, python-brace-format msgid "{status}: {provider} / {model}" msgstr "{status}:{provider} / {model}" -#: src/iac_code/commands/auth.py:1027 src/iac_code/commands/auth.py:1048 +#: src/iac_code/commands/auth.py:1037 src/iac_code/commands/auth.py:1058 msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:1028 src/iac_code/providers/registry.py:426 +#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:427 msgid "ZhiPu AI" msgstr "ZhiPu AI" -#: src/iac_code/commands/auth.py:1029 +#: src/iac_code/commands/auth.py:1039 msgid "Kimi" msgstr "Kimi" -#: src/iac_code/commands/auth.py:1030 +#: src/iac_code/commands/auth.py:1040 msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:1031 src/iac_code/providers/registry.py:428 +#: src/iac_code/commands/auth.py:1041 src/iac_code/providers/registry.py:429 msgid "Volcengine" msgstr "Volcengine" -#: src/iac_code/commands/auth.py:1032 +#: src/iac_code/commands/auth.py:1042 msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:1033 src/iac_code/providers/registry.py:419 +#: src/iac_code/commands/auth.py:1043 src/iac_code/providers/registry.py:420 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:1034 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:1044 src/iac_code/providers/registry.py:418 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:1035 src/iac_code/providers/registry.py:418 +#: src/iac_code/commands/auth.py:1045 src/iac_code/providers/registry.py:419 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:1036 src/iac_code/providers/registry.py:421 +#: src/iac_code/commands/auth.py:1046 src/iac_code/providers/registry.py:422 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:1037 src/iac_code/providers/registry.py:434 +#: src/iac_code/commands/auth.py:1047 src/iac_code/providers/registry.py:435 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:433 +#: src/iac_code/commands/auth.py:1048 src/iac_code/providers/registry.py:434 msgid "OpenRouter" msgstr "OpenRouter" -#: src/iac_code/commands/auth.py:1039 +#: src/iac_code/commands/auth.py:1049 msgid "Local" msgstr "ローカル" -#: src/iac_code/commands/auth.py:1040 +#: src/iac_code/commands/auth.py:1050 msgid "Compatible" msgstr "互換モード" -#: src/iac_code/commands/auth.py:1057 +#: src/iac_code/commands/auth.py:1067 msgid "Select Cloud Provider" msgstr "クラウドプロバイダーを選択" -#: src/iac_code/commands/auth.py:1073 +#: src/iac_code/commands/auth.py:1083 msgid "Credential" msgstr "クレデンシャル" -#: src/iac_code/commands/auth.py:1074 src/iac_code/commands/auth.py:1190 -#: src/iac_code/commands/auth.py:1295 src/iac_code/ui/renderer.py:455 +#: src/iac_code/commands/auth.py:1084 src/iac_code/commands/auth.py:1199 +#: src/iac_code/commands/auth.py:1527 src/iac_code/commands/status.py:36 +#: src/iac_code/ui/renderer.py:455 msgid "Region" msgstr "リージョン" -#: src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1086 msgid "Configure Alibaba Cloud" msgstr "Alibaba Cloud を設定" -#: src/iac_code/commands/auth.py:1178 +#: src/iac_code/commands/auth.py:1188 msgid "Current configuration" msgstr "現在の設定" -#: src/iac_code/commands/auth.py:1180 +#: src/iac_code/commands/auth.py:1190 msgid "Mode" msgstr "モード" -#: src/iac_code/commands/auth.py:1187 +#: src/iac_code/commands/auth.py:1196 msgid "(not set)" msgstr "(未設定)" -#: src/iac_code/commands/auth.py:1204 +#: src/iac_code/commands/auth.py:1205 +msgid "AccessKey" +msgstr "AccessKey" + +#: src/iac_code/commands/auth.py:1207 src/iac_code/commands/auth.py:1219 +msgid "STS Token" +msgstr "STS トークン" + +#: src/iac_code/commands/auth.py:1209 +msgid "RAM Role" +msgstr "RAM ロール" + +#: src/iac_code/commands/auth.py:1211 +msgid "OAuth Login (Browser)" +msgstr "OAuth ログイン(ブラウザー)" + +#: src/iac_code/commands/auth.py:1217 +msgid "AccessKey ID" +msgstr "AccessKey ID" + +#: src/iac_code/commands/auth.py:1218 +msgid "AccessKey Secret" +msgstr "AccessKey シークレット" + +#: src/iac_code/commands/auth.py:1220 +msgid "RAM Role ARN" +msgstr "RAM ロール ARN" + +#: src/iac_code/commands/auth.py:1221 +msgid "Session Name" +msgstr "セッション名" + +#: src/iac_code/commands/auth.py:1222 +msgid "OAuth Site Type" +msgstr "OAuth サイトタイプ" + +#: src/iac_code/commands/auth.py:1223 +msgid "OAuth Access Token" +msgstr "OAuth アクセストークン" + +#: src/iac_code/commands/auth.py:1224 +msgid "OAuth Refresh Token" +msgstr "OAuth リフレッシュトークン" + +#: src/iac_code/commands/auth.py:1225 +msgid "OAuth Access Token Expire" +msgstr "OAuth アクセストークンの有効期限" + +#: src/iac_code/commands/auth.py:1226 +msgid "OAuth Refresh Token Expire" +msgstr "OAuth リフレッシュトークンの有効期限" + +#: src/iac_code/commands/auth.py:1227 +msgid "STS Expiration" +msgstr "STS 有効期限" + +#: src/iac_code/commands/auth.py:1384 +msgid "China" +msgstr "中国" + +#: src/iac_code/commands/auth.py:1385 +msgid "International" +msgstr "国際" + +#: src/iac_code/commands/auth.py:1387 +msgid "Choose site type" +msgstr "サイトタイプを選択" + +#: src/iac_code/commands/auth.py:1402 +#, python-brace-format +msgid "Alibaba Cloud OAuth login failed: {error}" +msgstr "Alibaba Cloud OAuth ログインに失敗しました: {error}" + +#: src/iac_code/commands/auth.py:1418 +msgid "Configured: Alibaba Cloud OAuth credentials saved" +msgstr "設定完了: Alibaba Cloud OAuth 認証情報を保存しました" + +#: src/iac_code/commands/auth.py:1430 msgid "Configure Alibaba Cloud credentials" msgstr "Alibaba Cloud のクレデンシャルを設定" -#: src/iac_code/commands/auth.py:1217 +#: src/iac_code/commands/auth.py:1443 msgid "Reconfigure credential" msgstr "クレデンシャルを再設定" -#: src/iac_code/commands/auth.py:1230 +#: src/iac_code/commands/auth.py:1456 msgid "Select credential type" msgstr "クレデンシャルの種類を選択" -#: src/iac_code/commands/auth.py:1280 +#: src/iac_code/commands/auth.py:1512 msgid "Configured: Alibaba Cloud credentials saved to ~/.iac-code" msgstr "" "Configured: Alibaba Cloud credentials saved to ~/.iac-code設定しました:Alibaba " "Cloud のクレデンシャルを ~/.iac-code に保存しました" -#: src/iac_code/commands/auth.py:1287 +#: src/iac_code/commands/auth.py:1519 msgid "Configure Alibaba Cloud region" msgstr "Alibaba Cloud のリージョンを設定" -#: src/iac_code/commands/auth.py:1313 +#: src/iac_code/commands/auth.py:1545 msgid "Configured: Alibaba Cloud region saved to ~/.iac-code" msgstr "設定しました:Alibaba Cloud のリージョンを ~/.iac-code に保存しました" @@ -944,6 +1079,36 @@ msgstr "コマンド候補を表示" msgid "Exit" msgstr "終了" +#: src/iac_code/commands/memory.py:10 +msgid "Usage: /memory [|search |delete |help]" +msgstr "使用法: /memory [<名前>|search <検索語>|delete <名前>|help]" + +#: src/iac_code/commands/memory.py:40 +msgid "Saved memories:" +msgstr "保存済みメモリ:" + +#: src/iac_code/commands/memory.py:40 +msgid "No memories saved yet." +msgstr "保存済みメモリはまだありません。" + +#: src/iac_code/commands/memory.py:51 +msgid "Matching memories:" +msgstr "一致するメモリ:" + +#: src/iac_code/commands/memory.py:51 +msgid "No matching memories." +msgstr "一致するメモリはありません。" + +#: src/iac_code/commands/memory.py:60 src/iac_code/commands/memory.py:75 +#, python-brace-format +msgid "Memory '{name}' not found." +msgstr "メモリ '{name}' が見つかりません。" + +#: src/iac_code/commands/memory.py:64 +#, python-brace-format +msgid "Memory '{name}' deleted." +msgstr "メモリ '{name}' を削除しました。" + #: src/iac_code/commands/model.py:57 #, python-brace-format msgid "" @@ -968,23 +1133,128 @@ msgstr "現在のモデル:{model}" msgid "Kept model as {model}" msgstr "モデルを {model} のままにしました" -#: src/iac_code/commands/resume.py:21 +#: src/iac_code/commands/rename.py:16 src/iac_code/commands/rename.py:28 +msgid "Rename is only available in interactive mode." +msgstr "名前の変更は対話モードでのみ使用できます。" + +#: src/iac_code/commands/rename.py:31 +msgid "Rename cancelled" +msgstr "名前の変更をキャンセルしました" + +#: src/iac_code/commands/resume.py:22 msgid "Resume is only available in interactive mode." msgstr "resume は対話モードでのみ利用できます。" -#: src/iac_code/commands/resume.py:27 +#: src/iac_code/commands/resume.py:28 msgid "Resume is unavailable: session index not initialised." msgstr "resume を使用できません:セッション索引が初期化されていません。" -#: src/iac_code/commands/resume.py:32 +#: src/iac_code/commands/resume.py:33 src/iac_code/commands/resume.py:36 #, python-brace-format msgid "Session not found: {arg}" msgstr "セッションが見つかりません:{arg}" -#: src/iac_code/commands/resume.py:47 +#: src/iac_code/commands/resume.py:52 src/iac_code/commands/resume.py:68 msgid "Resume cancelled" msgstr "resume をキャンセルしました" +#: src/iac_code/commands/resume.py:55 +#, python-brace-format +msgid "Unable to resolve session: {arg}" +msgstr "セッションを解決できません: {arg}" + +#: src/iac_code/commands/skills.py:14 +msgid "Skills management is only available in interactive mode." +msgstr "スキル管理は対話モードでのみ使用できます。" + +#: src/iac_code/commands/skills.py:25 +msgid "Skills update cancelled" +msgstr "スキル更新をキャンセルしました" + +#: src/iac_code/commands/skills.py:29 +msgid "Skills updated" +msgstr "スキルを更新しました" + +#: src/iac_code/commands/status.py:19 +msgid "Status command requires a context." +msgstr "status コマンドにはコンテキストが必要です。" + +#: src/iac_code/commands/status.py:22 +msgid "Status command requires a REPL context." +msgstr "status コマンドには REPL コンテキストが必要です。" + +#: src/iac_code/commands/status.py:24 +msgid "Status is only available in interactive mode." +msgstr "status は対話モードでのみ利用できます。" + +#: src/iac_code/commands/status.py:33 src/iac_code/ui/banner.py:136 +#: src/iac_code/ui/banner.py:138 +msgid "Session" +msgstr "セッション" + +#: src/iac_code/commands/status.py:34 +msgid "Provider" +msgstr "プロバイダー" + +#: src/iac_code/commands/status.py:34 src/iac_code/commands/status.py:35 +#: src/iac_code/commands/status.py:36 +msgid "not configured" +msgstr "未設定" + +#: src/iac_code/commands/status.py:35 +msgid "Model" +msgstr "モデル" + +#: src/iac_code/commands/status.py:37 +msgid "CWD" +msgstr "現在のディレクトリ" + +#: src/iac_code/commands/status.py:41 +msgid "API Token Usage (recorded):" +msgstr "API トークン使用量(記録済み):" + +#: src/iac_code/commands/status.py:44 +msgid "Input" +msgstr "入力" + +#: src/iac_code/commands/status.py:45 +msgid "Output" +msgstr "出力" + +#: src/iac_code/commands/status.py:46 +msgid "Cache read" +msgstr "キャッシュ読み取り" + +#: src/iac_code/commands/status.py:47 +msgid "Total" +msgstr "合計" + +#: src/iac_code/commands/status.py:50 +msgid "No recorded API usage for this session yet." +msgstr "このセッションにはまだ記録済みの API 使用量がありません。" + +#: src/iac_code/commands/status.py:54 +msgid "Turns" +msgstr "ターン" + +#: src/iac_code/commands/status.py:55 +msgid "Context" +msgstr "コンテキスト" + +#: src/iac_code/commands/status.py:57 +msgid "Session Status" +msgstr "セッション状態" + +#: src/iac_code/commands/status.py:73 +#, python-brace-format +msgid "{session_id} (resumed)" +msgstr "{session_id}(再開済み)" + +#: src/iac_code/commands/status.py:81 +#, python-brace-format +msgid "{percent} used ({total} / {window})" +msgstr "{percent} 使用済み({total} / {window})" + # Typer/Click built-in strings #: src/iac_code/i18n/__init__.py:51 msgid "Options" @@ -1063,79 +1333,79 @@ msgstr "" "API から無効な応答が返りました。API Base URL が正しいか確認してください(現在:{base_url})。 多くの OpenAI " "互換エンドポイントでは /v1 接尾辞が必要です(例:{base_url}/v1)。" -#: src/iac_code/providers/registry.py:415 +#: src/iac_code/providers/registry.py:416 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud 百錬" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud 百錬 Token Plan" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "OpenAPI Compatible" msgstr "OpenAPI 互換" -#: src/iac_code/providers/registry.py:422 +#: src/iac_code/providers/registry.py:423 msgid "Kimi (China)" msgstr "Kimi(中国版)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "Kimi (International)" msgstr "Kimi(国際版)" -#: src/iac_code/providers/registry.py:424 +#: src/iac_code/providers/registry.py:425 msgid "MiniMax (China)" msgstr "MiniMax(中国版)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "MiniMax (International)" msgstr "MiniMax(国際版)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI(国際版)" -#: src/iac_code/providers/registry.py:429 +#: src/iac_code/providers/registry.py:430 msgid "SiliconFlow (China)" msgstr "SiliconFlow(中国版)" -#: src/iac_code/providers/registry.py:430 +#: src/iac_code/providers/registry.py:431 msgid "SiliconFlow (International)" msgstr "SiliconFlow(国際版)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "Ollama (Local)" msgstr "Ollama(ローカル)" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "LM Studio (Local)" msgstr "LM Studio(ローカル)" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan(国際版)" -#: src/iac_code/providers/registry.py:438 +#: src/iac_code/providers/registry.py:439 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:439 +#: src/iac_code/providers/registry.py:440 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan(国際版)" -#: src/iac_code/providers/registry.py:440 +#: src/iac_code/providers/registry.py:441 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:441 +#: src/iac_code/providers/registry.py:442 msgid "Anthropic Compatible" msgstr "Anthropic 互換" @@ -1154,6 +1424,16 @@ msgstr "" "対処法:QwenPaw でサポートされているプロバイダーに切り替えるか、QwenPaw モードを無効にしてください(settings.yml から" " 'llm_source: qwenpaw' を削除)。" +#: src/iac_code/services/session_metadata.py:52 +#, python-brace-format +msgid "Session name must match {pattern}" +msgstr "セッション名は {pattern} に一致する必要があります" + +#: src/iac_code/services/session_storage.py:241 +#, python-brace-format +msgid "Session name already exists in this project: {name}" +msgstr "このプロジェクトにはセッション名がすでに存在します: {name}" + #: src/iac_code/services/permissions/loader.py:50 #, python-brace-format msgid "Invalid --permission-mode {!r}. Valid values: {}" @@ -1165,15 +1445,135 @@ msgstr "無効な --permission-mode {!r} です。有効な値: {}" msgid "Allow {}?" msgstr "{} を許可しますか?" -#: src/iac_code/skills/skill_tool.py:130 +#: src/iac_code/services/providers/aliyun.py:144 +msgid "Alibaba Cloud OAuth site is missing." +msgstr "Alibaba Cloud OAuth サイトがありません。" + +#: src/iac_code/services/providers/aliyun.py:150 +msgid "Alibaba Cloud OAuth refresh token is missing." +msgstr "Alibaba Cloud OAuth リフレッシュトークンがありません。" + +#: src/iac_code/services/providers/aliyun.py:158 +msgid "Alibaba Cloud OAuth access token is missing." +msgstr "Alibaba Cloud OAuth アクセストークンがありません。" + +#: src/iac_code/services/providers/aliyun_oauth.py:83 +msgid "Run /auth and choose OAuth Login (Browser)." +msgstr "/auth を実行し、OAuth ログイン(ブラウザー)を選択してください。" + +#: src/iac_code/services/providers/aliyun_oauth.py:106 +#, python-brace-format +msgid "Unknown Aliyun OAuth site: {site_type}" +msgstr "不明な Aliyun OAuth サイト: {site_type}" + +#: src/iac_code/services/providers/aliyun_oauth.py:164 +msgid "Not found" +msgstr "見つかりません" + +#: src/iac_code/services/providers/aliyun_oauth.py:170 +msgid "invalid state" +msgstr "無効な状態" + +#: src/iac_code/services/providers/aliyun_oauth.py:171 +msgid "Invalid state" +msgstr "無効な状態" + +#: src/iac_code/services/providers/aliyun_oauth.py:176 +msgid "code not found" +msgstr "認可コードが見つかりません" + +#: src/iac_code/services/providers/aliyun_oauth.py:177 +msgid "Authorization code not found" +msgstr "認可コードが見つかりません" + +#: src/iac_code/services/providers/aliyun_oauth.py:181 +msgid "Authorization successful. You can close this window." +msgstr "認可に成功しました。このウィンドウを閉じてもかまいません。" + +#: src/iac_code/services/providers/aliyun_oauth.py:212 +#, python-brace-format +msgid "No available callback port in range {start}-{end}" +msgstr "{start}-{end} の範囲に利用可能なコールバックポートがありません" + +#: src/iac_code/services/providers/aliyun_oauth.py:227 +msgid "OAuth login cancelled." +msgstr "OAuth ログインをキャンセルしました。" + +#: src/iac_code/services/providers/aliyun_oauth.py:278 +msgid "Open in your browser:" +msgstr "ブラウザーで開く:" + +#: src/iac_code/services/providers/aliyun_oauth.py:299 +msgid "Waiting for browser authorization" +msgstr "ブラウザー認可を待機しています" + +#: src/iac_code/services/providers/aliyun_oauth.py:300 +msgid "" +"1. The browser may show official-cli; this is the Alibaba Cloud official " +"CLI OAuth application." +msgstr "" +"1. ブラウザーに official-cli と表示される場合があります。これは Alibaba Cloud 公式 CLI OAuth " +"アプリケーションです。" + +#: src/iac_code/services/providers/aliyun_oauth.py:302 +msgid "" +"2. If assignment is required, assign the RAM user or RAM role that is " +"signed in. User groups are not supported." +msgstr "2. 割り当てが必要な場合は、サインイン中の RAM ユーザーまたは RAM ロールを割り当ててください。ユーザーグループはサポートされていません。" + +#: src/iac_code/services/providers/aliyun_oauth.py:306 +msgid "" +"3. After assignment, close the old authorization page and run OAuth Login" +" (Browser) again. If it still fails, sign out of Alibaba Cloud and sign " +"in again." +msgstr "" +"3. 割り当て後、古い認可ページを閉じて OAuth ログイン(ブラウザー)を再実行してください。それでも失敗する場合は、Alibaba " +"Cloud からサインアウトして再度サインインしてください。" + +#: src/iac_code/services/providers/aliyun_oauth.py:310 +msgid "" +"4. STS credentials refresh when possible until Alibaba Cloud expires " +"them. If refresh fails, run /auth again." +msgstr "" +"4. STS 認証情報は Alibaba Cloud が期限切れにするまで可能な場合に更新されます。更新に失敗した場合は、/auth " +"を再実行してください。" + +#: src/iac_code/services/providers/aliyun_oauth.py:313 +msgid "Press Esc to cancel while waiting." +msgstr "Esc を押すと待機をキャンセルできます。" + +#: src/iac_code/services/providers/aliyun_oauth.py:321 +msgid "" +"Timed out waiting for OAuth callback. If Alibaba Cloud asked you to " +"assign the official-cli application, assign it to the exact RAM user or " +"RAM role currently signed in. User groups are not supported. Then close " +"the old authorization page, sign out of Alibaba Cloud and sign in again " +"if needed, and run /auth to choose OAuth Login (Browser) again." +msgstr "" +"OAuth コールバックの待機がタイムアウトしました。Alibaba Cloud から official-cli " +"アプリケーションの割り当てを求められた場合は、現在サインインしている正確な RAM ユーザーまたは RAM " +"ロールに割り当ててください。ユーザーグループはサポートされていません。その後、古い認可ページを閉じ、必要に応じて Alibaba Cloud " +"からサインアウトしてサインインし直し、/auth を実行して OAuth ログイン(ブラウザー)を再度選択してください。" + +#: src/iac_code/skills/skill_tool.py:85 src/iac_code/ui/repl.py:842 +#, python-brace-format +msgid "Skill '{name}' is disabled. Run /skills to enable it." +msgstr "スキル「{name}」は無効です。有効にするには /skills を実行してください。" + +#: src/iac_code/skills/skill_tool.py:137 #, python-brace-format msgid "Skill '{name}' loaded (inline)." msgstr "スキル '{name}' を読み込みました(インライン)。" -#: src/iac_code/skills/skill_tool.py:214 +#: src/iac_code/skills/skill_tool.py:221 msgid "Skill" msgstr "スキル" +#: src/iac_code/skills/skill_tool.py:245 +#, python-brace-format +msgid "Skill disabled: {name}" +msgstr "スキルが無効です: {name}" + #: src/iac_code/skills/bundled/simplify.py:25 msgid "" "Review changed code for reuse, quality, and efficiency, then fix issues " @@ -1444,7 +1844,7 @@ msgstr "CloudAPI" msgid "Calling {action}..." msgstr "{action} を呼び出しています…" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:390 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:400 #: src/iac_code/tools/cloud/base_api.py:123 msgid "Call succeeded" msgstr "呼び出しに成功しました" @@ -1604,11 +2004,11 @@ msgstr "インポート完了" msgid "IMPORT_FAILED" msgstr "インポート失敗" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:171 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:172 msgid "Aliyun API" msgstr "Aliyun API" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:389 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:399 #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" msgstr "呼び出しに成功しました(RequestId: {request_id})" @@ -1760,23 +2160,19 @@ msgstr "リリースノート" msgid "Run {} to update." msgstr "更新するには {} を実行してください。" -#: src/iac_code/ui/banner.py:105 +#: src/iac_code/ui/banner.py:110 msgid "Your AI-powered Infrastructure as Code assistant" msgstr "あなたの AI 駆動の Infrastructure as Code アシスタントです" -#: src/iac_code/ui/banner.py:131 +#: src/iac_code/ui/banner.py:144 msgid "Welcome back" msgstr "おかえりなさい" -#: src/iac_code/ui/banner.py:138 -msgid "Session" -msgstr "セッション" - -#: src/iac_code/ui/banner.py:148 +#: src/iac_code/ui/banner.py:161 msgid "Debug mode" msgstr "デバッグモード" -#: src/iac_code/ui/banner.py:149 +#: src/iac_code/ui/banner.py:162 msgid "Log file" msgstr "ログファイル" @@ -1873,99 +2269,111 @@ msgstr "いいえ、常に \"{rule}\" を拒否(このセッション)" msgid "No, always reject this tool" msgstr "いいえ、このツールは常に拒否" -#: src/iac_code/ui/repl.py:370 +#: src/iac_code/ui/repl.py:424 msgid "Press Ctrl+C again to exit." msgstr "終了するには Ctrl+C をもう一度押してください。" -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:449 msgid "Interrupted." msgstr "中断しました。" -#: src/iac_code/ui/repl.py:432 -msgid "Goodbye!" -msgstr "さようなら。" - -#: src/iac_code/ui/repl.py:433 -msgid "Resume this session with:" -msgstr "このセッションを再開するには次を実行してください:" - -#: src/iac_code/ui/repl.py:458 +#: src/iac_code/ui/repl.py:508 msgid "Update now" msgstr "今すぐ更新" -#: src/iac_code/ui/repl.py:460 +#: src/iac_code/ui/repl.py:510 msgid "Run the shown update command and exit when it succeeds." msgstr "表示された更新コマンドを実行し、成功したら終了します。" -#: src/iac_code/ui/repl.py:463 +#: src/iac_code/ui/repl.py:513 msgid "Skip" msgstr "スキップ" -#: src/iac_code/ui/repl.py:465 +#: src/iac_code/ui/repl.py:515 msgid "Continue with the current version for this session." msgstr "このセッションでは現在のバージョンを使い続けます。" -#: src/iac_code/ui/repl.py:468 +#: src/iac_code/ui/repl.py:518 msgid "Skip until next version" msgstr "次のバージョンまでスキップ" -#: src/iac_code/ui/repl.py:470 +#: src/iac_code/ui/repl.py:520 msgid "Hide this update until a newer version is available." msgstr "より新しいバージョンが利用可能になるまで、この更新を非表示にします。" -#: src/iac_code/ui/repl.py:489 src/iac_code/ui/repl.py:501 +#: src/iac_code/ui/repl.py:539 src/iac_code/ui/repl.py:551 msgid "Update command failed. Continuing with the current version." msgstr "更新コマンドに失敗しました。現在のバージョンで続行します。" -#: src/iac_code/ui/repl.py:494 +#: src/iac_code/ui/repl.py:544 msgid "Update completed. Restart iac-code to continue." msgstr "更新が完了しました。続行するには iac-code を再起動してください。" -#: src/iac_code/ui/repl.py:532 +#: src/iac_code/ui/repl.py:582 msgid "No image in clipboard." msgstr "クリップボードに画像がありません。" -#: src/iac_code/ui/repl.py:718 +#: src/iac_code/ui/repl.py:768 msgid "Usage: !" msgstr "使用方法: !" -#: src/iac_code/ui/repl.py:723 +#: src/iac_code/ui/repl.py:775 msgid "Shell command support is unavailable." msgstr "シェルコマンドのサポートは利用できません。" -#: src/iac_code/ui/repl.py:787 +#: src/iac_code/ui/repl.py:845 #, python-brace-format msgid "Unknown skill: ${name}. Type / to list commands and skills." msgstr "不明なスキル: ${name}。/ を入力するとコマンドとスキルを一覧表示します。" -#: src/iac_code/ui/repl.py:789 +#: src/iac_code/ui/repl.py:847 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" "Unknown command: /{name}. Type /help for available " "commands.不明なコマンドです:/{name}。利用可能なコマンドは /help を入力してください。" -#: src/iac_code/ui/repl.py:794 +#: src/iac_code/ui/repl.py:852 #, python-brace-format msgid "$ only invokes skills. Use /{name} instead." msgstr "$ はスキルのみを呼び出します。代わりに /{name} を使用してください。" -#: src/iac_code/ui/repl.py:816 src/iac_code/ui/repl.py:861 +#: src/iac_code/ui/repl.py:874 src/iac_code/ui/repl.py:922 #, python-brace-format msgid "Command error: {error}" msgstr "コマンドエラー:{error}" -#: src/iac_code/ui/repl.py:823 +#: src/iac_code/ui/repl.py:881 #, python-brace-format msgid "Command has no handler: {name}" msgstr "ハンドラーがないコマンドです:{name}" -#: src/iac_code/ui/repl.py:1128 +#: src/iac_code/ui/repl.py:1156 +msgid "Goodbye!" +msgstr "さようなら。" + +#: src/iac_code/ui/repl.py:1157 +msgid "Resume this session with:" +msgstr "このセッションを再開するには次を実行してください:" + +#: src/iac_code/ui/repl.py:1160 +msgid "Session ID" +msgstr "セッション ID" + +#: src/iac_code/ui/repl.py:1210 src/iac_code/ui/repl.py:1214 #, python-brace-format msgid "Session not found: {session_id}" msgstr "セッションが見つかりません:{session_id}" -#: src/iac_code/ui/repl.py:1147 +#: src/iac_code/ui/repl.py:1263 +msgid "Session name: " +msgstr "セッション名: " + +#: src/iac_code/ui/repl.py:1269 +msgid "Session name cannot be empty." +msgstr "セッション名は空にできません。" + +#: src/iac_code/ui/repl.py:1279 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1976,31 +2384,35 @@ msgstr "" "再開するには次を実行してください:\n" " {cmd}" -#: src/iac_code/ui/repl.py:1186 +#: src/iac_code/ui/repl.py:1283 +msgid "Multiple sessions match. Resume one by ID:" +msgstr "複数のセッションが一致しました。ID でいずれかを再開してください:" + +#: src/iac_code/ui/repl.py:1396 msgid "This conversation is from a different directory." msgstr "この会話は別のディレクトリ由来です。" -#: src/iac_code/ui/repl.py:1188 +#: src/iac_code/ui/repl.py:1398 msgid "To resume, run:" msgstr "再開するには次を実行してください:" -#: src/iac_code/ui/repl.py:1193 +#: src/iac_code/ui/repl.py:1403 msgid "(Command copied to clipboard)" msgstr "(コマンドをクリップボードにコピーしました)" -#: src/iac_code/ui/repl.py:1350 +#: src/iac_code/ui/repl.py:1560 #, python-brace-format msgid "" "Current model {model} does not support image input. Use /model to switch " "to a vision-capable model." msgstr "現在のモデル {model} は画像入力をサポートしていません。/model を使用してビジョン対応モデルに切り替えてください。" -#: src/iac_code/ui/repl.py:1359 +#: src/iac_code/ui/repl.py:1569 #, python-brace-format msgid "Image error: {err}" msgstr "画像エラー:{err}" -#: src/iac_code/ui/repl.py:1376 +#: src/iac_code/ui/repl.py:1586 msgid "" "Failed to persist image to cache; it will only exist in memory for this " "turn." @@ -2094,91 +2506,190 @@ msgstr "入力してファイルを検索…" msgid "No matching files" msgstr "一致するファイルがありません" -#: src/iac_code/ui/dialogs/resume_picker.py:114 +#: src/iac_code/ui/dialogs/resume_picker.py:116 msgid "Search..." msgstr "検索…" -#: src/iac_code/ui/dialogs/resume_picker.py:359 +#: src/iac_code/ui/dialogs/resume_picker.py:374 msgid "Resume Session" msgstr "セッションを再開" -#: src/iac_code/ui/dialogs/resume_picker.py:369 +#: src/iac_code/ui/dialogs/resume_picker.py:384 msgid "No sessions found" msgstr "セッションが見つかりません" -#: src/iac_code/ui/dialogs/resume_picker.py:427 +#: src/iac_code/ui/dialogs/resume_picker.py:444 msgid "show current dir" msgstr "現在のディレクトリのみ表示" -#: src/iac_code/ui/dialogs/resume_picker.py:429 +#: src/iac_code/ui/dialogs/resume_picker.py:446 msgid "show all projects" msgstr "すべてのプロジェクトを表示" -#: src/iac_code/ui/dialogs/resume_picker.py:432 +#: src/iac_code/ui/dialogs/resume_picker.py:449 msgid "show all branches" msgstr "すべてのブランチを表示" -#: src/iac_code/ui/dialogs/resume_picker.py:434 +#: src/iac_code/ui/dialogs/resume_picker.py:451 msgid "only show current branch" msgstr "現在のブランチのみ表示" -#: src/iac_code/ui/dialogs/resume_picker.py:435 +#: src/iac_code/ui/dialogs/resume_picker.py:452 msgid "preview" msgstr "プレビュー" -#: src/iac_code/ui/dialogs/resume_picker.py:436 +#: src/iac_code/ui/dialogs/resume_picker.py:453 msgid "Type to search" msgstr "入力して検索" -#: src/iac_code/ui/dialogs/resume_picker.py:437 +#: src/iac_code/ui/dialogs/resume_picker.py:454 msgid "cancel" msgstr "キャンセル" -#: src/iac_code/ui/dialogs/resume_picker.py:552 +#: src/iac_code/ui/dialogs/resume_picker.py:569 #, python-brace-format msgid "{n} more line{s}" msgstr "あと {n} 行{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:566 +#: src/iac_code/ui/dialogs/resume_picker.py:583 #, python-brace-format msgid "{n} message{s}" msgstr "{n} 件のメッセージ{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:580 +#: src/iac_code/ui/dialogs/resume_picker.py:597 msgid "resume" msgstr "再開" -#: src/iac_code/ui/dialogs/resume_picker.py:584 +#: src/iac_code/ui/dialogs/resume_picker.py:601 msgid "back" msgstr "戻る" -#: src/iac_code/ui/dialogs/resume_picker.py:589 +#: src/iac_code/ui/dialogs/resume_picker.py:606 msgid "scroll" msgstr "スクロール" -#: src/iac_code/ui/dialogs/resume_picker.py:608 +#: src/iac_code/ui/dialogs/resume_picker.py:625 msgid "(empty session)" msgstr "(空のセッション)" -#: src/iac_code/ui/dialogs/resume_picker.py:728 +#: src/iac_code/ui/dialogs/resume_picker.py:745 msgid "just now" msgstr "たった今" -#: src/iac_code/ui/dialogs/resume_picker.py:731 +#: src/iac_code/ui/dialogs/resume_picker.py:748 #, python-brace-format msgid "{n} minute{s} ago" msgstr "{n} 分{s}前" -#: src/iac_code/ui/dialogs/resume_picker.py:734 +#: src/iac_code/ui/dialogs/resume_picker.py:751 #, python-brace-format msgid "{n} hour{s} ago" msgstr "{n} 時間{s}前" -#: src/iac_code/ui/dialogs/resume_picker.py:736 +#: src/iac_code/ui/dialogs/resume_picker.py:753 #, python-brace-format msgid "{n} day{s} ago" msgstr "{n} 日{s}前" +#: src/iac_code/ui/dialogs/skills_picker.py:52 +msgid "Search skills..." +msgstr "スキルを検索..." + +#: src/iac_code/ui/dialogs/skills_picker.py:159 +msgid "Skills" +msgstr "スキル" + +#: src/iac_code/ui/dialogs/skills_picker.py:161 +#, python-brace-format +msgid "{current} of {total}" +msgstr "{total} 件中 {current}" + +#: src/iac_code/ui/dialogs/skills_picker.py:165 +#, python-brace-format +msgid "" +"{count} skills - Space to toggle, Enter to save, Tab to sort, Esc to " +"cancel" +msgstr "{count} 個のスキル - Space で切り替え、Enter で保存、Tab で並べ替え、Esc でキャンセル" + +#: src/iac_code/ui/dialogs/skills_picker.py:171 +#, python-brace-format +msgid "Sort: {mode}" +msgstr "並べ替え: {mode}" + +#: src/iac_code/ui/dialogs/skills_picker.py:176 +msgid "No skills found" +msgstr "スキルが見つかりません" + +#: src/iac_code/ui/dialogs/skills_picker.py:245 +msgid "Bundled skills cannot be disabled." +msgstr "バンドルスキルは無効にできません。" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "on" +msgstr "有効" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "off" +msgstr "無効" + +#: src/iac_code/ui/dialogs/skills_picker.py:265 +msgid "locked" +msgstr "ロック済み" + +#: src/iac_code/ui/dialogs/skills_picker.py:268 +msgid "matched description" +msgstr "説明に一致" + +#: src/iac_code/ui/dialogs/skills_picker.py:277 +msgid "source" +msgstr "提供元" + +#: src/iac_code/ui/dialogs/skills_picker.py:279 +msgid "size" +msgstr "サイズ" + +#: src/iac_code/ui/dialogs/skills_picker.py:280 +msgid "name" +msgstr "名前" + +#: src/iac_code/ui/dialogs/skills_picker.py:285 +msgid "bundled" +msgstr "バンドル" + +#: src/iac_code/ui/dialogs/skills_picker.py:287 +msgid "project" +msgstr "プロジェクト" + +#: src/iac_code/ui/dialogs/skills_picker.py:289 +msgid "user" +msgstr "ユーザー" + +#: src/iac_code/ui/dialogs/skills_picker.py:296 +#, python-brace-format +msgid "~{count}k tokens" +msgstr "約{count}kトークン" + +#: src/iac_code/ui/dialogs/skills_picker.py:297 +#, python-brace-format +msgid "~{count} tokens" +msgstr "約{count}トークン" + +#: src/iac_code/ui/suggestions/command_provider.py:79 +msgid "Search saved memories" +msgstr "保存済みメモリを検索" + +#: src/iac_code/ui/suggestions/command_provider.py:80 +msgid "Delete a saved memory" +msgstr "保存済みメモリを削除" + +#: src/iac_code/ui/suggestions/command_provider.py:81 +msgid "Show memory command help" +msgstr "memory コマンドのヘルプを表示" + +#: src/iac_code/ui/suggestions/command_provider.py:116 +msgid "Saved memory" +msgstr "保存済みメモリ" + #: src/iac_code/utils/platform.py:39 msgid "iac-code on Windows requires Git for Windows." msgstr "iac-code を Windows で使用するには Git for Windows が必要です。" @@ -2244,3 +2755,15 @@ msgstr " オプション 2 - github.com にアクセスできない場合は、 #~ msgid " Option 2 - npmmirror (China-friendly mirror):" #~ msgstr " 方法 2 - npmmirror(中国向けミラー):" +#~ msgid "Cache create" +#~ msgstr "キャッシュ作成" + +#~ msgid "" +#~ "{count} skills - Space to toggle, " +#~ "Enter to save, / to search, t " +#~ "to sort, Esc to cancel" +#~ msgstr "{count} 個のスキル - Space で切り替え、Enter で保存、/ で検索、t で並べ替え、Esc でキャンセル" + +#~ msgid "Resume a session by ID" +#~ msgstr "ID でセッションを再開する" + diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index a1fbd13..7858875 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -4,9 +4,9 @@ # msgid "" msgstr "" -"Project-Id-Version: iac-code 0.3.0\n" +"Project-Id-Version: iac-code 0.4.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-01 17:56+0800\n" +"POT-Creation-Date: 2026-06-03 13:32+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: pt\n" @@ -32,7 +32,22 @@ msgstr "" "O transporte de socket de domínio Unix não é suportado no Windows. Use " "--transport http ou --transport stdio." -#: src/iac_code/acp/slash_registry.py:44 +#: src/iac_code/acp/server.py:413 src/iac_code/acp/server.py:429 +#: src/iac_code/acp/server.py:456 +msgid "Session not found" +msgstr "Sessão não encontrada" + +#: src/iac_code/acp/server.py:416 +#, python-brace-format +msgid "Session name is ambiguous. Candidates: {candidates}" +msgstr "O nome da sessão é ambíguo. Candidatas: {candidates}" + +#: src/iac_code/acp/server.py:434 src/iac_code/acp/server.py:708 +#, python-brace-format +msgid "Session belongs to another project. Run: {hint}" +msgstr "A sessão pertence a outro projeto. Execute: {hint}" + +#: src/iac_code/acp/slash_registry.py:46 #, python-brace-format msgid "" "Command '/{cmd_name}' is not supported over ACP. Supported commands: " @@ -41,21 +56,21 @@ msgstr "" "O comando '/{cmd_name}' não é compatível com ACP. Comandos compatíveis: " "{supported}" -#: src/iac_code/acp/slash_registry.py:56 +#: src/iac_code/acp/slash_registry.py:62 #, python-brace-format msgid "Command '/{cmd_name}' handler not implemented." msgstr "Tratador do comando '/{cmd_name}' não implementado." -#: src/iac_code/acp/slash_registry.py:68 +#: src/iac_code/acp/slash_registry.py:74 #, python-brace-format msgid "Compaction failed: {error}" msgstr "Falha ao compactar: {error}" -#: src/iac_code/acp/slash_registry.py:71 src/iac_code/commands/compact.py:24 +#: src/iac_code/acp/slash_registry.py:77 src/iac_code/commands/compact.py:24 msgid "Nothing to compact: conversation is empty." msgstr "Nada para compactar: a conversa está vazia." -#: src/iac_code/acp/slash_registry.py:74 src/iac_code/commands/compact.py:27 +#: src/iac_code/acp/slash_registry.py:80 src/iac_code/commands/compact.py:27 #, python-brace-format msgid "" "Conversation too short to compact: all messages are within the recent " @@ -64,11 +79,11 @@ msgstr "" "Conversa curta demais para compactar: todas as mensagens estão na janela " "de preservação das últimas {turns} interações." -#: src/iac_code/acp/slash_registry.py:78 src/iac_code/commands/compact.py:30 +#: src/iac_code/acp/slash_registry.py:84 src/iac_code/commands/compact.py:30 msgid "Compaction failed. See logs for details." msgstr "Falha na compactação. Consulte os logs para detalhes." -#: src/iac_code/acp/slash_registry.py:83 +#: src/iac_code/acp/slash_registry.py:89 #, python-brace-format msgid "" "Context compacted: {original} → {compacted} tokens ({percent} reduction)." @@ -77,39 +92,61 @@ msgstr "" "Contexto compactado: {original} → {compacted} tokens (redução de " "{percent}). Uso do contexto: {usage}" -#: src/iac_code/acp/slash_registry.py:97 +#: src/iac_code/acp/slash_registry.py:103 #, python-brace-format msgid "Clear failed: {error}" msgstr "Falha ao limpar: {error}" -#: src/iac_code/acp/slash_registry.py:98 +#: src/iac_code/acp/slash_registry.py:104 msgid "Conversation history cleared." msgstr "Histórico da conversa limpo." -#: src/iac_code/acp/slash_registry.py:114 src/iac_code/commands/debug.py:34 +#: src/iac_code/acp/slash_registry.py:120 src/iac_code/commands/debug.py:34 #, python-brace-format msgid "Debug logging is on. Log file: {path}" msgstr "Debug ativado. Arquivo de log: {path}" -#: src/iac_code/acp/slash_registry.py:115 src/iac_code/commands/debug.py:35 +#: src/iac_code/acp/slash_registry.py:121 src/iac_code/commands/debug.py:35 msgid "Debug logging is off." msgstr "Debug desativado." -#: src/iac_code/acp/slash_registry.py:119 src/iac_code/commands/debug.py:39 +#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:39 #, python-brace-format msgid "Debug logging enabled. Log file: {path}" msgstr "Debug ativado. Arquivo de log: {path}" -#: src/iac_code/acp/slash_registry.py:123 src/iac_code/commands/debug.py:43 +#: src/iac_code/acp/slash_registry.py:129 src/iac_code/commands/debug.py:43 msgid "Debug logging disabled." msgstr "Debug desativado." -#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:45 +#: src/iac_code/acp/slash_registry.py:131 src/iac_code/commands/debug.py:45 msgid "Usage: /debug [on|off]" msgstr "Uso: /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:404 src/iac_code/agent/agent_loop.py:419 -#: src/iac_code/ui/repl.py:757 src/iac_code/ui/repl.py:771 +#: src/iac_code/acp/slash_registry.py:136 src/iac_code/commands/memory.py:84 +msgid "Memory manager is unavailable." +msgstr "O gerenciador de memória está indisponível." + +#: src/iac_code/acp/slash_registry.py:146 src/iac_code/commands/rename.py:21 +msgid "Usage: /rename " +msgstr "Uso: /rename " + +#: src/iac_code/acp/slash_registry.py:152 +msgid "Rename is only available after a session is created." +msgstr "Renomear só fica disponível depois que uma sessão é criada." + +#: src/iac_code/acp/slash_registry.py:163 src/iac_code/commands/rename.py:42 +#, python-brace-format +msgid "Session is already named {name}" +msgstr "A sessão já se chama {name}" + +#: src/iac_code/acp/slash_registry.py:164 src/iac_code/commands/rename.py:43 +#, python-brace-format +msgid "Renamed session to {name}" +msgstr "Sessão renomeada para {name}" + +#: src/iac_code/agent/agent_loop.py:413 src/iac_code/agent/agent_loop.py:428 +#: src/iac_code/ui/repl.py:813 src/iac_code/ui/repl.py:827 msgid "Permission denied." msgstr "Permissão negada." @@ -278,8 +315,8 @@ msgid "Show version and exit" msgstr "Mostrar versão e sair" #: src/iac_code/cli/main.py:89 -msgid "Resume a session by ID" -msgstr "Retomar uma sessão pelo ID" +msgid "Resume a session by ID or name" +msgstr "Retomar uma sessão por ID ou nome" #: src/iac_code/cli/main.py:90 msgid "Resume the most recent session" @@ -603,270 +640,368 @@ msgstr "Diretório para rotas A2A persistidas" msgid "Save the provided routes as a route snapshot" msgstr "Salva as rotas fornecidas como um snapshot de rotas" -#: src/iac_code/commands/__init__.py:22 +#: src/iac_code/commands/__init__.py:26 msgid "Show available commands" msgstr "Mostrar comandos disponíveis" -#: src/iac_code/commands/__init__.py:31 +#: src/iac_code/commands/__init__.py:35 msgid "Clear conversation history" msgstr "Limpar histórico da conversa" -#: src/iac_code/commands/__init__.py:39 +#: src/iac_code/commands/__init__.py:43 msgid "Show or switch model" msgstr "Exibir ou trocar modelo" -#: src/iac_code/commands/__init__.py:48 +#: src/iac_code/commands/__init__.py:52 msgid "Show or switch thinking effort" msgstr "Exibir ou alterar o nível de esforço de raciocínio" -#: src/iac_code/commands/__init__.py:57 +#: src/iac_code/commands/__init__.py:61 msgid "Compact conversation context" msgstr "Compactar contexto da conversa" -#: src/iac_code/commands/__init__.py:59 +#: src/iac_code/commands/__init__.py:63 msgid "Compacting conversation" msgstr "Compactando conversa" -#: src/iac_code/commands/__init__.py:66 +#: src/iac_code/commands/__init__.py:70 msgid "Exit the application" msgstr "Encerrar o aplicativo" -#: src/iac_code/commands/__init__.py:75 +#: src/iac_code/commands/__init__.py:79 msgid "Authenticate with LLM provider" msgstr "Autenticar com o provedor LLM" -#: src/iac_code/commands/__init__.py:84 +#: src/iac_code/commands/__init__.py:88 msgid "Toggle debug logging" msgstr "Alternar debug" -#: src/iac_code/commands/__init__.py:93 +#: src/iac_code/commands/__init__.py:97 +msgid "View and manage persistent memories" +msgstr "Ver e gerenciar memórias persistentes" + +#: src/iac_code/commands/__init__.py:99 +msgid "[|search |delete |help]" +msgstr "[|search |delete |help]" + +#: src/iac_code/commands/__init__.py:106 msgid "Resume a previous session" msgstr "Retomar uma sessão anterior" -#: src/iac_code/commands/__init__.py:95 +#: src/iac_code/commands/__init__.py:108 msgid "[conversation id or search term]" msgstr "[ID da conversa ou termo de busca]" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:1107 +#: src/iac_code/commands/__init__.py:115 +msgid "Rename the current session" +msgstr "Renomear a sessão atual" + +#: src/iac_code/commands/__init__.py:124 +msgid "Manage skills" +msgstr "Gerenciar habilidades" + +#: src/iac_code/commands/__init__.py:132 +msgid "Show current session status" +msgstr "Mostrar o status atual da sessão" + +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:1117 #: src/iac_code/ui/core/prompt_input.py:557 msgid "Navigate" msgstr "Navegar" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:530 -#: src/iac_code/commands/auth.py:562 src/iac_code/commands/auth.py:569 -#: src/iac_code/commands/auth.py:604 src/iac_code/commands/auth.py:611 -#: src/iac_code/commands/auth.py:632 src/iac_code/commands/auth.py:1107 -#: src/iac_code/commands/auth.py:1319 src/iac_code/ui/core/prompt_input.py:557 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:538 +#: src/iac_code/commands/auth.py:570 src/iac_code/commands/auth.py:577 +#: src/iac_code/commands/auth.py:612 src/iac_code/commands/auth.py:619 +#: src/iac_code/commands/auth.py:640 src/iac_code/commands/auth.py:1117 +#: src/iac_code/commands/auth.py:1551 src/iac_code/ui/core/prompt_input.py:557 msgid "Confirm" msgstr "Confirmar" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:528 -#: src/iac_code/commands/auth.py:530 src/iac_code/commands/auth.py:562 -#: src/iac_code/commands/auth.py:569 src/iac_code/commands/auth.py:604 -#: src/iac_code/commands/auth.py:611 src/iac_code/commands/auth.py:632 -#: src/iac_code/commands/auth.py:1107 src/iac_code/commands/auth.py:1217 -#: src/iac_code/commands/auth.py:1319 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:536 +#: src/iac_code/commands/auth.py:538 src/iac_code/commands/auth.py:570 +#: src/iac_code/commands/auth.py:577 src/iac_code/commands/auth.py:612 +#: src/iac_code/commands/auth.py:619 src/iac_code/commands/auth.py:640 +#: src/iac_code/commands/auth.py:1117 src/iac_code/commands/auth.py:1443 +#: src/iac_code/commands/auth.py:1551 msgid "Back" msgstr "Voltar" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Keep" msgstr "Manter" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Re-enter" msgstr "Digitar novamente" -#: src/iac_code/commands/auth.py:741 src/iac_code/commands/auth.py:853 -#: src/iac_code/commands/auth.py:911 src/iac_code/commands/auth.py:919 -#: src/iac_code/commands/auth.py:946 +#: src/iac_code/commands/auth.py:749 src/iac_code/commands/auth.py:863 +#: src/iac_code/commands/auth.py:921 src/iac_code/commands/auth.py:929 +#: src/iac_code/commands/auth.py:956 msgid " (current)" msgstr " (atual)" -#: src/iac_code/commands/auth.py:744 +#: src/iac_code/commands/auth.py:752 msgid "Custom model..." msgstr "Modelo personalizado..." -#: src/iac_code/commands/auth.py:747 +#: src/iac_code/commands/auth.py:755 #, python-brace-format msgid "Select model for {provider}" msgstr "Selecionar modelo para {provider}" -#: src/iac_code/commands/auth.py:749 +#: src/iac_code/commands/auth.py:757 msgid "Select model" msgstr "Selecionar modelo" -#: src/iac_code/commands/auth.py:757 +#: src/iac_code/commands/auth.py:765 msgid "Enter custom model name: " msgstr "Informe o nome do modelo personalizado: " -#: src/iac_code/commands/auth.py:783 +#: src/iac_code/commands/auth.py:791 msgid "Error: console not available" msgstr "Erro: console indisponível" -#: src/iac_code/commands/auth.py:810 +#: src/iac_code/commands/auth.py:820 msgid "Configure LLM Provider" msgstr "Configurar provedor LLM" -#: src/iac_code/commands/auth.py:811 +#: src/iac_code/commands/auth.py:821 msgid "Configure IaC Cloud Service" msgstr "Configurar serviço de nuvem IaC" -#: src/iac_code/commands/auth.py:813 +#: src/iac_code/commands/auth.py:823 msgid "Select configuration type" msgstr "Selecionar tipo de configuração" -#: src/iac_code/commands/auth.py:815 src/iac_code/commands/auth.py:971 -#: src/iac_code/commands/auth.py:987 src/iac_code/commands/auth.py:1066 -#: src/iac_code/commands/auth.py:1258 src/iac_code/commands/auth.py:1299 +#: src/iac_code/commands/auth.py:825 src/iac_code/commands/auth.py:981 +#: src/iac_code/commands/auth.py:997 src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1490 src/iac_code/commands/auth.py:1531 msgid "Auth cancelled" msgstr "Autenticação cancelada" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:951 -#: src/iac_code/commands/auth.py:1041 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:961 +#: src/iac_code/commands/auth.py:1051 #, python-brace-format msgid "Select provider — {group}" msgstr "Selecionar provedor — {group}" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:909 -#: src/iac_code/commands/auth.py:1026 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:919 +#: src/iac_code/commands/auth.py:1036 msgid "Third-party" msgstr "Terceiros" -#: src/iac_code/commands/auth.py:867 +#: src/iac_code/commands/auth.py:877 #, python-brace-format msgid "{status}: {provider}" msgstr "{status}: {provider}" -#: src/iac_code/commands/auth.py:868 src/iac_code/commands/auth.py:1019 +#: src/iac_code/commands/auth.py:878 src/iac_code/commands/auth.py:1029 msgid "Configured" msgstr "Configurado" -#: src/iac_code/commands/auth.py:923 +#: src/iac_code/commands/auth.py:933 msgid "Select provider" msgstr "Selecionar provedor" -#: src/iac_code/commands/auth.py:964 +#: src/iac_code/commands/auth.py:974 #, python-brace-format msgid "Configure {provider}" msgstr "Configurar {provider}" -#: src/iac_code/commands/auth.py:980 +#: src/iac_code/commands/auth.py:990 #, python-brace-format msgid "Enter API key for {provider}" msgstr "Informe a API key para {provider}" -#: src/iac_code/commands/auth.py:1018 +#: src/iac_code/commands/auth.py:1028 #, python-brace-format msgid "{status}: {provider} / {model}" msgstr "{status}: {provider} / {model}" -#: src/iac_code/commands/auth.py:1027 src/iac_code/commands/auth.py:1048 +#: src/iac_code/commands/auth.py:1037 src/iac_code/commands/auth.py:1058 msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:1028 src/iac_code/providers/registry.py:426 +#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:427 msgid "ZhiPu AI" msgstr "ZhiPu AI" -#: src/iac_code/commands/auth.py:1029 +#: src/iac_code/commands/auth.py:1039 msgid "Kimi" msgstr "Kimi" -#: src/iac_code/commands/auth.py:1030 +#: src/iac_code/commands/auth.py:1040 msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:1031 src/iac_code/providers/registry.py:428 +#: src/iac_code/commands/auth.py:1041 src/iac_code/providers/registry.py:429 msgid "Volcengine" msgstr "Volcengine" -#: src/iac_code/commands/auth.py:1032 +#: src/iac_code/commands/auth.py:1042 msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:1033 src/iac_code/providers/registry.py:419 +#: src/iac_code/commands/auth.py:1043 src/iac_code/providers/registry.py:420 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:1034 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:1044 src/iac_code/providers/registry.py:418 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:1035 src/iac_code/providers/registry.py:418 +#: src/iac_code/commands/auth.py:1045 src/iac_code/providers/registry.py:419 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:1036 src/iac_code/providers/registry.py:421 +#: src/iac_code/commands/auth.py:1046 src/iac_code/providers/registry.py:422 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:1037 src/iac_code/providers/registry.py:434 +#: src/iac_code/commands/auth.py:1047 src/iac_code/providers/registry.py:435 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:433 +#: src/iac_code/commands/auth.py:1048 src/iac_code/providers/registry.py:434 msgid "OpenRouter" msgstr "OpenRouter" -#: src/iac_code/commands/auth.py:1039 +#: src/iac_code/commands/auth.py:1049 msgid "Local" msgstr "Local" -#: src/iac_code/commands/auth.py:1040 +#: src/iac_code/commands/auth.py:1050 msgid "Compatible" msgstr "Compatível" -#: src/iac_code/commands/auth.py:1057 +#: src/iac_code/commands/auth.py:1067 msgid "Select Cloud Provider" msgstr "Selecionar provedor de nuvem" -#: src/iac_code/commands/auth.py:1073 +#: src/iac_code/commands/auth.py:1083 msgid "Credential" msgstr "Credencial" -#: src/iac_code/commands/auth.py:1074 src/iac_code/commands/auth.py:1190 -#: src/iac_code/commands/auth.py:1295 src/iac_code/ui/renderer.py:455 +#: src/iac_code/commands/auth.py:1084 src/iac_code/commands/auth.py:1199 +#: src/iac_code/commands/auth.py:1527 src/iac_code/commands/status.py:36 +#: src/iac_code/ui/renderer.py:455 msgid "Region" msgstr "Região" -#: src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1086 msgid "Configure Alibaba Cloud" msgstr "Configurar Alibaba Cloud" -#: src/iac_code/commands/auth.py:1178 +#: src/iac_code/commands/auth.py:1188 msgid "Current configuration" msgstr "Configuração atual" -#: src/iac_code/commands/auth.py:1180 +#: src/iac_code/commands/auth.py:1190 msgid "Mode" msgstr "Modo" -#: src/iac_code/commands/auth.py:1187 +#: src/iac_code/commands/auth.py:1196 msgid "(not set)" msgstr "(não definido)" -#: src/iac_code/commands/auth.py:1204 +#: src/iac_code/commands/auth.py:1205 +msgid "AccessKey" +msgstr "AccessKey" + +#: src/iac_code/commands/auth.py:1207 src/iac_code/commands/auth.py:1219 +msgid "STS Token" +msgstr "Token STS" + +#: src/iac_code/commands/auth.py:1209 +msgid "RAM Role" +msgstr "Função RAM" + +#: src/iac_code/commands/auth.py:1211 +msgid "OAuth Login (Browser)" +msgstr "Login OAuth (navegador)" + +#: src/iac_code/commands/auth.py:1217 +msgid "AccessKey ID" +msgstr "ID da AccessKey" + +#: src/iac_code/commands/auth.py:1218 +msgid "AccessKey Secret" +msgstr "Segredo da AccessKey" + +#: src/iac_code/commands/auth.py:1220 +msgid "RAM Role ARN" +msgstr "ARN da função RAM" + +#: src/iac_code/commands/auth.py:1221 +msgid "Session Name" +msgstr "Nome da sessão" + +#: src/iac_code/commands/auth.py:1222 +msgid "OAuth Site Type" +msgstr "Tipo de site OAuth" + +#: src/iac_code/commands/auth.py:1223 +msgid "OAuth Access Token" +msgstr "Token de acesso OAuth" + +#: src/iac_code/commands/auth.py:1224 +msgid "OAuth Refresh Token" +msgstr "Token de atualização OAuth" + +#: src/iac_code/commands/auth.py:1225 +msgid "OAuth Access Token Expire" +msgstr "Expiração do token de acesso OAuth" + +#: src/iac_code/commands/auth.py:1226 +msgid "OAuth Refresh Token Expire" +msgstr "Expiração do token de atualização OAuth" + +#: src/iac_code/commands/auth.py:1227 +msgid "STS Expiration" +msgstr "Expiração STS" + +#: src/iac_code/commands/auth.py:1384 +msgid "China" +msgstr "China" + +#: src/iac_code/commands/auth.py:1385 +msgid "International" +msgstr "Internacional" + +#: src/iac_code/commands/auth.py:1387 +msgid "Choose site type" +msgstr "Escolha o tipo de site" + +#: src/iac_code/commands/auth.py:1402 +#, python-brace-format +msgid "Alibaba Cloud OAuth login failed: {error}" +msgstr "Falha no login OAuth da Alibaba Cloud: {error}" + +#: src/iac_code/commands/auth.py:1418 +msgid "Configured: Alibaba Cloud OAuth credentials saved" +msgstr "Configurado: credenciais OAuth da Alibaba Cloud salvas" + +#: src/iac_code/commands/auth.py:1430 msgid "Configure Alibaba Cloud credentials" msgstr "Configurar credenciais da Alibaba Cloud" -#: src/iac_code/commands/auth.py:1217 +#: src/iac_code/commands/auth.py:1443 msgid "Reconfigure credential" msgstr "Reconfigurar credencial" -#: src/iac_code/commands/auth.py:1230 +#: src/iac_code/commands/auth.py:1456 msgid "Select credential type" msgstr "Selecionar tipo de credencial" -#: src/iac_code/commands/auth.py:1280 +#: src/iac_code/commands/auth.py:1512 msgid "Configured: Alibaba Cloud credentials saved to ~/.iac-code" msgstr "Configurado: credenciais da Alibaba Cloud salvas em ~/.iac-code" -#: src/iac_code/commands/auth.py:1287 +#: src/iac_code/commands/auth.py:1519 msgid "Configure Alibaba Cloud region" msgstr "Configurar região da Alibaba Cloud" -#: src/iac_code/commands/auth.py:1313 +#: src/iac_code/commands/auth.py:1545 msgid "Configured: Alibaba Cloud region saved to ~/.iac-code" msgstr "Configurado: região da Alibaba Cloud salva em ~/.iac-code" @@ -962,6 +1097,36 @@ msgstr "Mostrar sugestões de comando" msgid "Exit" msgstr "Sair" +#: src/iac_code/commands/memory.py:10 +msgid "Usage: /memory [|search |delete |help]" +msgstr "Uso: /memory [|search |delete |help]" + +#: src/iac_code/commands/memory.py:40 +msgid "Saved memories:" +msgstr "Memórias salvas:" + +#: src/iac_code/commands/memory.py:40 +msgid "No memories saved yet." +msgstr "Ainda não há memórias salvas." + +#: src/iac_code/commands/memory.py:51 +msgid "Matching memories:" +msgstr "Memórias correspondentes:" + +#: src/iac_code/commands/memory.py:51 +msgid "No matching memories." +msgstr "Nenhuma memória correspondente." + +#: src/iac_code/commands/memory.py:60 src/iac_code/commands/memory.py:75 +#, python-brace-format +msgid "Memory '{name}' not found." +msgstr "Memória '{name}' não encontrada." + +#: src/iac_code/commands/memory.py:64 +#, python-brace-format +msgid "Memory '{name}' deleted." +msgstr "Memória '{name}' excluída." + #: src/iac_code/commands/model.py:57 #, python-brace-format msgid "" @@ -986,23 +1151,128 @@ msgstr "Modelo atual: {model}" msgid "Kept model as {model}" msgstr "Modelo mantido como {model}" -#: src/iac_code/commands/resume.py:21 +#: src/iac_code/commands/rename.py:16 src/iac_code/commands/rename.py:28 +msgid "Rename is only available in interactive mode." +msgstr "Renomear só está disponível no modo interativo." + +#: src/iac_code/commands/rename.py:31 +msgid "Rename cancelled" +msgstr "Renomeação cancelada" + +#: src/iac_code/commands/resume.py:22 msgid "Resume is only available in interactive mode." msgstr "Retomar está disponível apenas no modo interativo." -#: src/iac_code/commands/resume.py:27 +#: src/iac_code/commands/resume.py:28 msgid "Resume is unavailable: session index not initialised." msgstr "Não é possível retomar: índice de sessões não inicializado." -#: src/iac_code/commands/resume.py:32 +#: src/iac_code/commands/resume.py:33 src/iac_code/commands/resume.py:36 #, python-brace-format msgid "Session not found: {arg}" msgstr "Sessão não encontrada: {arg}" -#: src/iac_code/commands/resume.py:47 +#: src/iac_code/commands/resume.py:52 src/iac_code/commands/resume.py:68 msgid "Resume cancelled" msgstr "Retomada cancelada" +#: src/iac_code/commands/resume.py:55 +#, python-brace-format +msgid "Unable to resolve session: {arg}" +msgstr "Não foi possível resolver a sessão: {arg}" + +#: src/iac_code/commands/skills.py:14 +msgid "Skills management is only available in interactive mode." +msgstr "O gerenciamento de habilidades só está disponível no modo interativo." + +#: src/iac_code/commands/skills.py:25 +msgid "Skills update cancelled" +msgstr "Atualização de habilidades cancelada" + +#: src/iac_code/commands/skills.py:29 +msgid "Skills updated" +msgstr "Habilidades atualizadas" + +#: src/iac_code/commands/status.py:19 +msgid "Status command requires a context." +msgstr "O comando status requer um contexto." + +#: src/iac_code/commands/status.py:22 +msgid "Status command requires a REPL context." +msgstr "O comando status requer um contexto REPL." + +#: src/iac_code/commands/status.py:24 +msgid "Status is only available in interactive mode." +msgstr "status está disponível apenas no modo interativo." + +#: src/iac_code/commands/status.py:33 src/iac_code/ui/banner.py:136 +#: src/iac_code/ui/banner.py:138 +msgid "Session" +msgstr "Sessão" + +#: src/iac_code/commands/status.py:34 +msgid "Provider" +msgstr "Provedor" + +#: src/iac_code/commands/status.py:34 src/iac_code/commands/status.py:35 +#: src/iac_code/commands/status.py:36 +msgid "not configured" +msgstr "não configurado" + +#: src/iac_code/commands/status.py:35 +msgid "Model" +msgstr "Modelo" + +#: src/iac_code/commands/status.py:37 +msgid "CWD" +msgstr "Diretório atual" + +#: src/iac_code/commands/status.py:41 +msgid "API Token Usage (recorded):" +msgstr "Uso de tokens da API (registrado):" + +#: src/iac_code/commands/status.py:44 +msgid "Input" +msgstr "Entrada" + +#: src/iac_code/commands/status.py:45 +msgid "Output" +msgstr "Saída" + +#: src/iac_code/commands/status.py:46 +msgid "Cache read" +msgstr "Leitura de cache" + +#: src/iac_code/commands/status.py:47 +msgid "Total" +msgstr "Total" + +#: src/iac_code/commands/status.py:50 +msgid "No recorded API usage for this session yet." +msgstr "Ainda não há uso de API registrado para esta sessão." + +#: src/iac_code/commands/status.py:54 +msgid "Turns" +msgstr "Turnos" + +#: src/iac_code/commands/status.py:55 +msgid "Context" +msgstr "Contexto" + +#: src/iac_code/commands/status.py:57 +msgid "Session Status" +msgstr "Status da sessão" + +#: src/iac_code/commands/status.py:73 +#, python-brace-format +msgid "{session_id} (resumed)" +msgstr "{session_id} (retomada)" + +#: src/iac_code/commands/status.py:81 +#, python-brace-format +msgid "{percent} used ({total} / {window})" +msgstr "{percent} usado ({total} / {window})" + # Typer/Click built-in strings #: src/iac_code/i18n/__init__.py:51 msgid "Options" @@ -1087,79 +1357,79 @@ msgstr "" "correta (atual: {base_url}). Muitos endpoints compatíveis com OpenAI " "exigem o sufixo /v1 (por exemplo, {base_url}/v1)." -#: src/iac_code/providers/registry.py:415 +#: src/iac_code/providers/registry.py:416 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "OpenAPI Compatible" msgstr "Compatível com OpenAPI" -#: src/iac_code/providers/registry.py:422 +#: src/iac_code/providers/registry.py:423 msgid "Kimi (China)" msgstr "Kimi (China)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "Kimi (International)" msgstr "Kimi (Internacional)" -#: src/iac_code/providers/registry.py:424 +#: src/iac_code/providers/registry.py:425 msgid "MiniMax (China)" msgstr "MiniMax (China)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "MiniMax (International)" msgstr "MiniMax (Internacional)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (Internacional)" -#: src/iac_code/providers/registry.py:429 +#: src/iac_code/providers/registry.py:430 msgid "SiliconFlow (China)" msgstr "SiliconFlow (China)" -#: src/iac_code/providers/registry.py:430 +#: src/iac_code/providers/registry.py:431 msgid "SiliconFlow (International)" msgstr "SiliconFlow (Internacional)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "Ollama (Local)" msgstr "Ollama (Local)" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "LM Studio (Local)" msgstr "LM Studio (Local)" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:438 +#: src/iac_code/providers/registry.py:439 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:439 +#: src/iac_code/providers/registry.py:440 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:440 +#: src/iac_code/providers/registry.py:441 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:441 +#: src/iac_code/providers/registry.py:442 msgid "Anthropic Compatible" msgstr "Compatível com Anthropic" @@ -1178,6 +1448,16 @@ msgstr "" "Solução: mude para um provider suportado no QwenPaw, ou desative o modo " "QwenPaw (remova 'llm_source: qwenpaw' do settings.yml)." +#: src/iac_code/services/session_metadata.py:52 +#, python-brace-format +msgid "Session name must match {pattern}" +msgstr "O nome da sessão deve corresponder a {pattern}" + +#: src/iac_code/services/session_storage.py:241 +#, python-brace-format +msgid "Session name already exists in this project: {name}" +msgstr "O nome da sessão já existe neste projeto: {name}" + #: src/iac_code/services/permissions/loader.py:50 #, python-brace-format msgid "Invalid --permission-mode {!r}. Valid values: {}" @@ -1189,15 +1469,140 @@ msgstr "--permission-mode inválido {!r}. Valores válidos: {}" msgid "Allow {}?" msgstr "Permitir {}?" -#: src/iac_code/skills/skill_tool.py:130 +#: src/iac_code/services/providers/aliyun.py:144 +msgid "Alibaba Cloud OAuth site is missing." +msgstr "O site OAuth da Alibaba Cloud está ausente." + +#: src/iac_code/services/providers/aliyun.py:150 +msgid "Alibaba Cloud OAuth refresh token is missing." +msgstr "O token de atualização OAuth da Alibaba Cloud está ausente." + +#: src/iac_code/services/providers/aliyun.py:158 +msgid "Alibaba Cloud OAuth access token is missing." +msgstr "O token de acesso OAuth da Alibaba Cloud está ausente." + +#: src/iac_code/services/providers/aliyun_oauth.py:83 +msgid "Run /auth and choose OAuth Login (Browser)." +msgstr "Execute /auth e escolha Login OAuth (navegador)." + +#: src/iac_code/services/providers/aliyun_oauth.py:106 +#, python-brace-format +msgid "Unknown Aliyun OAuth site: {site_type}" +msgstr "Site OAuth Aliyun desconhecido: {site_type}" + +#: src/iac_code/services/providers/aliyun_oauth.py:164 +msgid "Not found" +msgstr "Não encontrado" + +#: src/iac_code/services/providers/aliyun_oauth.py:170 +msgid "invalid state" +msgstr "estado inválido" + +#: src/iac_code/services/providers/aliyun_oauth.py:171 +msgid "Invalid state" +msgstr "Estado inválido" + +#: src/iac_code/services/providers/aliyun_oauth.py:176 +msgid "code not found" +msgstr "código de autorização não encontrado" + +#: src/iac_code/services/providers/aliyun_oauth.py:177 +msgid "Authorization code not found" +msgstr "Código de autorização não encontrado" + +#: src/iac_code/services/providers/aliyun_oauth.py:181 +msgid "Authorization successful. You can close this window." +msgstr "Autorização bem-sucedida. Você pode fechar esta janela." + +#: src/iac_code/services/providers/aliyun_oauth.py:212 +#, python-brace-format +msgid "No available callback port in range {start}-{end}" +msgstr "Nenhuma porta de callback disponível no intervalo {start}-{end}" + +#: src/iac_code/services/providers/aliyun_oauth.py:227 +msgid "OAuth login cancelled." +msgstr "Login OAuth cancelado." + +#: src/iac_code/services/providers/aliyun_oauth.py:278 +msgid "Open in your browser:" +msgstr "Abrir no navegador:" + +#: src/iac_code/services/providers/aliyun_oauth.py:299 +msgid "Waiting for browser authorization" +msgstr "Aguardando autorização do navegador" + +#: src/iac_code/services/providers/aliyun_oauth.py:300 +msgid "" +"1. The browser may show official-cli; this is the Alibaba Cloud official " +"CLI OAuth application." +msgstr "" +"1. O navegador pode mostrar official-cli; este é o aplicativo OAuth " +"oficial da CLI da Alibaba Cloud." + +#: src/iac_code/services/providers/aliyun_oauth.py:302 +msgid "" +"2. If assignment is required, assign the RAM user or RAM role that is " +"signed in. User groups are not supported." +msgstr "" +"2. Se a atribuição for necessária, atribua o usuário RAM ou a função RAM " +"que está conectada. Grupos de usuários não são compatíveis." + +#: src/iac_code/services/providers/aliyun_oauth.py:306 +msgid "" +"3. After assignment, close the old authorization page and run OAuth Login" +" (Browser) again. If it still fails, sign out of Alibaba Cloud and sign " +"in again." +msgstr "" +"3. Após a atribuição, feche a página de autorização antiga e execute " +"Login OAuth (navegador) novamente. Se ainda falhar, saia da Alibaba Cloud" +" e entre novamente." + +#: src/iac_code/services/providers/aliyun_oauth.py:310 +msgid "" +"4. STS credentials refresh when possible until Alibaba Cloud expires " +"them. If refresh fails, run /auth again." +msgstr "" +"4. As credenciais STS são atualizadas quando possível até expirarem na " +"Alibaba Cloud. Se a atualização falhar, execute /auth novamente." + +#: src/iac_code/services/providers/aliyun_oauth.py:313 +msgid "Press Esc to cancel while waiting." +msgstr "Pressione Esc para cancelar a espera." + +#: src/iac_code/services/providers/aliyun_oauth.py:321 +msgid "" +"Timed out waiting for OAuth callback. If Alibaba Cloud asked you to " +"assign the official-cli application, assign it to the exact RAM user or " +"RAM role currently signed in. User groups are not supported. Then close " +"the old authorization page, sign out of Alibaba Cloud and sign in again " +"if needed, and run /auth to choose OAuth Login (Browser) again." +msgstr "" +"Tempo esgotado aguardando o callback OAuth. Se a Alibaba Cloud pediu para" +" atribuir o aplicativo official-cli, atribua-o ao usuário RAM ou à função" +" RAM exata atualmente conectada. Grupos de usuários não são compatíveis. " +"Depois feche a página de autorização antiga, saia da Alibaba Cloud e " +"entre novamente se necessário, e execute /auth para escolher Login OAuth " +"(navegador) novamente." + +#: src/iac_code/skills/skill_tool.py:85 src/iac_code/ui/repl.py:842 +#, python-brace-format +msgid "Skill '{name}' is disabled. Run /skills to enable it." +msgstr "A habilidade '{name}' está desativada. Execute /skills para ativá-la." + +#: src/iac_code/skills/skill_tool.py:137 #, python-brace-format msgid "Skill '{name}' loaded (inline)." msgstr "Skill '{name}' carregada (inline)." -#: src/iac_code/skills/skill_tool.py:214 +#: src/iac_code/skills/skill_tool.py:221 msgid "Skill" msgstr "Skill" +#: src/iac_code/skills/skill_tool.py:245 +#, python-brace-format +msgid "Skill disabled: {name}" +msgstr "Habilidade desativada: {name}" + #: src/iac_code/skills/bundled/simplify.py:25 msgid "" "Review changed code for reuse, quality, and efficiency, then fix issues " @@ -1470,7 +1875,7 @@ msgstr "API na nuvem" msgid "Calling {action}..." msgstr "Chamando {action}..." -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:390 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:400 #: src/iac_code/tools/cloud/base_api.py:123 msgid "Call succeeded" msgstr "Chamada bem-sucedida" @@ -1630,11 +2035,11 @@ msgstr "Importação concluída" msgid "IMPORT_FAILED" msgstr "Falha na importação" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:171 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:172 msgid "Aliyun API" msgstr "Aliyun API" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:389 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:399 #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" msgstr "Chamada bem-sucedida (RequestId: {request_id})" @@ -1796,23 +2201,19 @@ msgstr "Notas da versão" msgid "Run {} to update." msgstr "Execute {} para atualizar." -#: src/iac_code/ui/banner.py:105 +#: src/iac_code/ui/banner.py:110 msgid "Your AI-powered Infrastructure as Code assistant" msgstr "Seu assistente de Infrastructure as Code com IA" -#: src/iac_code/ui/banner.py:131 +#: src/iac_code/ui/banner.py:144 msgid "Welcome back" msgstr "Bem-vindo de volta" -#: src/iac_code/ui/banner.py:138 -msgid "Session" -msgstr "Sessão" - -#: src/iac_code/ui/banner.py:148 +#: src/iac_code/ui/banner.py:161 msgid "Debug mode" msgstr "Modo debug" -#: src/iac_code/ui/banner.py:149 +#: src/iac_code/ui/banner.py:162 msgid "Log file" msgstr "Arquivo de log" @@ -1909,101 +2310,113 @@ msgstr "Não, sempre negar \"{rule}\" (esta sessão)" msgid "No, always reject this tool" msgstr "Não, sempre rejeitar esta ferramenta" -#: src/iac_code/ui/repl.py:370 +#: src/iac_code/ui/repl.py:424 msgid "Press Ctrl+C again to exit." msgstr "Pressione Ctrl+C novamente para sair." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:449 msgid "Interrupted." msgstr "Interrompido." -#: src/iac_code/ui/repl.py:432 -msgid "Goodbye!" -msgstr "Até logo!" - -#: src/iac_code/ui/repl.py:433 -msgid "Resume this session with:" -msgstr "Para retomar esta sessão, execute:" - -#: src/iac_code/ui/repl.py:458 +#: src/iac_code/ui/repl.py:508 msgid "Update now" msgstr "Atualizar agora" -#: src/iac_code/ui/repl.py:460 +#: src/iac_code/ui/repl.py:510 msgid "Run the shown update command and exit when it succeeds." msgstr "" "Executa o comando de atualização mostrado e sai quando ele for concluído " "com sucesso." -#: src/iac_code/ui/repl.py:463 +#: src/iac_code/ui/repl.py:513 msgid "Skip" msgstr "Ignorar" -#: src/iac_code/ui/repl.py:465 +#: src/iac_code/ui/repl.py:515 msgid "Continue with the current version for this session." msgstr "Continuar com a versão atual nesta sessão." -#: src/iac_code/ui/repl.py:468 +#: src/iac_code/ui/repl.py:518 msgid "Skip until next version" msgstr "Ignorar até a próxima versão" -#: src/iac_code/ui/repl.py:470 +#: src/iac_code/ui/repl.py:520 msgid "Hide this update until a newer version is available." msgstr "Ocultar esta atualização até que uma versão mais nova esteja disponível." -#: src/iac_code/ui/repl.py:489 src/iac_code/ui/repl.py:501 +#: src/iac_code/ui/repl.py:539 src/iac_code/ui/repl.py:551 msgid "Update command failed. Continuing with the current version." msgstr "O comando de atualização falhou. Continuando com a versão atual." -#: src/iac_code/ui/repl.py:494 +#: src/iac_code/ui/repl.py:544 msgid "Update completed. Restart iac-code to continue." msgstr "Atualização concluída. Reinicie o iac-code para continuar." -#: src/iac_code/ui/repl.py:532 +#: src/iac_code/ui/repl.py:582 msgid "No image in clipboard." msgstr "Nenhuma imagem na área de transferência." -#: src/iac_code/ui/repl.py:718 +#: src/iac_code/ui/repl.py:768 msgid "Usage: !" msgstr "Uso: !" -#: src/iac_code/ui/repl.py:723 +#: src/iac_code/ui/repl.py:775 msgid "Shell command support is unavailable." msgstr "O suporte a comandos shell não está disponível." -#: src/iac_code/ui/repl.py:787 +#: src/iac_code/ui/repl.py:845 #, python-brace-format msgid "Unknown skill: ${name}. Type / to list commands and skills." msgstr "" "Habilidade desconhecida: ${name}. Digite / para listar comandos e " "habilidades." -#: src/iac_code/ui/repl.py:789 +#: src/iac_code/ui/repl.py:847 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "Comando desconhecido: /{name}. Digite /help para ver os comandos." -#: src/iac_code/ui/repl.py:794 +#: src/iac_code/ui/repl.py:852 #, python-brace-format msgid "$ only invokes skills. Use /{name} instead." msgstr "$ invoca apenas habilidades. Use /{name} em vez disso." -#: src/iac_code/ui/repl.py:816 src/iac_code/ui/repl.py:861 +#: src/iac_code/ui/repl.py:874 src/iac_code/ui/repl.py:922 #, python-brace-format msgid "Command error: {error}" msgstr "Erro de comando: {error}" -#: src/iac_code/ui/repl.py:823 +#: src/iac_code/ui/repl.py:881 #, python-brace-format msgid "Command has no handler: {name}" msgstr "Comando sem tratador: {name}" -#: src/iac_code/ui/repl.py:1128 +#: src/iac_code/ui/repl.py:1156 +msgid "Goodbye!" +msgstr "Até logo!" + +#: src/iac_code/ui/repl.py:1157 +msgid "Resume this session with:" +msgstr "Para retomar esta sessão, execute:" + +#: src/iac_code/ui/repl.py:1160 +msgid "Session ID" +msgstr "ID da sessão" + +#: src/iac_code/ui/repl.py:1210 src/iac_code/ui/repl.py:1214 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Sessão não encontrada: {session_id}" -#: src/iac_code/ui/repl.py:1147 +#: src/iac_code/ui/repl.py:1263 +msgid "Session name: " +msgstr "Nome da sessão: " + +#: src/iac_code/ui/repl.py:1269 +msgid "Session name cannot be empty." +msgstr "O nome da sessão não pode estar vazio." + +#: src/iac_code/ui/repl.py:1279 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -2014,19 +2427,23 @@ msgstr "" "Para retomar, execute:\n" " {cmd}" -#: src/iac_code/ui/repl.py:1186 +#: src/iac_code/ui/repl.py:1283 +msgid "Multiple sessions match. Resume one by ID:" +msgstr "Várias sessões correspondem. Retome uma por ID:" + +#: src/iac_code/ui/repl.py:1396 msgid "This conversation is from a different directory." msgstr "Esta conversa é de outro diretório." -#: src/iac_code/ui/repl.py:1188 +#: src/iac_code/ui/repl.py:1398 msgid "To resume, run:" msgstr "Para retomar, execute:" -#: src/iac_code/ui/repl.py:1193 +#: src/iac_code/ui/repl.py:1403 msgid "(Command copied to clipboard)" msgstr "(Comando copiado para a área de transferência)" -#: src/iac_code/ui/repl.py:1350 +#: src/iac_code/ui/repl.py:1560 #, python-brace-format msgid "" "Current model {model} does not support image input. Use /model to switch " @@ -2035,12 +2452,12 @@ msgstr "" "O modelo atual {model} não suporta entrada de imagem. Use /model para " "alternar para um modelo com capacidade de visão." -#: src/iac_code/ui/repl.py:1359 +#: src/iac_code/ui/repl.py:1569 #, python-brace-format msgid "Image error: {err}" msgstr "Erro de imagem: {err}" -#: src/iac_code/ui/repl.py:1376 +#: src/iac_code/ui/repl.py:1586 msgid "" "Failed to persist image to cache; it will only exist in memory for this " "turn." @@ -2136,91 +2553,192 @@ msgstr "Digite para buscar arquivos..." msgid "No matching files" msgstr "Nenhum arquivo correspondente" -#: src/iac_code/ui/dialogs/resume_picker.py:114 +#: src/iac_code/ui/dialogs/resume_picker.py:116 msgid "Search..." msgstr "Buscar..." -#: src/iac_code/ui/dialogs/resume_picker.py:359 +#: src/iac_code/ui/dialogs/resume_picker.py:374 msgid "Resume Session" msgstr "Retomar sessão" -#: src/iac_code/ui/dialogs/resume_picker.py:369 +#: src/iac_code/ui/dialogs/resume_picker.py:384 msgid "No sessions found" msgstr "Nenhuma sessão encontrada" -#: src/iac_code/ui/dialogs/resume_picker.py:427 +#: src/iac_code/ui/dialogs/resume_picker.py:444 msgid "show current dir" msgstr "mostrar diretório atual" -#: src/iac_code/ui/dialogs/resume_picker.py:429 +#: src/iac_code/ui/dialogs/resume_picker.py:446 msgid "show all projects" msgstr "mostrar todos os projetos" -#: src/iac_code/ui/dialogs/resume_picker.py:432 +#: src/iac_code/ui/dialogs/resume_picker.py:449 msgid "show all branches" msgstr "mostrar todos os branches" -#: src/iac_code/ui/dialogs/resume_picker.py:434 +#: src/iac_code/ui/dialogs/resume_picker.py:451 msgid "only show current branch" msgstr "mostrar apenas o branch atual" -#: src/iac_code/ui/dialogs/resume_picker.py:435 +#: src/iac_code/ui/dialogs/resume_picker.py:452 msgid "preview" msgstr "pré-visualização" -#: src/iac_code/ui/dialogs/resume_picker.py:436 +#: src/iac_code/ui/dialogs/resume_picker.py:453 msgid "Type to search" msgstr "Digite para buscar" -#: src/iac_code/ui/dialogs/resume_picker.py:437 +#: src/iac_code/ui/dialogs/resume_picker.py:454 msgid "cancel" msgstr "cancelar" -#: src/iac_code/ui/dialogs/resume_picker.py:552 +#: src/iac_code/ui/dialogs/resume_picker.py:569 #, python-brace-format msgid "{n} more line{s}" msgstr "mais {n} linha{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:566 +#: src/iac_code/ui/dialogs/resume_picker.py:583 #, python-brace-format msgid "{n} message{s}" msgstr "{n} mensagem{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:580 +#: src/iac_code/ui/dialogs/resume_picker.py:597 msgid "resume" msgstr "retomar" -#: src/iac_code/ui/dialogs/resume_picker.py:584 +#: src/iac_code/ui/dialogs/resume_picker.py:601 msgid "back" msgstr "voltar" -#: src/iac_code/ui/dialogs/resume_picker.py:589 +#: src/iac_code/ui/dialogs/resume_picker.py:606 msgid "scroll" msgstr "rolar" -#: src/iac_code/ui/dialogs/resume_picker.py:608 +#: src/iac_code/ui/dialogs/resume_picker.py:625 msgid "(empty session)" msgstr "(sessão vazia)" -#: src/iac_code/ui/dialogs/resume_picker.py:728 +#: src/iac_code/ui/dialogs/resume_picker.py:745 msgid "just now" msgstr "agora mesmo" -#: src/iac_code/ui/dialogs/resume_picker.py:731 +#: src/iac_code/ui/dialogs/resume_picker.py:748 #, python-brace-format msgid "{n} minute{s} ago" msgstr "há {n} minuto{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:734 +#: src/iac_code/ui/dialogs/resume_picker.py:751 #, python-brace-format msgid "{n} hour{s} ago" msgstr "há {n} hora{s}" -#: src/iac_code/ui/dialogs/resume_picker.py:736 +#: src/iac_code/ui/dialogs/resume_picker.py:753 #, python-brace-format msgid "{n} day{s} ago" msgstr "há {n} dia{s}" +#: src/iac_code/ui/dialogs/skills_picker.py:52 +msgid "Search skills..." +msgstr "Buscar habilidades..." + +#: src/iac_code/ui/dialogs/skills_picker.py:159 +msgid "Skills" +msgstr "Habilidades" + +#: src/iac_code/ui/dialogs/skills_picker.py:161 +#, python-brace-format +msgid "{current} of {total}" +msgstr "{current} de {total}" + +#: src/iac_code/ui/dialogs/skills_picker.py:165 +#, python-brace-format +msgid "" +"{count} skills - Space to toggle, Enter to save, Tab to sort, Esc to " +"cancel" +msgstr "" +"{count} habilidades - Espaço para alternar, Enter para salvar, Tab para " +"ordenar, Esc para cancelar" + +#: src/iac_code/ui/dialogs/skills_picker.py:171 +#, python-brace-format +msgid "Sort: {mode}" +msgstr "Ordenar: {mode}" + +#: src/iac_code/ui/dialogs/skills_picker.py:176 +msgid "No skills found" +msgstr "Nenhuma habilidade encontrada" + +#: src/iac_code/ui/dialogs/skills_picker.py:245 +msgid "Bundled skills cannot be disabled." +msgstr "Habilidades integradas não podem ser desativadas." + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "on" +msgstr "ativada" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "off" +msgstr "desativada" + +#: src/iac_code/ui/dialogs/skills_picker.py:265 +msgid "locked" +msgstr "bloqueada" + +#: src/iac_code/ui/dialogs/skills_picker.py:268 +msgid "matched description" +msgstr "corresponde à descrição" + +#: src/iac_code/ui/dialogs/skills_picker.py:277 +msgid "source" +msgstr "origem" + +#: src/iac_code/ui/dialogs/skills_picker.py:279 +msgid "size" +msgstr "tamanho" + +#: src/iac_code/ui/dialogs/skills_picker.py:280 +msgid "name" +msgstr "nome" + +#: src/iac_code/ui/dialogs/skills_picker.py:285 +msgid "bundled" +msgstr "integrada" + +#: src/iac_code/ui/dialogs/skills_picker.py:287 +msgid "project" +msgstr "projeto" + +#: src/iac_code/ui/dialogs/skills_picker.py:289 +msgid "user" +msgstr "usuário" + +#: src/iac_code/ui/dialogs/skills_picker.py:296 +#, python-brace-format +msgid "~{count}k tokens" +msgstr "~{count}k tokens" + +#: src/iac_code/ui/dialogs/skills_picker.py:297 +#, python-brace-format +msgid "~{count} tokens" +msgstr "~{count} tokens" + +#: src/iac_code/ui/suggestions/command_provider.py:79 +msgid "Search saved memories" +msgstr "Pesquisar memórias salvas" + +#: src/iac_code/ui/suggestions/command_provider.py:80 +msgid "Delete a saved memory" +msgstr "Excluir uma memória salva" + +#: src/iac_code/ui/suggestions/command_provider.py:81 +msgid "Show memory command help" +msgstr "Mostrar ajuda do comando memory" + +#: src/iac_code/ui/suggestions/command_provider.py:116 +msgid "Saved memory" +msgstr "Memória salva" + #: src/iac_code/utils/platform.py:39 msgid "iac-code on Windows requires Git for Windows." msgstr "iac-code no Windows requer o Git for Windows." @@ -2276,3 +2794,19 @@ msgstr "" #~ msgid " Option 2 - npmmirror (China-friendly mirror):" #~ msgstr " Opção 2 - npmmirror (espelho para a China):" +#~ msgid "Cache create" +#~ msgstr "Criação de cache" + +#~ msgid "" +#~ "{count} skills - Space to toggle, " +#~ "Enter to save, / to search, t " +#~ "to sort, Esc to cancel" +#~ msgstr "" +#~ "{count} habilidades - Espaço para " +#~ "alternar, Enter para salvar, / para " +#~ "buscar, t para ordenar, Esc para " +#~ "cancelar" + +#~ msgid "Resume a session by ID" +#~ msgstr "Retomar uma sessão pelo ID" + diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index ea8f6f6..83ca420 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -4,9 +4,9 @@ # msgid "" msgstr "" -"Project-Id-Version: iac-code 0.3.0\n" +"Project-Id-Version: iac-code 0.4.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-01 17:56+0800\n" +"POT-Creation-Date: 2026-06-03 13:32+0800\n" "PO-Revision-Date: 2026-04-02 00:00+0000\n" "Last-Translator: \n" "Language: zh\n" @@ -28,78 +28,115 @@ msgid "" " http or --transport stdio instead." msgstr "Windows 不支持 Unix 域套接字传输。请使用 --transport http 或 --transport stdio。" -#: src/iac_code/acp/slash_registry.py:44 +#: src/iac_code/acp/server.py:413 src/iac_code/acp/server.py:429 +#: src/iac_code/acp/server.py:456 +msgid "Session not found" +msgstr "未找到会话" + +#: src/iac_code/acp/server.py:416 +#, python-brace-format +msgid "Session name is ambiguous. Candidates: {candidates}" +msgstr "会话名称不唯一。候选项:{candidates}" + +#: src/iac_code/acp/server.py:434 src/iac_code/acp/server.py:708 +#, python-brace-format +msgid "Session belongs to another project. Run: {hint}" +msgstr "会话属于另一个项目。请运行:{hint}" + +#: src/iac_code/acp/slash_registry.py:46 #, python-brace-format msgid "" "Command '/{cmd_name}' is not supported over ACP. Supported commands: " "{supported}" msgstr "命令 '/{cmd_name}' 不支持通过 ACP 使用。支持的命令:{supported}" -#: src/iac_code/acp/slash_registry.py:56 +#: src/iac_code/acp/slash_registry.py:62 #, python-brace-format msgid "Command '/{cmd_name}' handler not implemented." msgstr "命令 '/{cmd_name}' 的处理器尚未实现。" -#: src/iac_code/acp/slash_registry.py:68 +#: src/iac_code/acp/slash_registry.py:74 #, python-brace-format msgid "Compaction failed: {error}" msgstr "压缩失败:{error}" -#: src/iac_code/acp/slash_registry.py:71 src/iac_code/commands/compact.py:24 +#: src/iac_code/acp/slash_registry.py:77 src/iac_code/commands/compact.py:24 msgid "Nothing to compact: conversation is empty." msgstr "无内容可压缩:对话为空。" -#: src/iac_code/acp/slash_registry.py:74 src/iac_code/commands/compact.py:27 +#: src/iac_code/acp/slash_registry.py:80 src/iac_code/commands/compact.py:27 #, python-brace-format msgid "" "Conversation too short to compact: all messages are within the recent " "{turns}-turn preservation window." msgstr "对话过短,无需压缩:所有消息都在最近 {turns} 轮保留窗口内。" -#: src/iac_code/acp/slash_registry.py:78 src/iac_code/commands/compact.py:30 +#: src/iac_code/acp/slash_registry.py:84 src/iac_code/commands/compact.py:30 msgid "Compaction failed. See logs for details." msgstr "压缩失败,详情请查看日志。" -#: src/iac_code/acp/slash_registry.py:83 +#: src/iac_code/acp/slash_registry.py:89 #, python-brace-format msgid "" "Context compacted: {original} → {compacted} tokens ({percent} reduction)." " Context usage: {usage}" msgstr "上下文已压缩:{original} → {compacted} tokens(减少 {percent})。上下文用量:{usage}" -#: src/iac_code/acp/slash_registry.py:97 +#: src/iac_code/acp/slash_registry.py:103 #, python-brace-format msgid "Clear failed: {error}" msgstr "清除失败:{error}" -#: src/iac_code/acp/slash_registry.py:98 +#: src/iac_code/acp/slash_registry.py:104 msgid "Conversation history cleared." msgstr "对话历史已清除。" -#: src/iac_code/acp/slash_registry.py:114 src/iac_code/commands/debug.py:34 +#: src/iac_code/acp/slash_registry.py:120 src/iac_code/commands/debug.py:34 #, python-brace-format msgid "Debug logging is on. Log file: {path}" msgstr "调试日志已启用。日志文件:{path}" -#: src/iac_code/acp/slash_registry.py:115 src/iac_code/commands/debug.py:35 +#: src/iac_code/acp/slash_registry.py:121 src/iac_code/commands/debug.py:35 msgid "Debug logging is off." msgstr "调试日志已关闭。" -#: src/iac_code/acp/slash_registry.py:119 src/iac_code/commands/debug.py:39 +#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:39 #, python-brace-format msgid "Debug logging enabled. Log file: {path}" msgstr "调试日志已启用。日志文件:{path}" -#: src/iac_code/acp/slash_registry.py:123 src/iac_code/commands/debug.py:43 +#: src/iac_code/acp/slash_registry.py:129 src/iac_code/commands/debug.py:43 msgid "Debug logging disabled." msgstr "调试日志已关闭。" -#: src/iac_code/acp/slash_registry.py:125 src/iac_code/commands/debug.py:45 +#: src/iac_code/acp/slash_registry.py:131 src/iac_code/commands/debug.py:45 msgid "Usage: /debug [on|off]" msgstr "用法:/debug [on|off]" -#: src/iac_code/agent/agent_loop.py:404 src/iac_code/agent/agent_loop.py:419 -#: src/iac_code/ui/repl.py:757 src/iac_code/ui/repl.py:771 +#: src/iac_code/acp/slash_registry.py:136 src/iac_code/commands/memory.py:84 +msgid "Memory manager is unavailable." +msgstr "记忆管理器不可用。" + +#: src/iac_code/acp/slash_registry.py:146 src/iac_code/commands/rename.py:21 +msgid "Usage: /rename " +msgstr "用法:/rename <名称>" + +#: src/iac_code/acp/slash_registry.py:152 +msgid "Rename is only available after a session is created." +msgstr "创建会话后才能重命名。" + +#: src/iac_code/acp/slash_registry.py:163 src/iac_code/commands/rename.py:42 +#, python-brace-format +msgid "Session is already named {name}" +msgstr "会话已命名为 {name}" + +#: src/iac_code/acp/slash_registry.py:164 src/iac_code/commands/rename.py:43 +#, python-brace-format +msgid "Renamed session to {name}" +msgstr "已将会话重命名为 {name}" + +#: src/iac_code/agent/agent_loop.py:413 src/iac_code/agent/agent_loop.py:428 +#: src/iac_code/ui/repl.py:813 src/iac_code/ui/repl.py:827 msgid "Permission denied." msgstr "权限被拒绝。" @@ -262,8 +299,8 @@ msgid "Show version and exit" msgstr "显示版本号并退出" #: src/iac_code/cli/main.py:89 -msgid "Resume a session by ID" -msgstr "通过 ID 恢复会话" +msgid "Resume a session by ID or name" +msgstr "通过 ID 或名称恢复会话" #: src/iac_code/cli/main.py:90 msgid "Resume the most recent session" @@ -575,270 +612,368 @@ msgstr "持久化 A2A 路由的目录" msgid "Save the provided routes as a route snapshot" msgstr "将提供的路由保存为路由快照" -#: src/iac_code/commands/__init__.py:22 +#: src/iac_code/commands/__init__.py:26 msgid "Show available commands" msgstr "显示可用命令" -#: src/iac_code/commands/__init__.py:31 +#: src/iac_code/commands/__init__.py:35 msgid "Clear conversation history" msgstr "清除对话历史" -#: src/iac_code/commands/__init__.py:39 +#: src/iac_code/commands/__init__.py:43 msgid "Show or switch model" msgstr "显示或切换模型" -#: src/iac_code/commands/__init__.py:48 +#: src/iac_code/commands/__init__.py:52 msgid "Show or switch thinking effort" msgstr "显示或切换思考强度" -#: src/iac_code/commands/__init__.py:57 +#: src/iac_code/commands/__init__.py:61 msgid "Compact conversation context" msgstr "压缩对话上下文" -#: src/iac_code/commands/__init__.py:59 +#: src/iac_code/commands/__init__.py:63 msgid "Compacting conversation" msgstr "正在压缩对话" -#: src/iac_code/commands/__init__.py:66 +#: src/iac_code/commands/__init__.py:70 msgid "Exit the application" msgstr "退出应用程序" -#: src/iac_code/commands/__init__.py:75 +#: src/iac_code/commands/__init__.py:79 msgid "Authenticate with LLM provider" msgstr "配置 LLM 提供商认证" -#: src/iac_code/commands/__init__.py:84 +#: src/iac_code/commands/__init__.py:88 msgid "Toggle debug logging" msgstr "切换调试日志开关" -#: src/iac_code/commands/__init__.py:93 +#: src/iac_code/commands/__init__.py:97 +msgid "View and manage persistent memories" +msgstr "查看和管理持久记忆" + +#: src/iac_code/commands/__init__.py:99 +msgid "[|search |delete |help]" +msgstr "[<名称>|search <查询>|delete <名称>|help]" + +#: src/iac_code/commands/__init__.py:106 msgid "Resume a previous session" msgstr "恢复之前的会话" -#: src/iac_code/commands/__init__.py:95 +#: src/iac_code/commands/__init__.py:108 msgid "[conversation id or search term]" msgstr "[会话 ID 或搜索词]" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:1107 +#: src/iac_code/commands/__init__.py:115 +msgid "Rename the current session" +msgstr "重命名当前会话" + +#: src/iac_code/commands/__init__.py:124 +msgid "Manage skills" +msgstr "管理技能" + +#: src/iac_code/commands/__init__.py:132 +msgid "Show current session status" +msgstr "显示当前会话状态" + +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:1117 #: src/iac_code/ui/core/prompt_input.py:557 msgid "Navigate" msgstr "导航" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:530 -#: src/iac_code/commands/auth.py:562 src/iac_code/commands/auth.py:569 -#: src/iac_code/commands/auth.py:604 src/iac_code/commands/auth.py:611 -#: src/iac_code/commands/auth.py:632 src/iac_code/commands/auth.py:1107 -#: src/iac_code/commands/auth.py:1319 src/iac_code/ui/core/prompt_input.py:557 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:538 +#: src/iac_code/commands/auth.py:570 src/iac_code/commands/auth.py:577 +#: src/iac_code/commands/auth.py:612 src/iac_code/commands/auth.py:619 +#: src/iac_code/commands/auth.py:640 src/iac_code/commands/auth.py:1117 +#: src/iac_code/commands/auth.py:1551 src/iac_code/ui/core/prompt_input.py:557 msgid "Confirm" msgstr "确认" -#: src/iac_code/commands/auth.py:382 src/iac_code/commands/auth.py:528 -#: src/iac_code/commands/auth.py:530 src/iac_code/commands/auth.py:562 -#: src/iac_code/commands/auth.py:569 src/iac_code/commands/auth.py:604 -#: src/iac_code/commands/auth.py:611 src/iac_code/commands/auth.py:632 -#: src/iac_code/commands/auth.py:1107 src/iac_code/commands/auth.py:1217 -#: src/iac_code/commands/auth.py:1319 +#: src/iac_code/commands/auth.py:390 src/iac_code/commands/auth.py:536 +#: src/iac_code/commands/auth.py:538 src/iac_code/commands/auth.py:570 +#: src/iac_code/commands/auth.py:577 src/iac_code/commands/auth.py:612 +#: src/iac_code/commands/auth.py:619 src/iac_code/commands/auth.py:640 +#: src/iac_code/commands/auth.py:1117 src/iac_code/commands/auth.py:1443 +#: src/iac_code/commands/auth.py:1551 msgid "Back" msgstr "返回" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Keep" msgstr "保留" -#: src/iac_code/commands/auth.py:528 +#: src/iac_code/commands/auth.py:536 msgid "Re-enter" msgstr "重新输入" -#: src/iac_code/commands/auth.py:741 src/iac_code/commands/auth.py:853 -#: src/iac_code/commands/auth.py:911 src/iac_code/commands/auth.py:919 -#: src/iac_code/commands/auth.py:946 +#: src/iac_code/commands/auth.py:749 src/iac_code/commands/auth.py:863 +#: src/iac_code/commands/auth.py:921 src/iac_code/commands/auth.py:929 +#: src/iac_code/commands/auth.py:956 msgid " (current)" msgstr " (当前)" -#: src/iac_code/commands/auth.py:744 +#: src/iac_code/commands/auth.py:752 msgid "Custom model..." msgstr "自定义模型..." -#: src/iac_code/commands/auth.py:747 +#: src/iac_code/commands/auth.py:755 #, python-brace-format msgid "Select model for {provider}" msgstr "为 {provider} 选择模型" -#: src/iac_code/commands/auth.py:749 +#: src/iac_code/commands/auth.py:757 msgid "Select model" msgstr "选择模型" -#: src/iac_code/commands/auth.py:757 +#: src/iac_code/commands/auth.py:765 msgid "Enter custom model name: " msgstr "输入自定义模型名称:" -#: src/iac_code/commands/auth.py:783 +#: src/iac_code/commands/auth.py:791 msgid "Error: console not available" msgstr "错误:控制台不可用" -#: src/iac_code/commands/auth.py:810 +#: src/iac_code/commands/auth.py:820 msgid "Configure LLM Provider" msgstr "配置 LLM 提供商" -#: src/iac_code/commands/auth.py:811 +#: src/iac_code/commands/auth.py:821 msgid "Configure IaC Cloud Service" msgstr "配置 IaC 云服务" -#: src/iac_code/commands/auth.py:813 +#: src/iac_code/commands/auth.py:823 msgid "Select configuration type" msgstr "选择配置类型" -#: src/iac_code/commands/auth.py:815 src/iac_code/commands/auth.py:971 -#: src/iac_code/commands/auth.py:987 src/iac_code/commands/auth.py:1066 -#: src/iac_code/commands/auth.py:1258 src/iac_code/commands/auth.py:1299 +#: src/iac_code/commands/auth.py:825 src/iac_code/commands/auth.py:981 +#: src/iac_code/commands/auth.py:997 src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1490 src/iac_code/commands/auth.py:1531 msgid "Auth cancelled" msgstr "认证已取消" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:951 -#: src/iac_code/commands/auth.py:1041 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:961 +#: src/iac_code/commands/auth.py:1051 #, python-brace-format msgid "Select provider — {group}" msgstr "选择提供商 — {group}" -#: src/iac_code/commands/auth.py:857 src/iac_code/commands/auth.py:909 -#: src/iac_code/commands/auth.py:1026 +#: src/iac_code/commands/auth.py:867 src/iac_code/commands/auth.py:919 +#: src/iac_code/commands/auth.py:1036 msgid "Third-party" msgstr "第三方" -#: src/iac_code/commands/auth.py:867 +#: src/iac_code/commands/auth.py:877 #, python-brace-format msgid "{status}: {provider}" msgstr "{status}:{provider}" -#: src/iac_code/commands/auth.py:868 src/iac_code/commands/auth.py:1019 +#: src/iac_code/commands/auth.py:878 src/iac_code/commands/auth.py:1029 msgid "Configured" msgstr "已配置" -#: src/iac_code/commands/auth.py:923 +#: src/iac_code/commands/auth.py:933 msgid "Select provider" msgstr "选择提供商" -#: src/iac_code/commands/auth.py:964 +#: src/iac_code/commands/auth.py:974 #, python-brace-format msgid "Configure {provider}" msgstr "配置 {provider}" -#: src/iac_code/commands/auth.py:980 +#: src/iac_code/commands/auth.py:990 #, python-brace-format msgid "Enter API key for {provider}" msgstr "为 {provider} 输入 API 密钥" -#: src/iac_code/commands/auth.py:1018 +#: src/iac_code/commands/auth.py:1028 #, python-brace-format msgid "{status}: {provider} / {model}" msgstr "{status}:{provider} / {model}" -#: src/iac_code/commands/auth.py:1027 src/iac_code/commands/auth.py:1048 +#: src/iac_code/commands/auth.py:1037 src/iac_code/commands/auth.py:1058 msgid "Alibaba Cloud" msgstr "阿里云" -#: src/iac_code/commands/auth.py:1028 src/iac_code/providers/registry.py:426 +#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:427 msgid "ZhiPu AI" msgstr "智谱 AI" -#: src/iac_code/commands/auth.py:1029 +#: src/iac_code/commands/auth.py:1039 msgid "Kimi" msgstr "Kimi" -#: src/iac_code/commands/auth.py:1030 +#: src/iac_code/commands/auth.py:1040 msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:1031 src/iac_code/providers/registry.py:428 +#: src/iac_code/commands/auth.py:1041 src/iac_code/providers/registry.py:429 msgid "Volcengine" msgstr "火山引擎" -#: src/iac_code/commands/auth.py:1032 +#: src/iac_code/commands/auth.py:1042 msgid "SiliconFlow" msgstr "硅基流动" -#: src/iac_code/commands/auth.py:1033 src/iac_code/providers/registry.py:419 +#: src/iac_code/commands/auth.py:1043 src/iac_code/providers/registry.py:420 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:1034 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:1044 src/iac_code/providers/registry.py:418 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:1035 src/iac_code/providers/registry.py:418 +#: src/iac_code/commands/auth.py:1045 src/iac_code/providers/registry.py:419 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:1036 src/iac_code/providers/registry.py:421 +#: src/iac_code/commands/auth.py:1046 src/iac_code/providers/registry.py:422 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:1037 src/iac_code/providers/registry.py:434 +#: src/iac_code/commands/auth.py:1047 src/iac_code/providers/registry.py:435 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:1038 src/iac_code/providers/registry.py:433 +#: src/iac_code/commands/auth.py:1048 src/iac_code/providers/registry.py:434 msgid "OpenRouter" msgstr "OpenRouter" -#: src/iac_code/commands/auth.py:1039 +#: src/iac_code/commands/auth.py:1049 msgid "Local" msgstr "本地模型" -#: src/iac_code/commands/auth.py:1040 +#: src/iac_code/commands/auth.py:1050 msgid "Compatible" msgstr "兼容模式" -#: src/iac_code/commands/auth.py:1057 +#: src/iac_code/commands/auth.py:1067 msgid "Select Cloud Provider" msgstr "选择云服务商" -#: src/iac_code/commands/auth.py:1073 +#: src/iac_code/commands/auth.py:1083 msgid "Credential" msgstr "凭证" -#: src/iac_code/commands/auth.py:1074 src/iac_code/commands/auth.py:1190 -#: src/iac_code/commands/auth.py:1295 src/iac_code/ui/renderer.py:455 +#: src/iac_code/commands/auth.py:1084 src/iac_code/commands/auth.py:1199 +#: src/iac_code/commands/auth.py:1527 src/iac_code/commands/status.py:36 +#: src/iac_code/ui/renderer.py:455 msgid "Region" msgstr "地域" -#: src/iac_code/commands/auth.py:1076 +#: src/iac_code/commands/auth.py:1086 msgid "Configure Alibaba Cloud" msgstr "配置阿里云" -#: src/iac_code/commands/auth.py:1178 +#: src/iac_code/commands/auth.py:1188 msgid "Current configuration" msgstr "当前配置" -#: src/iac_code/commands/auth.py:1180 +#: src/iac_code/commands/auth.py:1190 msgid "Mode" msgstr "模式" -#: src/iac_code/commands/auth.py:1187 +#: src/iac_code/commands/auth.py:1196 msgid "(not set)" msgstr "(未设置)" -#: src/iac_code/commands/auth.py:1204 +#: src/iac_code/commands/auth.py:1205 +msgid "AccessKey" +msgstr "AccessKey" + +#: src/iac_code/commands/auth.py:1207 src/iac_code/commands/auth.py:1219 +msgid "STS Token" +msgstr "STS 令牌" + +#: src/iac_code/commands/auth.py:1209 +msgid "RAM Role" +msgstr "RAM 角色" + +#: src/iac_code/commands/auth.py:1211 +msgid "OAuth Login (Browser)" +msgstr "OAuth 登录(浏览器)" + +#: src/iac_code/commands/auth.py:1217 +msgid "AccessKey ID" +msgstr "AccessKey ID" + +#: src/iac_code/commands/auth.py:1218 +msgid "AccessKey Secret" +msgstr "AccessKey 密钥" + +#: src/iac_code/commands/auth.py:1220 +msgid "RAM Role ARN" +msgstr "RAM 角色 ARN" + +#: src/iac_code/commands/auth.py:1221 +msgid "Session Name" +msgstr "会话名称" + +#: src/iac_code/commands/auth.py:1222 +msgid "OAuth Site Type" +msgstr "OAuth 站点类型" + +#: src/iac_code/commands/auth.py:1223 +msgid "OAuth Access Token" +msgstr "OAuth 访问令牌" + +#: src/iac_code/commands/auth.py:1224 +msgid "OAuth Refresh Token" +msgstr "OAuth 刷新令牌" + +#: src/iac_code/commands/auth.py:1225 +msgid "OAuth Access Token Expire" +msgstr "OAuth 访问令牌过期时间" + +#: src/iac_code/commands/auth.py:1226 +msgid "OAuth Refresh Token Expire" +msgstr "OAuth 刷新令牌过期时间" + +#: src/iac_code/commands/auth.py:1227 +msgid "STS Expiration" +msgstr "STS 过期时间" + +#: src/iac_code/commands/auth.py:1384 +msgid "China" +msgstr "中国" + +#: src/iac_code/commands/auth.py:1385 +msgid "International" +msgstr "国际" + +#: src/iac_code/commands/auth.py:1387 +msgid "Choose site type" +msgstr "选择站点类型" + +#: src/iac_code/commands/auth.py:1402 +#, python-brace-format +msgid "Alibaba Cloud OAuth login failed: {error}" +msgstr "阿里云 OAuth 登录失败:{error}" + +#: src/iac_code/commands/auth.py:1418 +msgid "Configured: Alibaba Cloud OAuth credentials saved" +msgstr "已配置:阿里云 OAuth 凭证已保存" + +#: src/iac_code/commands/auth.py:1430 msgid "Configure Alibaba Cloud credentials" msgstr "配置阿里云凭证" -#: src/iac_code/commands/auth.py:1217 +#: src/iac_code/commands/auth.py:1443 msgid "Reconfigure credential" msgstr "重新配置凭证" -#: src/iac_code/commands/auth.py:1230 +#: src/iac_code/commands/auth.py:1456 msgid "Select credential type" msgstr "选择凭证类型" -#: src/iac_code/commands/auth.py:1280 +#: src/iac_code/commands/auth.py:1512 msgid "Configured: Alibaba Cloud credentials saved to ~/.iac-code" msgstr "已配置:阿里云凭证已保存到 ~/.iac-code" -#: src/iac_code/commands/auth.py:1287 +#: src/iac_code/commands/auth.py:1519 msgid "Configure Alibaba Cloud region" msgstr "配置阿里云地域" -#: src/iac_code/commands/auth.py:1313 +#: src/iac_code/commands/auth.py:1545 msgid "Configured: Alibaba Cloud region saved to ~/.iac-code" msgstr "已配置:阿里云地域已保存到 ~/.iac-code" @@ -934,6 +1069,36 @@ msgstr "显示命令建议" msgid "Exit" msgstr "退出" +#: src/iac_code/commands/memory.py:10 +msgid "Usage: /memory [|search |delete |help]" +msgstr "用法:/memory [<名称>|search <查询>|delete <名称>|help]" + +#: src/iac_code/commands/memory.py:40 +msgid "Saved memories:" +msgstr "已保存的记忆:" + +#: src/iac_code/commands/memory.py:40 +msgid "No memories saved yet." +msgstr "尚未保存任何记忆。" + +#: src/iac_code/commands/memory.py:51 +msgid "Matching memories:" +msgstr "匹配的记忆:" + +#: src/iac_code/commands/memory.py:51 +msgid "No matching memories." +msgstr "没有匹配的记忆。" + +#: src/iac_code/commands/memory.py:60 src/iac_code/commands/memory.py:75 +#, python-brace-format +msgid "Memory '{name}' not found." +msgstr "未找到记忆 '{name}'。" + +#: src/iac_code/commands/memory.py:64 +#, python-brace-format +msgid "Memory '{name}' deleted." +msgstr "记忆 '{name}' 已删除。" + #: src/iac_code/commands/model.py:57 #, python-brace-format msgid "" @@ -956,23 +1121,128 @@ msgstr "当前模型:{model}" msgid "Kept model as {model}" msgstr "保持模型为 {model}" -#: src/iac_code/commands/resume.py:21 +#: src/iac_code/commands/rename.py:16 src/iac_code/commands/rename.py:28 +msgid "Rename is only available in interactive mode." +msgstr "重命名仅在交互模式下可用。" + +#: src/iac_code/commands/rename.py:31 +msgid "Rename cancelled" +msgstr "已取消重命名" + +#: src/iac_code/commands/resume.py:22 msgid "Resume is only available in interactive mode." msgstr "/resume 仅在交互模式下可用。" -#: src/iac_code/commands/resume.py:27 +#: src/iac_code/commands/resume.py:28 msgid "Resume is unavailable: session index not initialised." msgstr "无法恢复:会话索引未初始化。" -#: src/iac_code/commands/resume.py:32 +#: src/iac_code/commands/resume.py:33 src/iac_code/commands/resume.py:36 #, python-brace-format msgid "Session not found: {arg}" msgstr "未找到会话:{arg}" -#: src/iac_code/commands/resume.py:47 +#: src/iac_code/commands/resume.py:52 src/iac_code/commands/resume.py:68 msgid "Resume cancelled" msgstr "已取消恢复" +#: src/iac_code/commands/resume.py:55 +#, python-brace-format +msgid "Unable to resolve session: {arg}" +msgstr "无法解析会话:{arg}" + +#: src/iac_code/commands/skills.py:14 +msgid "Skills management is only available in interactive mode." +msgstr "技能管理仅在交互模式下可用。" + +#: src/iac_code/commands/skills.py:25 +msgid "Skills update cancelled" +msgstr "已取消技能更新" + +#: src/iac_code/commands/skills.py:29 +msgid "Skills updated" +msgstr "技能已更新" + +#: src/iac_code/commands/status.py:19 +msgid "Status command requires a context." +msgstr "status 命令需要上下文。" + +#: src/iac_code/commands/status.py:22 +msgid "Status command requires a REPL context." +msgstr "status 命令需要 REPL 上下文。" + +#: src/iac_code/commands/status.py:24 +msgid "Status is only available in interactive mode." +msgstr "status 仅在交互模式下可用。" + +#: src/iac_code/commands/status.py:33 src/iac_code/ui/banner.py:136 +#: src/iac_code/ui/banner.py:138 +msgid "Session" +msgstr "会话" + +#: src/iac_code/commands/status.py:34 +msgid "Provider" +msgstr "提供商" + +#: src/iac_code/commands/status.py:34 src/iac_code/commands/status.py:35 +#: src/iac_code/commands/status.py:36 +msgid "not configured" +msgstr "未配置" + +#: src/iac_code/commands/status.py:35 +msgid "Model" +msgstr "模型" + +#: src/iac_code/commands/status.py:37 +msgid "CWD" +msgstr "当前目录" + +#: src/iac_code/commands/status.py:41 +msgid "API Token Usage (recorded):" +msgstr "API Token 用量(已记录):" + +#: src/iac_code/commands/status.py:44 +msgid "Input" +msgstr "输入" + +#: src/iac_code/commands/status.py:45 +msgid "Output" +msgstr "输出" + +#: src/iac_code/commands/status.py:46 +msgid "Cache read" +msgstr "缓存读取" + +#: src/iac_code/commands/status.py:47 +msgid "Total" +msgstr "总计" + +#: src/iac_code/commands/status.py:50 +msgid "No recorded API usage for this session yet." +msgstr "此会话尚无已记录的 API 用量。" + +#: src/iac_code/commands/status.py:54 +msgid "Turns" +msgstr "轮次" + +#: src/iac_code/commands/status.py:55 +msgid "Context" +msgstr "上下文" + +#: src/iac_code/commands/status.py:57 +msgid "Session Status" +msgstr "会话状态" + +#: src/iac_code/commands/status.py:73 +#, python-brace-format +msgid "{session_id} (resumed)" +msgstr "{session_id}(已恢复)" + +#: src/iac_code/commands/status.py:81 +#, python-brace-format +msgid "{percent} used ({total} / {window})" +msgstr "已使用 {percent}({total} / {window})" + # Typer/Click built-in strings #: src/iac_code/i18n/__init__.py:51 msgid "Options" @@ -1049,79 +1319,79 @@ msgstr "" "API 返回了无效响应。请检查您的 API Base URL 是否正确(当前:{base_url})。许多 OpenAI 兼容端点需要 /v1 " "后缀(如 {base_url}/v1)。" -#: src/iac_code/providers/registry.py:415 +#: src/iac_code/providers/registry.py:416 msgid "Alibaba Cloud Bailian" msgstr "阿里云百炼" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "Alibaba Cloud Bailian Token Plan" msgstr "阿里云百炼 Token Plan" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "OpenAPI Compatible" msgstr "OpenAPI 兼容" -#: src/iac_code/providers/registry.py:422 +#: src/iac_code/providers/registry.py:423 msgid "Kimi (China)" msgstr "Kimi(中国版)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "Kimi (International)" msgstr "Kimi(国际版)" -#: src/iac_code/providers/registry.py:424 +#: src/iac_code/providers/registry.py:425 msgid "MiniMax (China)" msgstr "MiniMax(中国版)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "MiniMax (International)" msgstr "MiniMax(国际版)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "ZhiPu AI (International)" msgstr "智谱 AI(国际版)" -#: src/iac_code/providers/registry.py:429 +#: src/iac_code/providers/registry.py:430 msgid "SiliconFlow (China)" msgstr "硅基流动(中国版)" -#: src/iac_code/providers/registry.py:430 +#: src/iac_code/providers/registry.py:431 msgid "SiliconFlow (International)" msgstr "硅基流动(国际版)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "Ollama (Local)" msgstr "Ollama(本地)" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "LM Studio (Local)" msgstr "LM Studio(本地)" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ModelScope" msgstr "魔搭" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Alibaba Cloud CodingPlan" msgstr "阿里云编程计划" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Alibaba Cloud CodingPlan (International)" msgstr "阿里云编程计划(国际版)" -#: src/iac_code/providers/registry.py:438 +#: src/iac_code/providers/registry.py:439 msgid "ZhiPu AI CodingPlan" msgstr "智谱 AI 编程计划" -#: src/iac_code/providers/registry.py:439 +#: src/iac_code/providers/registry.py:440 msgid "ZhiPu AI CodingPlan (International)" msgstr "智谱 AI 编程计划(国际版)" -#: src/iac_code/providers/registry.py:440 +#: src/iac_code/providers/registry.py:441 msgid "Volcengine CodingPlan" msgstr "火山引擎编程计划" -#: src/iac_code/providers/registry.py:441 +#: src/iac_code/providers/registry.py:442 msgid "Anthropic Compatible" msgstr "Anthropic 兼容" @@ -1139,6 +1409,16 @@ msgstr "" "解决方法:在 QwenPaw 中切换到已支持的 provider,或关闭 QwenPaw 模式(从 settings.yml 移除 " "'llm_source: qwenpaw')。" +#: src/iac_code/services/session_metadata.py:52 +#, python-brace-format +msgid "Session name must match {pattern}" +msgstr "会话名称必须匹配 {pattern}" + +#: src/iac_code/services/session_storage.py:241 +#, python-brace-format +msgid "Session name already exists in this project: {name}" +msgstr "此项目中已存在会话名称:{name}" + #: src/iac_code/services/permissions/loader.py:50 #, python-brace-format msgid "Invalid --permission-mode {!r}. Valid values: {}" @@ -1150,15 +1430,128 @@ msgstr "无效的 --permission-mode {!r}。有效值:{}" msgid "Allow {}?" msgstr "允许 {}?" -#: src/iac_code/skills/skill_tool.py:130 +#: src/iac_code/services/providers/aliyun.py:144 +msgid "Alibaba Cloud OAuth site is missing." +msgstr "阿里云 OAuth 站点缺失。" + +#: src/iac_code/services/providers/aliyun.py:150 +msgid "Alibaba Cloud OAuth refresh token is missing." +msgstr "阿里云 OAuth 刷新令牌缺失。" + +#: src/iac_code/services/providers/aliyun.py:158 +msgid "Alibaba Cloud OAuth access token is missing." +msgstr "阿里云 OAuth 访问令牌缺失。" + +#: src/iac_code/services/providers/aliyun_oauth.py:83 +msgid "Run /auth and choose OAuth Login (Browser)." +msgstr "请运行 /auth 并选择 OAuth 登录(浏览器)。" + +#: src/iac_code/services/providers/aliyun_oauth.py:106 +#, python-brace-format +msgid "Unknown Aliyun OAuth site: {site_type}" +msgstr "未知的阿里云 OAuth 站点:{site_type}" + +#: src/iac_code/services/providers/aliyun_oauth.py:164 +msgid "Not found" +msgstr "未找到" + +#: src/iac_code/services/providers/aliyun_oauth.py:170 +msgid "invalid state" +msgstr "无效状态" + +#: src/iac_code/services/providers/aliyun_oauth.py:171 +msgid "Invalid state" +msgstr "无效状态" + +#: src/iac_code/services/providers/aliyun_oauth.py:176 +msgid "code not found" +msgstr "未找到授权码" + +#: src/iac_code/services/providers/aliyun_oauth.py:177 +msgid "Authorization code not found" +msgstr "未找到授权码" + +#: src/iac_code/services/providers/aliyun_oauth.py:181 +msgid "Authorization successful. You can close this window." +msgstr "授权成功。你可以关闭此窗口。" + +#: src/iac_code/services/providers/aliyun_oauth.py:212 +#, python-brace-format +msgid "No available callback port in range {start}-{end}" +msgstr "范围 {start}-{end} 内没有可用的回调端口" + +#: src/iac_code/services/providers/aliyun_oauth.py:227 +msgid "OAuth login cancelled." +msgstr "OAuth 登录已取消。" + +#: src/iac_code/services/providers/aliyun_oauth.py:278 +msgid "Open in your browser:" +msgstr "在浏览器中打开:" + +#: src/iac_code/services/providers/aliyun_oauth.py:299 +msgid "Waiting for browser authorization" +msgstr "等待浏览器授权" + +#: src/iac_code/services/providers/aliyun_oauth.py:300 +msgid "" +"1. The browser may show official-cli; this is the Alibaba Cloud official " +"CLI OAuth application." +msgstr "1. 浏览器可能显示 official-cli,这是 Alibaba Cloud 官方 CLI OAuth 应用。" + +#: src/iac_code/services/providers/aliyun_oauth.py:302 +msgid "" +"2. If assignment is required, assign the RAM user or RAM role that is " +"signed in. User groups are not supported." +msgstr "2. 如需分配应用,请分配给当前登录的 RAM 用户或 RAM 角色;不支持用户组。" + +#: src/iac_code/services/providers/aliyun_oauth.py:306 +msgid "" +"3. After assignment, close the old authorization page and run OAuth Login" +" (Browser) again. If it still fails, sign out of Alibaba Cloud and sign " +"in again." +msgstr "3. 分配后关闭旧授权页,并再次运行 OAuth 登录(浏览器)。若仍失败,请退出 Alibaba Cloud 后重新登录。" + +#: src/iac_code/services/providers/aliyun_oauth.py:310 +msgid "" +"4. STS credentials refresh when possible until Alibaba Cloud expires " +"them. If refresh fails, run /auth again." +msgstr "4. STS 凭证会在可能时自动刷新;如果刷新失败,请重新运行 /auth。" + +#: src/iac_code/services/providers/aliyun_oauth.py:313 +msgid "Press Esc to cancel while waiting." +msgstr "按 Esc 可取消等待。" + +#: src/iac_code/services/providers/aliyun_oauth.py:321 +msgid "" +"Timed out waiting for OAuth callback. If Alibaba Cloud asked you to " +"assign the official-cli application, assign it to the exact RAM user or " +"RAM role currently signed in. User groups are not supported. Then close " +"the old authorization page, sign out of Alibaba Cloud and sign in again " +"if needed, and run /auth to choose OAuth Login (Browser) again." +msgstr "" +"等待 OAuth 回调超时。如果 Alibaba Cloud 要求分配 official-cli 应用,请将它分配给当前实际登录的 RAM 用户或" +" RAM 角色。不支持用户组。然后关闭旧的授权页面,必要时退出 Alibaba Cloud 并重新登录,再运行 /auth 重新选择 OAuth " +"登录(浏览器)。" + +#: src/iac_code/skills/skill_tool.py:85 src/iac_code/ui/repl.py:842 +#, python-brace-format +msgid "Skill '{name}' is disabled. Run /skills to enable it." +msgstr "技能“{name}”已禁用。运行 /skills 以启用它。" + +#: src/iac_code/skills/skill_tool.py:137 #, python-brace-format msgid "Skill '{name}' loaded (inline)." msgstr "技能 '{name}' 已加载(内联)。" -#: src/iac_code/skills/skill_tool.py:214 +#: src/iac_code/skills/skill_tool.py:221 msgid "Skill" msgstr "技能" +#: src/iac_code/skills/skill_tool.py:245 +#, python-brace-format +msgid "Skill disabled: {name}" +msgstr "技能已禁用:{name}" + #: src/iac_code/skills/bundled/simplify.py:25 msgid "" "Review changed code for reuse, quality, and efficiency, then fix issues " @@ -1427,7 +1820,7 @@ msgstr "云API" msgid "Calling {action}..." msgstr "正在调用 {action}..." -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:390 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:400 #: src/iac_code/tools/cloud/base_api.py:123 msgid "Call succeeded" msgstr "调用成功" @@ -1587,11 +1980,11 @@ msgstr "导入完成" msgid "IMPORT_FAILED" msgstr "导入失败" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:171 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:172 msgid "Aliyun API" msgstr "阿里云 API" -#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:389 +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py:399 #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" msgstr "调用成功(RequestId: {request_id})" @@ -1741,23 +2134,19 @@ msgstr "发行说明" msgid "Run {} to update." msgstr "运行 {} 进行更新。" -#: src/iac_code/ui/banner.py:105 +#: src/iac_code/ui/banner.py:110 msgid "Your AI-powered Infrastructure as Code assistant" msgstr "您的 AI 驱动的基础设施即代码助手" -#: src/iac_code/ui/banner.py:131 +#: src/iac_code/ui/banner.py:144 msgid "Welcome back" msgstr "欢迎回来" -#: src/iac_code/ui/banner.py:138 -msgid "Session" -msgstr "会话" - -#: src/iac_code/ui/banner.py:148 +#: src/iac_code/ui/banner.py:161 msgid "Debug mode" msgstr "调试模式" -#: src/iac_code/ui/banner.py:149 +#: src/iac_code/ui/banner.py:162 msgid "Log file" msgstr "日志文件" @@ -1854,97 +2243,109 @@ msgstr "否,始终拒绝 \"{rule}\"(本次会话)" msgid "No, always reject this tool" msgstr "否,始终拒绝此工具" -#: src/iac_code/ui/repl.py:370 +#: src/iac_code/ui/repl.py:424 msgid "Press Ctrl+C again to exit." msgstr "再次按 Ctrl+C 退出。" -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:449 msgid "Interrupted." msgstr "已中断。" -#: src/iac_code/ui/repl.py:432 -msgid "Goodbye!" -msgstr "再见!" - -#: src/iac_code/ui/repl.py:433 -msgid "Resume this session with:" -msgstr "恢复此会话请运行:" - -#: src/iac_code/ui/repl.py:458 +#: src/iac_code/ui/repl.py:508 msgid "Update now" msgstr "立即更新" -#: src/iac_code/ui/repl.py:460 +#: src/iac_code/ui/repl.py:510 msgid "Run the shown update command and exit when it succeeds." msgstr "运行显示的更新命令,成功后退出。" -#: src/iac_code/ui/repl.py:463 +#: src/iac_code/ui/repl.py:513 msgid "Skip" msgstr "跳过" -#: src/iac_code/ui/repl.py:465 +#: src/iac_code/ui/repl.py:515 msgid "Continue with the current version for this session." msgstr "本次会话继续使用当前版本。" -#: src/iac_code/ui/repl.py:468 +#: src/iac_code/ui/repl.py:518 msgid "Skip until next version" msgstr "跳过直到下一个版本" -#: src/iac_code/ui/repl.py:470 +#: src/iac_code/ui/repl.py:520 msgid "Hide this update until a newer version is available." msgstr "隐藏此更新,直到有更新的版本可用。" -#: src/iac_code/ui/repl.py:489 src/iac_code/ui/repl.py:501 +#: src/iac_code/ui/repl.py:539 src/iac_code/ui/repl.py:551 msgid "Update command failed. Continuing with the current version." msgstr "更新命令失败。将继续使用当前版本。" -#: src/iac_code/ui/repl.py:494 +#: src/iac_code/ui/repl.py:544 msgid "Update completed. Restart iac-code to continue." msgstr "更新已完成。请重启 iac-code 以继续。" -#: src/iac_code/ui/repl.py:532 +#: src/iac_code/ui/repl.py:582 msgid "No image in clipboard." msgstr "剪贴板中没有图像。" -#: src/iac_code/ui/repl.py:718 +#: src/iac_code/ui/repl.py:768 msgid "Usage: !" msgstr "用法:!" -#: src/iac_code/ui/repl.py:723 +#: src/iac_code/ui/repl.py:775 msgid "Shell command support is unavailable." msgstr "Shell 命令支持不可用。" -#: src/iac_code/ui/repl.py:787 +#: src/iac_code/ui/repl.py:845 #, python-brace-format msgid "Unknown skill: ${name}. Type / to list commands and skills." msgstr "未知技能:${name}。输入 / 可列出命令和技能。" -#: src/iac_code/ui/repl.py:789 +#: src/iac_code/ui/repl.py:847 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "未知命令:/{name}。输入 /help 查看可用命令。" -#: src/iac_code/ui/repl.py:794 +#: src/iac_code/ui/repl.py:852 #, python-brace-format msgid "$ only invokes skills. Use /{name} instead." msgstr "$ 只能调用技能。请改用 /{name}。" -#: src/iac_code/ui/repl.py:816 src/iac_code/ui/repl.py:861 +#: src/iac_code/ui/repl.py:874 src/iac_code/ui/repl.py:922 #, python-brace-format msgid "Command error: {error}" msgstr "命令错误:{error}" -#: src/iac_code/ui/repl.py:823 +#: src/iac_code/ui/repl.py:881 #, python-brace-format msgid "Command has no handler: {name}" msgstr "命令没有处理器:{name}" -#: src/iac_code/ui/repl.py:1128 +#: src/iac_code/ui/repl.py:1156 +msgid "Goodbye!" +msgstr "再见!" + +#: src/iac_code/ui/repl.py:1157 +msgid "Resume this session with:" +msgstr "恢复此会话请运行:" + +#: src/iac_code/ui/repl.py:1160 +msgid "Session ID" +msgstr "会话 ID" + +#: src/iac_code/ui/repl.py:1210 src/iac_code/ui/repl.py:1214 #, python-brace-format msgid "Session not found: {session_id}" msgstr "会话不存在:{session_id}" -#: src/iac_code/ui/repl.py:1147 +#: src/iac_code/ui/repl.py:1263 +msgid "Session name: " +msgstr "会话名称:" + +#: src/iac_code/ui/repl.py:1269 +msgid "Session name cannot be empty." +msgstr "会话名称不能为空。" + +#: src/iac_code/ui/repl.py:1279 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1955,31 +2356,35 @@ msgstr "" "请运行以下命令恢复:\n" " {cmd}" -#: src/iac_code/ui/repl.py:1186 +#: src/iac_code/ui/repl.py:1283 +msgid "Multiple sessions match. Resume one by ID:" +msgstr "匹配到多个会话。请通过 ID 恢复其中一个:" + +#: src/iac_code/ui/repl.py:1396 msgid "This conversation is from a different directory." msgstr "该会话来自另一个目录。" -#: src/iac_code/ui/repl.py:1188 +#: src/iac_code/ui/repl.py:1398 msgid "To resume, run:" msgstr "请运行以下命令恢复:" -#: src/iac_code/ui/repl.py:1193 +#: src/iac_code/ui/repl.py:1403 msgid "(Command copied to clipboard)" msgstr "(命令已复制到剪贴板)" -#: src/iac_code/ui/repl.py:1350 +#: src/iac_code/ui/repl.py:1560 #, python-brace-format msgid "" "Current model {model} does not support image input. Use /model to switch " "to a vision-capable model." msgstr "当前模型 {model} 不支持图像输入。请使用 /model 切换到支持视觉的模型。" -#: src/iac_code/ui/repl.py:1359 +#: src/iac_code/ui/repl.py:1569 #, python-brace-format msgid "Image error: {err}" msgstr "图像错误:{err}" -#: src/iac_code/ui/repl.py:1376 +#: src/iac_code/ui/repl.py:1586 msgid "" "Failed to persist image to cache; it will only exist in memory for this " "turn." @@ -2073,91 +2478,190 @@ msgstr "输入以搜索文件..." msgid "No matching files" msgstr "没有匹配的文件" -#: src/iac_code/ui/dialogs/resume_picker.py:114 +#: src/iac_code/ui/dialogs/resume_picker.py:116 msgid "Search..." msgstr "搜索…" -#: src/iac_code/ui/dialogs/resume_picker.py:359 +#: src/iac_code/ui/dialogs/resume_picker.py:374 msgid "Resume Session" msgstr "恢复会话" -#: src/iac_code/ui/dialogs/resume_picker.py:369 +#: src/iac_code/ui/dialogs/resume_picker.py:384 msgid "No sessions found" msgstr "未找到会话" -#: src/iac_code/ui/dialogs/resume_picker.py:427 +#: src/iac_code/ui/dialogs/resume_picker.py:444 msgid "show current dir" msgstr "显示当前目录" -#: src/iac_code/ui/dialogs/resume_picker.py:429 +#: src/iac_code/ui/dialogs/resume_picker.py:446 msgid "show all projects" msgstr "显示所有项目" -#: src/iac_code/ui/dialogs/resume_picker.py:432 +#: src/iac_code/ui/dialogs/resume_picker.py:449 msgid "show all branches" msgstr "显示所有分支" -#: src/iac_code/ui/dialogs/resume_picker.py:434 +#: src/iac_code/ui/dialogs/resume_picker.py:451 msgid "only show current branch" msgstr "仅显示当前分支" -#: src/iac_code/ui/dialogs/resume_picker.py:435 +#: src/iac_code/ui/dialogs/resume_picker.py:452 msgid "preview" msgstr "预览" -#: src/iac_code/ui/dialogs/resume_picker.py:436 +#: src/iac_code/ui/dialogs/resume_picker.py:453 msgid "Type to search" msgstr "输入以搜索" -#: src/iac_code/ui/dialogs/resume_picker.py:437 +#: src/iac_code/ui/dialogs/resume_picker.py:454 msgid "cancel" msgstr "取消" -#: src/iac_code/ui/dialogs/resume_picker.py:552 +#: src/iac_code/ui/dialogs/resume_picker.py:569 #, python-brace-format msgid "{n} more line{s}" msgstr "还有 {n} 行" -#: src/iac_code/ui/dialogs/resume_picker.py:566 +#: src/iac_code/ui/dialogs/resume_picker.py:583 #, python-brace-format msgid "{n} message{s}" msgstr "{n} 条消息" -#: src/iac_code/ui/dialogs/resume_picker.py:580 +#: src/iac_code/ui/dialogs/resume_picker.py:597 msgid "resume" msgstr "恢复" -#: src/iac_code/ui/dialogs/resume_picker.py:584 +#: src/iac_code/ui/dialogs/resume_picker.py:601 msgid "back" msgstr "返回" -#: src/iac_code/ui/dialogs/resume_picker.py:589 +#: src/iac_code/ui/dialogs/resume_picker.py:606 msgid "scroll" msgstr "滚动" -#: src/iac_code/ui/dialogs/resume_picker.py:608 +#: src/iac_code/ui/dialogs/resume_picker.py:625 msgid "(empty session)" msgstr "(空会话)" -#: src/iac_code/ui/dialogs/resume_picker.py:728 +#: src/iac_code/ui/dialogs/resume_picker.py:745 msgid "just now" msgstr "刚刚" -#: src/iac_code/ui/dialogs/resume_picker.py:731 +#: src/iac_code/ui/dialogs/resume_picker.py:748 #, python-brace-format msgid "{n} minute{s} ago" msgstr "{n} 分钟前" -#: src/iac_code/ui/dialogs/resume_picker.py:734 +#: src/iac_code/ui/dialogs/resume_picker.py:751 #, python-brace-format msgid "{n} hour{s} ago" msgstr "{n} 小时前" -#: src/iac_code/ui/dialogs/resume_picker.py:736 +#: src/iac_code/ui/dialogs/resume_picker.py:753 #, python-brace-format msgid "{n} day{s} ago" msgstr "{n} 天前" +#: src/iac_code/ui/dialogs/skills_picker.py:52 +msgid "Search skills..." +msgstr "搜索技能..." + +#: src/iac_code/ui/dialogs/skills_picker.py:159 +msgid "Skills" +msgstr "技能" + +#: src/iac_code/ui/dialogs/skills_picker.py:161 +#, python-brace-format +msgid "{current} of {total}" +msgstr "{current} / {total}" + +#: src/iac_code/ui/dialogs/skills_picker.py:165 +#, python-brace-format +msgid "" +"{count} skills - Space to toggle, Enter to save, Tab to sort, Esc to " +"cancel" +msgstr "{count} 个技能 - 空格切换,Enter 保存,Tab 排序,Esc 取消" + +#: src/iac_code/ui/dialogs/skills_picker.py:171 +#, python-brace-format +msgid "Sort: {mode}" +msgstr "排序:{mode}" + +#: src/iac_code/ui/dialogs/skills_picker.py:176 +msgid "No skills found" +msgstr "未找到技能" + +#: src/iac_code/ui/dialogs/skills_picker.py:245 +msgid "Bundled skills cannot be disabled." +msgstr "内置技能不能被禁用。" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "on" +msgstr "启用" + +#: src/iac_code/ui/dialogs/skills_picker.py:259 +msgid "off" +msgstr "禁用" + +#: src/iac_code/ui/dialogs/skills_picker.py:265 +msgid "locked" +msgstr "已锁定" + +#: src/iac_code/ui/dialogs/skills_picker.py:268 +msgid "matched description" +msgstr "匹配描述" + +#: src/iac_code/ui/dialogs/skills_picker.py:277 +msgid "source" +msgstr "来源" + +#: src/iac_code/ui/dialogs/skills_picker.py:279 +msgid "size" +msgstr "大小" + +#: src/iac_code/ui/dialogs/skills_picker.py:280 +msgid "name" +msgstr "名称" + +#: src/iac_code/ui/dialogs/skills_picker.py:285 +msgid "bundled" +msgstr "内置" + +#: src/iac_code/ui/dialogs/skills_picker.py:287 +msgid "project" +msgstr "项目" + +#: src/iac_code/ui/dialogs/skills_picker.py:289 +msgid "user" +msgstr "用户" + +#: src/iac_code/ui/dialogs/skills_picker.py:296 +#, python-brace-format +msgid "~{count}k tokens" +msgstr "约 {count}k 个 token" + +#: src/iac_code/ui/dialogs/skills_picker.py:297 +#, python-brace-format +msgid "~{count} tokens" +msgstr "约 {count} 个 token" + +#: src/iac_code/ui/suggestions/command_provider.py:79 +msgid "Search saved memories" +msgstr "搜索已保存的记忆" + +#: src/iac_code/ui/suggestions/command_provider.py:80 +msgid "Delete a saved memory" +msgstr "删除已保存的记忆" + +#: src/iac_code/ui/suggestions/command_provider.py:81 +msgid "Show memory command help" +msgstr "显示 memory 命令帮助" + +#: src/iac_code/ui/suggestions/command_provider.py:116 +msgid "Saved memory" +msgstr "已保存的记忆" + #: src/iac_code/utils/platform.py:39 msgid "iac-code on Windows requires Git for Windows." msgstr "iac-code 在 Windows 上需要安装 Git for Windows。" @@ -2221,3 +2725,15 @@ msgstr " 方式 2 - 如果无法访问 github.com,运行以下命令通过 np #~ msgid " Option 2 - npmmirror (China-friendly mirror):" #~ msgstr " 方式 2 - npmmirror(国内镜像):" +#~ msgid "Cache create" +#~ msgstr "缓存创建" + +#~ msgid "" +#~ "{count} skills - Space to toggle, " +#~ "Enter to save, / to search, t " +#~ "to sort, Esc to cancel" +#~ msgstr "{count} 个技能 - 空格切换,Enter 保存,/ 搜索,t 排序,Esc 取消" + +#~ msgid "Resume a session by ID" +#~ msgstr "通过 ID 恢复会话" + diff --git a/src/iac_code/memory/memory_manager.py b/src/iac_code/memory/memory_manager.py index bfe0369..e626e96 100644 --- a/src/iac_code/memory/memory_manager.py +++ b/src/iac_code/memory/memory_manager.py @@ -18,8 +18,9 @@ class MemoryManager: def __init__(self, memory_dir: str): - self._memory_dir = memory_dir - ensure_private_dir(Path(memory_dir)) + memory_path = ensure_private_dir(Path(memory_dir)) + self._memory_dir = memory_path + self._memory_root = memory_path.resolve() @staticmethod def _validate_name(name: str) -> str: @@ -34,32 +35,39 @@ def _validate_name(name: str) -> str: raise ValueError(f"Invalid memory name: {name!r}") return cleaned - def _memory_path(self, name: str) -> str: + def _memory_path(self, name: str) -> Path: safe_name = self._validate_name(name) - return os.path.join(self._memory_dir, f"{safe_name}.md") + return self._memory_dir / f"{safe_name}.md" - def _index_path(self) -> str: - return os.path.join(self._memory_dir, INDEX_FILE) + def _index_path(self) -> Path: + return self._memory_dir / INDEX_FILE def save(self, name: str, content: str, memory_type: str, description: str) -> None: if memory_type not in MEMORY_TYPES: raise ValueError(f"Invalid memory type: {memory_type}") file_content = f"---\nname: {name}\ndescription: {description}\ntype: {memory_type}\n---\n\n{content}\n" path = self._memory_path(name) + self._ensure_writable_path(path) + self._ensure_writable_path(self._index_path()) with open(path, "w", encoding="utf-8", newline="\n") as f: f.write(file_content) - ensure_private_file(Path(path)) + ensure_private_file(path) self._update_index() def load(self, name: str) -> dict[str, Any] | None: path = self._memory_path(name) - if not os.path.exists(path): + safe_path = self._safe_existing_file(path) + if safe_path is None: return None - return self._load_memory_file(Path(path)) + return self._load_memory_file(safe_path) def delete(self, name: str) -> None: path = self._memory_path(name) - if os.path.exists(path): + self._ensure_writable_path(self._index_path()) + if path.is_symlink(): + raise ValueError(f"Invalid memory path: {path.name}") + if path.exists(): + self._ensure_writable_path(path) os.remove(path) self._update_index() @@ -71,11 +79,26 @@ def list_memories(self) -> list[dict[str, Any]]: memories.append(mem) return memories + def search(self, query: str) -> list[dict[str, Any]]: + needle = query.strip().casefold() + if not needle: + return [] + + matches: list[dict[str, Any]] = [] + for memory in self.list_memories(): + haystack = "\n".join( + str(memory.get(field, "")) for field in ("name", "description", "type", "content") + ).casefold() + if needle in haystack: + matches.append(memory) + return matches + def get_index_content(self) -> str: path = self._index_path() - if not os.path.exists(path): + safe_path = self._safe_existing_file(path) + if safe_path is None: return "" - with open(path, encoding="utf-8") as f: + with open(safe_path, encoding="utf-8") as f: return f.read() def get_prompt_content(self) -> str: @@ -91,18 +114,50 @@ def _update_index(self) -> None: if mem: entries.append(f"- [{path.stem}]({path.name}) — {mem.get('description', '')}") index_path = self._index_path() + self._ensure_writable_path(index_path) with open(index_path, "w", encoding="utf-8", newline="\n") as f: f.write("\n".join(entries[:MAX_INDEX_LINES]) + "\n") - ensure_private_file(Path(index_path)) + ensure_private_file(index_path) def _iter_memory_files(self) -> list[Path]: - root = Path(self._memory_dir) + root = self._memory_dir return [ - path + safe_path for path in root.iterdir() - if path.is_file() and path.suffix == ".md" and path.name.casefold() != INDEX_FILE.casefold() + if (safe_path := self._safe_existing_file(path)) is not None + and path.suffix == ".md" + and path.name.casefold() != INDEX_FILE.casefold() ] + def _safe_existing_file(self, path: Path) -> Path | None: + if path.is_symlink(): + return None + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError): + return None + if not resolved.is_relative_to(self._memory_root) or not path.is_file(): + return None + return path + + def _ensure_writable_path(self, path: Path) -> None: + if path.is_symlink(): + raise ValueError(f"Invalid memory path: {path.name}") + try: + parent = path.parent.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise ValueError(f"Invalid memory path: {path.name}") from exc + if parent != self._memory_root: + raise ValueError(f"Invalid memory path: {path.name}") + if not path.exists(): + return + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise ValueError(f"Invalid memory path: {path.name}") from exc + if not resolved.is_relative_to(self._memory_root) or not path.is_file(): + raise ValueError(f"Invalid memory path: {path.name}") + def _load_memory_file(self, path: Path) -> dict[str, Any] | None: try: return self._parse_memory_file(path.read_text(encoding="utf-8")) diff --git a/src/iac_code/memory/memory_tools.py b/src/iac_code/memory/memory_tools.py index b8d274a..caba4b6 100644 --- a/src/iac_code/memory/memory_tools.py +++ b/src/iac_code/memory/memory_tools.py @@ -57,7 +57,12 @@ def name(self) -> str: @property def description(self) -> str: - return f"Save a persistent memory. Types: {', '.join(sorted(MEMORY_TYPES))}." + types = ", ".join(sorted(MEMORY_TYPES)) + return ( + "Save a persistent memory. Use when the user explicitly asks you to remember or preserve information. " + "Choose a concise, stable name, an appropriate type, a short description, and the useful content to keep. " + f"Types: {types}." + ) @property def input_schema(self) -> dict[str, Any]: diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index 591baf4..0008766 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -233,6 +233,28 @@ def reconfigure( def get_model_name(self) -> str: return self._model + def get_provider_key(self) -> str: + """Return the runtime provider key without forcing provider creation.""" + if self._provider_key_override: + return self._provider_key_override + if self._provider is not None: + key = getattr(self._provider, "_PROVIDER_KEY", "") + if isinstance(key, str) and key: + return key + try: + return _detect_provider_name(self._model) + except ValueError: + return "" + + def get_provider_display(self) -> str: + key = self.get_provider_key() + if not key: + return "" + from iac_code.providers.registry import PROVIDER_REGISTRY + + descriptor = PROVIDER_REGISTRY.get(key) + return descriptor.display_name if descriptor is not None else key + def _get_fallback_model(self) -> str | None: return MODEL_FALLBACK_MAP.get(self._model) diff --git a/src/iac_code/providers/registry.py b/src/iac_code/providers/registry.py index 04289a3..b818982 100644 --- a/src/iac_code/providers/registry.py +++ b/src/iac_code/providers/registry.py @@ -46,6 +46,7 @@ def model_ids(self) -> list[str]: base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", models=[ ModelEntry("qwen3.7-max", is_default=True), + ModelEntry("qwen3.7-plus", support_multimodal=True), ModelEntry("qwen3.6-plus", support_multimodal=True), ModelEntry("qwen3.6-max-preview"), ModelEntry("qwen3-max"), diff --git a/src/iac_code/providers/thinking.py b/src/iac_code/providers/thinking.py index 6c0eb71..5949d2b 100644 --- a/src/iac_code/providers/thinking.py +++ b/src/iac_code/providers/thinking.py @@ -131,6 +131,7 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: }, "dashscope": { "qwen3.7-max": ThinkingSpec(ThinkingFamily.DASHSCOPE), + "qwen3.7-plus": ThinkingSpec(ThinkingFamily.DASHSCOPE), "qwen3.6-max-preview": ThinkingSpec(ThinkingFamily.DASHSCOPE), "qwen3.6-plus": ThinkingSpec(ThinkingFamily.DASHSCOPE), "qwen3.5-plus": ThinkingSpec(ThinkingFamily.DASHSCOPE), diff --git a/src/iac_code/services/agent_factory.py b/src/iac_code/services/agent_factory.py index dbbbbee..dbb85b4 100644 --- a/src/iac_code/services/agent_factory.py +++ b/src/iac_code/services/agent_factory.py @@ -44,8 +44,10 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: from iac_code.services.cloud_credentials import CloudCredentials from iac_code.services.session_storage import SessionStorage from iac_code.skills.bundled import init_bundled_skills - from iac_code.skills.discovery import discover_all_skills, skill_to_command + from iac_code.skills.discovery import discover_all_skills from iac_code.skills.listing import build_skill_listing + from iac_code.skills.management import build_skill_management_state + from iac_code.skills.settings import load_disabled_skills from iac_code.skills.skill_tool import SkillTool from iac_code.tasks.notification_queue import NotificationQueue from iac_code.tasks.task_state import TaskManager @@ -123,8 +125,8 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: init_bundled_skills() command_registry = create_default_registry() - for skill in discover_all_skills(cwd): - cmd = skill_to_command(skill) + skill_state = build_skill_management_state(discover_all_skills(cwd), load_disabled_skills()) + for cmd in skill_state.enabled_commands: existing = command_registry.get(cmd.name) if existing is not None and not isinstance(existing, PromptCommand): logger.warning("Skill '{}' skipped: conflicts with built-in command", cmd.name) @@ -139,6 +141,7 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: provider_manager=provider_manager, tool_registry=tool_registry, system_prompt=base_system_prompt, + disabled_skills=skill_state.disabled_commands, ) ) diff --git a/src/iac_code/services/providers/aliyun.py b/src/iac_code/services/providers/aliyun.py index d92ca20..f858e77 100644 --- a/src/iac_code/services/providers/aliyun.py +++ b/src/iac_code/services/providers/aliyun.py @@ -1,16 +1,18 @@ import json import os +import time from dataclasses import dataclass, field from pathlib import Path from typing import Any from iac_code.config import _load_yaml, _save_yaml, get_cloud_credentials_path +from iac_code.i18n import _ DEFAULT_REGION = "cn-hangzhou" DEFAULT_ALIYUN_CLI_CONFIG_PATH = os.path.expanduser("~/.aliyun/config.json") # Credential modes matching aliyun CLI -CREDENTIAL_MODES = ["AK", "StsToken", "RamRoleArn"] +CREDENTIAL_MODES = ["AK", "StsToken", "RamRoleArn", "OAuth"] # Fields definition for each credential mode # Each field: (name, label, sensitive) @@ -30,6 +32,17 @@ ("ram_role_arn", "RAM Role ARN", False), ("ram_session_name", "Session Name", False), ], + "OAuth": [ + ("oauth_site_type", "OAuth Site Type", False), + ("oauth_access_token", "OAuth Access Token", True), + ("oauth_refresh_token", "OAuth Refresh Token", True), + ("oauth_access_token_expire", "OAuth Access Token Expire", False), + ("oauth_refresh_token_expire", "OAuth Refresh Token Expire", False), + ("access_key_id", "AccessKey ID", True), + ("access_key_secret", "AccessKey Secret", True), + ("sts_token", "STS Token", True), + ("sts_expiration", "STS Expiration", False), + ], } # Display names for credential modes (English, translatable via i18n) @@ -37,6 +50,7 @@ "AK": "AccessKey", "StsToken": "STS Token", "RamRoleArn": "RAM Role", + "OAuth": "OAuth Login (Browser)", } @@ -47,8 +61,14 @@ class AliyunCredential: access_key_secret: str = "" region_id: str = field(default=DEFAULT_REGION) sts_token: str = "" + sts_expiration: int = 0 ram_role_arn: str = "" ram_session_name: str = "" + oauth_site_type: str = "" + oauth_access_token: str = "" + oauth_refresh_token: str = "" + oauth_access_token_expire: int = 0 + oauth_refresh_token_expire: int = 0 def mask_sensitive(value: str) -> str: @@ -95,6 +115,57 @@ def load(config_path: str | None = None) -> AliyunCredential | None: # Fall back to aliyun CLI config return AliyunCredentials._load_from_aliyun_cli(config_path) + @staticmethod + def refresh_oauth_if_needed( + credential: AliyunCredential, + *, + oauth_client: Any | None = None, + now: int | None = None, + ) -> AliyunCredential: + """Refresh OAuth-backed STS credentials before Alibaba Cloud API use.""" + from iac_code.services.providers.aliyun_oauth import ( + ACCESS_TOKEN_SKEW_SECONDS, + STS_SKEW_SECONDS, + AliyunOAuthClient, + AliyunOAuthReloginRequired, + get_oauth_site, + is_epoch_expired, + ) + + if credential.mode != "OAuth": + return credential + + current = int(time.time()) if now is None else now + has_sts = bool(credential.access_key_id and credential.access_key_secret and credential.sts_token) + if has_sts and not is_epoch_expired(credential.sts_expiration, current, STS_SKEW_SECONDS): + return credential + + if not credential.oauth_site_type: + raise AliyunOAuthReloginRequired(_("Alibaba Cloud OAuth site is missing.")) + + client = oauth_client or AliyunOAuthClient(get_oauth_site(credential.oauth_site_type)) + + if is_epoch_expired(credential.oauth_access_token_expire, current, ACCESS_TOKEN_SKEW_SECONDS): + if not credential.oauth_refresh_token: + raise AliyunOAuthReloginRequired(_("Alibaba Cloud OAuth refresh token is missing.")) + token = client.refresh_access_token(credential.oauth_refresh_token, now=current) + credential.oauth_access_token = token.access_token + credential.oauth_refresh_token = token.refresh_token + credential.oauth_access_token_expire = token.access_token_expire + credential.oauth_refresh_token_expire = token.refresh_token_expire + + if not credential.oauth_access_token: + raise AliyunOAuthReloginRequired(_("Alibaba Cloud OAuth access token is missing.")) + + sts = client.exchange_access_token_for_sts(credential.oauth_access_token) + credential.access_key_id = sts.access_key_id + credential.access_key_secret = sts.access_key_secret + credential.sts_token = sts.sts_token + credential.sts_expiration = sts.sts_expiration + + AliyunCredentials.save(credential) + return credential + @staticmethod def _load_from_iac_code_config() -> AliyunCredential | None: """Load credentials from ~/.iac-code/.cloud-credentials.yml.""" @@ -113,8 +184,14 @@ def _load_from_iac_code_config() -> AliyunCredential | None: access_key_secret=aliyun_data.get("access_key_secret", ""), region_id=aliyun_data.get("region_id", DEFAULT_REGION), sts_token=aliyun_data.get("sts_token", ""), + sts_expiration=int(aliyun_data.get("sts_expiration") or 0), ram_role_arn=aliyun_data.get("ram_role_arn", ""), ram_session_name=aliyun_data.get("ram_session_name", ""), + oauth_site_type=aliyun_data.get("oauth_site_type", ""), + oauth_access_token=aliyun_data.get("oauth_access_token", ""), + oauth_refresh_token=aliyun_data.get("oauth_refresh_token", ""), + oauth_access_token_expire=int(aliyun_data.get("oauth_access_token_expire") or 0), + oauth_refresh_token_expire=int(aliyun_data.get("oauth_refresh_token_expire") or 0), ) @staticmethod @@ -141,8 +218,14 @@ def _load_from_aliyun_cli(config_path: str | None = None) -> AliyunCredential | access_key_secret=profile.get("access_key_secret", ""), region_id=profile.get("region_id", DEFAULT_REGION), sts_token=profile.get("sts_token", ""), + sts_expiration=int(profile.get("sts_expiration") or 0), ram_role_arn=profile.get("ram_role_arn", ""), ram_session_name=profile.get("ram_session_name", ""), + oauth_site_type=profile.get("oauth_site_type", ""), + oauth_access_token=profile.get("oauth_access_token", ""), + oauth_refresh_token=profile.get("oauth_refresh_token", ""), + oauth_access_token_expire=int(profile.get("oauth_access_token_expire") or 0), + oauth_refresh_token_expire=int(profile.get("oauth_refresh_token_expire") or 0), ) @staticmethod @@ -175,7 +258,20 @@ def save( # Save fields relevant to the credential mode mode_fields = MODE_FIELDS.get(credential.mode, []) for field_name, _label, _sensitive in mode_fields: - aliyun_data[field_name] = getattr(credential, field_name, "") + value = getattr(credential, field_name, "") + if value in ("", None): + continue + if ( + field_name + in { + "sts_expiration", + "oauth_access_token_expire", + "oauth_refresh_token_expire", + } + and value == 0 + ): + continue + aliyun_data[field_name] = value cloud_creds["aliyun"] = aliyun_data _save_yaml(path, cloud_creds) @@ -197,20 +293,26 @@ def _save_to_aliyun_cli_format(credential: AliyunCredential, config_path: str) - except (json.JSONDecodeError, OSError): pass - updated_profile: dict[str, str] = { + updated_profile: dict[str, Any] = { "name": "default", "mode": credential.mode, "access_key_id": credential.access_key_id, "access_key_secret": credential.access_key_secret, "region_id": credential.region_id, "sts_token": credential.sts_token, + "sts_expiration": credential.sts_expiration, "ram_role_arn": credential.ram_role_arn, "ram_session_name": credential.ram_session_name, + "oauth_site_type": credential.oauth_site_type, + "oauth_access_token": credential.oauth_access_token, + "oauth_refresh_token": credential.oauth_refresh_token, + "oauth_access_token_expire": credential.oauth_access_token_expire, + "oauth_refresh_token_expire": credential.oauth_refresh_token_expire, } raw_profiles = data.get("profiles") - profiles: list[dict[str, str]] = ( - cast(list[dict[str, str]], raw_profiles) if isinstance(raw_profiles, list) else [] + profiles: list[dict[str, Any]] = ( + cast(list[dict[str, Any]], raw_profiles) if isinstance(raw_profiles, list) else [] ) for i, profile in enumerate(profiles): diff --git a/src/iac_code/services/providers/aliyun_oauth.py b/src/iac_code/services/providers/aliyun_oauth.py new file mode 100644 index 0000000..7d3220b --- /dev/null +++ b/src/iac_code/services/providers/aliyun_oauth.py @@ -0,0 +1,583 @@ +import base64 +import hashlib +import queue +import secrets +import threading +import time +import webbrowser +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx + +from iac_code.i18n import _ + +CALLBACK_HOST = "127.0.0.1" +CALLBACK_PATH = "/cli/callback" +CALLBACK_PORTS = tuple(range(12345, 12350)) +DEFAULT_CALLBACK_TIMEOUT_SECONDS = 300 +ACCESS_TOKEN_SKEW_SECONDS = 60 +STS_SKEW_SECONDS = 120 +PERMANENT_OAUTH_ERROR_CODES = {"invalid_grant", "invalid_client", "unauthorized_client", "invalid_token"} + + +@dataclass(frozen=True) +class AliyunOAuthSite: + site_type: str + display_name: str + client_id: str + signin_base_url: str + oauth_base_url: str + + +OAUTH_SITES: dict[str, AliyunOAuthSite] = { + "CN": AliyunOAuthSite( + site_type="CN", + display_name="China", + client_id="4038181954557748008", + signin_base_url="https://signin.aliyun.com", + oauth_base_url="https://oauth.aliyun.com", + ), + "INTL": AliyunOAuthSite( + site_type="INTL", + display_name="International", + client_id="4103531455503354461", + signin_base_url="https://signin.alibabacloud.com", + oauth_base_url="https://oauth.alibabacloud.com", + ), +} + + +@dataclass(frozen=True) +class OAuthToken: + access_token: str + refresh_token: str + access_token_expire: int + refresh_token_expire: int = 0 + + +@dataclass(frozen=True) +class OAuthStsCredentials: + access_key_id: str + access_key_secret: str + sts_token: str + sts_expiration: int + + +class AliyunOAuthError(RuntimeError): + def __init__(self, message: str, *, error_code: str | None = None, status_code: int | None = None) -> None: + super().__init__(message) + self.error_code = error_code + self.status_code = status_code + + +class AliyunOAuthCancelledError(AliyunOAuthError): + pass + + +def oauth_relogin_hint() -> str: + return _("Run /auth and choose OAuth Login (Browser).") + + +class AliyunOAuthReloginRequired(AliyunOAuthError): # noqa: N818 + def __init__(self, message: str, *, error_code: str | None = None, status_code: int | None = None) -> None: + hint = oauth_relogin_hint() + if hint not in message: + message = "{} {}".format(message, hint) + super().__init__(message, error_code=error_code, status_code=status_code) + + +def get_oauth_site(site_type: str) -> AliyunOAuthSite: + normalized = site_type.strip().lower() + aliases = { + "cn": "CN", + "china": "CN", + "aliyun": "CN", + "intl": "INTL", + "international": "INTL", + "alibabacloud": "INTL", + } + site_key = aliases.get(normalized) + if not site_key: + raise AliyunOAuthError(_("Unknown Aliyun OAuth site: {site_type}").format(site_type=site_type)) + return OAUTH_SITES[site_key] + + +def oauth_site_options() -> list[tuple[str, str]]: + return [("CN", "China"), ("INTL", "International")] + + +def generate_state() -> str: + return secrets.token_urlsafe(16) + + +def generate_code_verifier() -> str: + return secrets.token_urlsafe(96)[:128] + + +def generate_code_challenge(code_verifier: str) -> str: + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def build_authorization_url(site: AliyunOAuthSite, redirect_uri: str, state: str, code_challenge: str) -> str: + query = urlencode( + { + "response_type": "code", + "client_id": site.client_id, + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + ) + return "{}/oauth2/v1/auth?{}".format(site.signin_base_url.rstrip("/"), query) + + +class OAuthCallbackServer: + def __init__( + self, + ports: tuple[int, ...] = CALLBACK_PORTS, + timeout_seconds: int = DEFAULT_CALLBACK_TIMEOUT_SECONDS, + ) -> None: + self.ports = ports + self.timeout_seconds = timeout_seconds + self.redirect_uri = "" + self._results: queue.Queue[tuple[str, str]] = queue.Queue(maxsize=1) + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + + def start(self, expected_state: str) -> None: + if self._server is not None: + return + + result_queue = self._results + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + parsed_url = urlparse(self.path) + if parsed_url.path != CALLBACK_PATH: + self._send_plain_response(404, _("Not found")) + return + + query = parse_qs(parsed_url.query) + state = query.get("state", [""])[0] + if state != expected_state: + self._put_result(("error", _("invalid state"))) + self._send_plain_response(400, _("Invalid state")) + return + + code = query.get("code", [""])[0] + if not code: + self._put_result(("error", _("code not found"))) + self._send_plain_response(400, _("Authorization code not found")) + return + + self._put_result(("code", code)) + self._send_plain_response(200, _("Authorization successful. You can close this window.")) + + def log_message(self, format: str, *args: Any) -> None: + return + + def _put_result(self, result: tuple[str, str]) -> None: + try: + result_queue.put_nowait(result) + except queue.Full: + return + + def _send_plain_response(self, status_code: int, body: str) -> None: + body_bytes = body.encode("utf-8") + self.send_response(status_code) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body_bytes))) + self.end_headers() + self.wfile.write(body_bytes) + + last_error: OSError | None = None + for port in self.ports: + try: + self._server = ThreadingHTTPServer((CALLBACK_HOST, port), CallbackHandler) + except OSError as exc: + last_error = exc + continue + self.redirect_uri = "http://{}:{}{}".format(CALLBACK_HOST, port, CALLBACK_PATH) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return + + message = _("No available callback port in range {start}-{end}").format( + start=self.ports[0], + end=self.ports[-1], + ) + raise AliyunOAuthError(message) from last_error + + def wait_for_code( + self, + *, + cancel_event: threading.Event | None = None, + poll_interval_seconds: float = 0.1, + ) -> str: + deadline = time.monotonic() + self.timeout_seconds + while True: + if cancel_event is not None and cancel_event.is_set(): + raise AliyunOAuthCancelledError(_("OAuth login cancelled.")) + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AliyunOAuthError(oauth_callback_timeout_message()) + + try: + result_type, value = self._results.get(timeout=min(poll_interval_seconds, remaining)) + break + except queue.Empty: + continue + + if result_type == "error": + raise AliyunOAuthError(value) + return value + + def close(self) -> None: + server = self._server + thread = self._thread + self._server = None + self._thread = None + if server is None: + return + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=1) + + +def run_browser_oauth_flow( + site_type: str, + *, + oauth_client: Any | None = None, + browser_opener: Callable[[str], bool] = webbrowser.open, + callback_server_factory: Callable[[], Any] | None = None, + writer: Callable[[str], None] = print, + cancel_event: threading.Event | None = None, + now: int | None = None, +) -> OAuthToken: + site = get_oauth_site(site_type) + client = oauth_client or AliyunOAuthClient(site) + server = callback_server_factory() if callback_server_factory is not None else OAuthCallbackServer() + state = generate_state() + code_verifier = generate_code_verifier() + code_challenge = generate_code_challenge(code_verifier) + + try: + server.start(state) + url = build_authorization_url(site, server.redirect_uri, state, code_challenge) + for line in oauth_browser_login_guidance(): + writer(line) + writer(" {}".format(_("Open in your browser:"))) + writer(" {}".format(url)) + try: + browser_opener(url) + except Exception: + pass + + code = server.wait_for_code(cancel_event=cancel_event) if cancel_event is not None else server.wait_for_code() + return client.exchange_code_for_token( + code=code, + redirect_uri=server.redirect_uri, + code_verifier=code_verifier, + now=now, + ) + finally: + server.close() + + +def oauth_browser_login_guidance() -> list[str]: + messages = [ + "", + _("Waiting for browser authorization"), + _("1. The browser may show official-cli; this is the Alibaba Cloud official CLI OAuth application."), + _( + "2. If assignment is required, assign the RAM user or RAM role that is signed in. " + "User groups are not supported." + ), + _( + "3. After assignment, close the old authorization page and run OAuth Login (Browser) again. " + "If it still fails, sign out of Alibaba Cloud and sign in again." + ), + _( + "4. STS credentials refresh when possible until Alibaba Cloud expires them. " + "If refresh fails, run /auth again." + ), + _("Press Esc to cancel while waiting."), + "", + ] + return ["" if not message else " {}".format(message) for message in messages] + + +def oauth_callback_timeout_message() -> str: + return _( + "Timed out waiting for OAuth callback. If Alibaba Cloud asked you to assign the official-cli application, " + "assign it to the exact RAM user or RAM role currently signed in. User groups are not supported. " + "Then close the old authorization page, sign out of Alibaba Cloud and sign in again if needed, " + "and run /auth to choose OAuth Login (Browser) again." + ) + + +def is_epoch_expired(expiration: int, now: int | None = None, skew_seconds: int = 0) -> bool: + if expiration <= 0: + return True + current_time = int(time.time()) if now is None else now + return expiration <= current_time + skew_seconds + + +def parse_sts_exchange_response(data: dict[str, Any]) -> OAuthStsCredentials: + access_key_id = _first_present(data, "accessKeyId", "AccessKeyId") + access_key_secret = _first_present(data, "accessKeySecret", "AccessKeySecret") + sts_token = _first_present(data, "securityToken", "SecurityToken") + expiration = _first_present(data, "expiration", "Expiration") + + missing = [ + name + for name, value in ( + ("accessKeyId", access_key_id), + ("accessKeySecret", access_key_secret), + ("securityToken", sts_token), + ("expiration", expiration), + ) + if value in (None, "") + ] + if missing: + raise AliyunOAuthError("STS exchange response missing required field(s): {}".format(", ".join(missing))) + + return OAuthStsCredentials( + access_key_id=str(access_key_id), + access_key_secret=str(access_key_secret), + sts_token=str(sts_token), + sts_expiration=_parse_expiration(expiration), + ) + + +class AliyunOAuthClient: + def __init__(self, site: AliyunOAuthSite, http_client: httpx.Client | None = None) -> None: + self.site = site + self.http_client = http_client or httpx.Client(timeout=30.0) + + def exchange_code_for_token( + self, + code: str, + redirect_uri: str, + code_verifier: str, + now: int | None = None, + ) -> OAuthToken: + response = self._post( + "{}/v1/token".format(self.site.oauth_base_url.rstrip("/")), + "exchange authorization code for token", + sensitive_values=(code, code_verifier), + data={ + "grant_type": "authorization_code", + "code": code, + "client_id": self.site.client_id, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + ) + self._raise_for_oauth_error( + response, + "exchange authorization code for token", + sensitive_values=(code, code_verifier), + ) + data = self._json_response(response, "exchange authorization code for token") + return self._parse_token_response( + data, + operation="exchange authorization code for token", + fallback_refresh_token=None, + now=now, + ) + + def refresh_access_token(self, refresh_token: str, now: int | None = None) -> OAuthToken: + response = self._post( + "{}/v1/token".format(self.site.oauth_base_url.rstrip("/")), + "refresh access token", + sensitive_values=(refresh_token,), + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self.site.client_id, + }, + ) + self._raise_for_oauth_error(response, "refresh access token", sensitive_values=(refresh_token,)) + data = self._json_response(response, "refresh access token") + return self._parse_token_response( + data, + operation="refresh access token", + fallback_refresh_token=refresh_token, + now=now, + ) + + def exchange_access_token_for_sts(self, access_token: str) -> OAuthStsCredentials: + response = self._post( + "{}/v1/exchange".format(self.site.oauth_base_url.rstrip("/")), + "exchange access token for STS", + sensitive_values=(access_token,), + headers={"Authorization": "Bearer {}".format(access_token), "Content-Type": "application/json"}, + json={}, + ) + self._raise_for_oauth_error( + response, + "exchange access token for STS", + sensitive_values=(access_token,), + ) + data = self._json_response(response, "exchange access token for STS") + return parse_sts_exchange_response(data) + + def _parse_token_response( + self, + data: dict[str, Any], + *, + operation: str, + fallback_refresh_token: str | None, + now: int | None, + ) -> OAuthToken: + access_token = data.get("access_token") + refresh_token = data.get("refresh_token") or fallback_refresh_token + expires_in = data.get("expires_in") + missing = [ + name + for name, value in ( + ("access_token", access_token), + ("refresh_token", refresh_token), + ("expires_in", expires_in), + ) + if value in (None, "") + ] + if missing: + raise AliyunOAuthError("{} response missing required field(s): {}".format(operation, ", ".join(missing))) + + current_time = int(time.time()) if now is None else now + access_token_expire = current_time + _parse_int(expires_in, "expires_in") + refresh_expires_in = data.get("refresh_expires_in") + refresh_token_expire = 0 + if refresh_expires_in not in (None, ""): + refresh_token_expire = current_time + _parse_int(refresh_expires_in, "refresh_expires_in") + + return OAuthToken( + access_token=str(access_token), + refresh_token=str(refresh_token), + access_token_expire=access_token_expire, + refresh_token_expire=refresh_token_expire, + ) + + def _raise_for_oauth_error( + self, + response: httpx.Response, + operation: str, + sensitive_values: tuple[str, ...] = (), + ) -> None: + if response.status_code == 200: + return + + body = _response_json_or_empty(response) + error_code = _string_or_none(body.get("error")) + error_description = _redact_sensitive_values(_string_or_none(body.get("error_description")), sensitive_values) + message_parts = ["{} failed with status {}".format(operation, response.status_code)] + if error_code: + message_parts.append("error={}".format(error_code)) + if error_description: + message_parts.append("error_description={}".format(error_description)) + if len(message_parts) > 1: + message = ": ".join([message_parts[0], ", ".join(message_parts[1:])]) + else: + message = message_parts[0] + + error_cls = AliyunOAuthReloginRequired if error_code in PERMANENT_OAUTH_ERROR_CODES else AliyunOAuthError + raise error_cls(message, error_code=error_code, status_code=response.status_code) + + def _post( + self, + url: str, + operation: str, + *, + sensitive_values: tuple[str, ...], + **kwargs: Any, + ) -> httpx.Response: + try: + return self.http_client.post(url, **kwargs) + except httpx.HTTPError as exc: + detail = _redact_sensitive_values(str(exc), sensitive_values) or exc.__class__.__name__ + raise AliyunOAuthError("{} request failed: {}".format(operation, detail)) from exc + + def _json_response(self, response: httpx.Response, operation: str) -> dict[str, Any]: + try: + data = response.json() + except ValueError as exc: + raise AliyunOAuthError( + "{} response was not valid JSON".format(operation), + status_code=response.status_code, + ) from exc + if not isinstance(data, dict): + raise AliyunOAuthError( + "{} response JSON was not an object".format(operation), + status_code=response.status_code, + ) + return data + + +def _first_present(data: dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in data: + return data[key] + return None + + +def _parse_expiration(value: Any) -> int: + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + stripped = value.strip() + if stripped.isdigit(): + return int(stripped) + if stripped.endswith("Z"): + stripped = "{}+00:00".format(stripped[:-1]) + try: + parsed = datetime.fromisoformat(stripped) + except ValueError as exc: + raise AliyunOAuthError("STS exchange response has invalid expiration") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp()) + raise AliyunOAuthError("STS exchange response has invalid expiration") + + +def _parse_int(value: Any, field_name: str) -> int: + try: + return int(value) + except (TypeError, ValueError) as exc: + raise AliyunOAuthError("OAuth token response has invalid {}".format(field_name)) from exc + + +def _response_json_or_empty(response: httpx.Response) -> dict[str, Any]: + try: + data = response.json() + except ValueError: + return {} + return data if isinstance(data, dict) else {} + + +def _string_or_none(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value) + + +def _redact_sensitive_values(value: str | None, sensitive_values: tuple[str, ...]) -> str | None: + if value is None: + return None + redacted = value + for sensitive_value in sensitive_values: + if sensitive_value: + redacted = redacted.replace(sensitive_value, "[REDACTED]") + return redacted diff --git a/src/iac_code/services/session_index.py b/src/iac_code/services/session_index.py index d57112b..dcfc128 100644 --- a/src/iac_code/services/session_index.py +++ b/src/iac_code/services/session_index.py @@ -14,7 +14,13 @@ from dataclasses import dataclass from pathlib import Path -from iac_code.utils.project_paths import get_project_dir, get_projects_dir, sanitize_path +from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, read_session_metadata +from iac_code.utils.project_paths import ( + get_project_dir, + get_projects_dir, + is_conversation_session_file, + sanitize_path, +) LITE_READ_BUF_SIZE = 64 * 1024 @@ -36,6 +42,9 @@ class SessionEntry: title: str mtime: float size_bytes: int + name: str | None = None + auto_title: str | None = None + is_legacy: bool = True # --------------------------------------------------------------------------- @@ -199,23 +208,45 @@ def _trim_title(text: str, max_len: int = 200) -> str: return flat[:max_len].rstrip() + "…" -def _build_entry(path: Path, fallback_cwd: str) -> SessionEntry | None: +def _iter_session_files(project_dir: Path) -> list[tuple[Path, str]]: + files_by_session_id = { + jsonl.stem: jsonl for jsonl in project_dir.glob("*.jsonl") if is_conversation_session_file(jsonl) + } + for session_dir in project_dir.iterdir(): + if not session_dir.is_dir(): + continue + jsonl = session_dir / SESSION_JSONL_FILENAME + if jsonl.exists(): + files_by_session_id[session_dir.name] = jsonl + return [(jsonl, session_id) for session_id, jsonl in files_by_session_id.items()] + + +def _build_entry(path: Path, fallback_cwd: str, session_id: str | None = None) -> SessionEntry | None: try: stat = path.stat() except OSError: return None - meta = read_lite_metadata(path) - cwd = meta.cwd or fallback_cwd - title_raw = meta.last_prompt or meta.first_prompt or "" - title = _trim_title(title_raw) if title_raw else "(empty)" + lite_meta = read_lite_metadata(path) + path_session_id = session_id or path.stem + directory_metadata = read_session_metadata(path.parent) if path.name == SESSION_JSONL_FILENAME else None + if directory_metadata and directory_metadata.session_id != path_session_id: + directory_metadata = None + name = directory_metadata.name if directory_metadata else None + auto_title_raw = lite_meta.last_prompt or lite_meta.first_prompt + auto_title = _trim_title(auto_title_raw) if auto_title_raw else None + cwd = (directory_metadata.cwd if directory_metadata else None) or lite_meta.cwd or fallback_cwd + title = name or auto_title or "(empty)" return SessionEntry( - session_id=path.stem, + session_id=path_session_id, cwd=cwd, project_name=os.path.basename(cwd) if cwd else "?", - git_branch=meta.git_branch, + git_branch=(directory_metadata.git_branch if directory_metadata else None) or lite_meta.git_branch, title=title, mtime=stat.st_mtime, size_bytes=stat.st_size, + name=name, + auto_title=auto_title, + is_legacy=path.name != SESSION_JSONL_FILENAME, ) @@ -238,8 +269,8 @@ def list_for_cwd(self, cwd: str) -> list[SessionEntry]: if not project_dir.exists(): return [] entries: list[SessionEntry] = [] - for jsonl in project_dir.glob("*.jsonl"): - entry = _build_entry(jsonl, fallback_cwd=cwd) + for jsonl, session_id in _iter_session_files(project_dir): + entry = _build_entry(jsonl, fallback_cwd=cwd, session_id=session_id) if entry is not None: entries.append(entry) entries.sort(key=lambda e: e.mtime, reverse=True) @@ -253,8 +284,8 @@ def list_all_projects(self) -> list[SessionEntry]: for proj_dir in self._projects_dir.iterdir(): if not proj_dir.is_dir(): continue - for jsonl in proj_dir.glob("*.jsonl"): - entry = _build_entry(jsonl, fallback_cwd="") + for jsonl, session_id in _iter_session_files(proj_dir): + entry = _build_entry(jsonl, fallback_cwd="", session_id=session_id) if entry is not None: entries.append(entry) entries.sort(key=lambda e: e.mtime, reverse=True) @@ -264,18 +295,11 @@ def find_by_id_or_prefix(self, arg: str) -> SessionEntry | None: """Locate a single entry by exact session id or unique id prefix.""" if not self._projects_dir.exists() or not arg: return None - matches: list[SessionEntry] = [] - for proj_dir in self._projects_dir.iterdir(): - if not proj_dir.is_dir(): - continue - for jsonl in proj_dir.glob("*.jsonl"): - sid = jsonl.stem - if sid == arg: - return _build_entry(jsonl, fallback_cwd="") - if sid.startswith(arg): - entry = _build_entry(jsonl, fallback_cwd="") - if entry is not None: - matches.append(entry) + entries = self.list_all_projects() + for entry in entries: + if entry.session_id == arg: + return entry + matches = [entry for entry in entries if entry.session_id.startswith(arg)] if len(matches) == 1: return matches[0] return None diff --git a/src/iac_code/services/session_metadata.py b/src/iac_code/services/session_metadata.py new file mode 100644 index 0000000..fb034f0 --- /dev/null +++ b/src/iac_code/services/session_metadata.py @@ -0,0 +1,78 @@ +"""Session metadata primitives.""" + +from __future__ import annotations + +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from iac_code.i18n import _ +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file + +SESSION_JSONL_FILENAME = "session.jsonl" +SESSION_METADATA_FILENAME = "metadata.json" +SESSION_NAME_PATTERN_TEXT = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$" +SESSION_NAME_PATTERN = re.compile(SESSION_NAME_PATTERN_TEXT) +SESSION_METADATA_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class SessionMetadata: + session_id: str + name: str | None = None + cwd: str | None = None + git_branch: str | None = None + created_at: str | None = None + updated_at: str | None = None + schema_version: int = SESSION_METADATA_SCHEMA_VERSION + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SessionMetadata | None: + session_id = data.get("session_id") + if not isinstance(session_id, str) or not session_id: + return None + + name = data.get("name") + schema_version = data.get("schema_version") + return cls( + session_id=session_id, + name=name if isinstance(name, str) and name else None, + cwd=_string_or_none(data.get("cwd")), + git_branch=_string_or_none(data.get("git_branch")), + created_at=_string_or_none(data.get("created_at")), + updated_at=_string_or_none(data.get("updated_at")), + schema_version=schema_version if type(schema_version) is int else SESSION_METADATA_SCHEMA_VERSION, + ) + + +def validate_session_name(name: str) -> str: + if not SESSION_NAME_PATTERN.fullmatch(name): + raise ValueError(_("Session name must match {pattern}").format(pattern=SESSION_NAME_PATTERN_TEXT)) + return name + + +def normalize_session_name(name: str) -> str: + return validate_session_name(name.strip()) + + +def read_session_metadata(session_dir: Path) -> SessionMetadata | None: + try: + data = json.loads((session_dir / SESSION_METADATA_FILENAME).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + return SessionMetadata.from_dict(data) + + +def write_session_metadata(session_dir: Path, metadata: SessionMetadata) -> None: + ensure_private_dir(session_dir) + path = session_dir / SESSION_METADATA_FILENAME + path.write_text(json.dumps(asdict(metadata), ensure_ascii=False) + "\n", encoding="utf-8") + ensure_private_file(path) + + +def _string_or_none(value: object) -> str | None: + return value if isinstance(value, str) else None diff --git a/src/iac_code/services/session_resolver.py b/src/iac_code/services/session_resolver.py new file mode 100644 index 0000000..ab20871 --- /dev/null +++ b/src/iac_code/services/session_resolver.py @@ -0,0 +1,73 @@ +"""Shared session argument resolver.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + +from iac_code.services.session_index import SessionEntry, SessionIndex + + +class ResolutionStatus(str, Enum): + FOUND = "found" + NOT_FOUND = "not_found" + AMBIGUOUS_NAME = "ambiguous_name" + + +@dataclass(frozen=True) +class SessionResolution: + status: ResolutionStatus + entry: SessionEntry | None = None + candidates: list[SessionEntry] = field(default_factory=list) + + +def resolve_session_argument(index: SessionIndex, current_cwd: str, arg: str) -> SessionResolution: + needle = arg.strip() + if not needle: + return SessionResolution(status=ResolutionStatus.NOT_FOUND) + + current_entries = index.list_for_cwd(current_cwd) + entry = _exact_id(current_entries, needle) + if entry is not None: + return SessionResolution(status=ResolutionStatus.FOUND, entry=entry) + + current_prefix_matches = _id_prefix_matches(current_entries, needle) + if len(current_prefix_matches) == 1: + return SessionResolution(status=ResolutionStatus.FOUND, entry=current_prefix_matches[0]) + if len(current_prefix_matches) > 1: + return SessionResolution(status=ResolutionStatus.NOT_FOUND) + + entry = _exact_name(current_entries, needle) + if entry is not None: + return SessionResolution(status=ResolutionStatus.FOUND, entry=entry) + + all_entries = index.list_all_projects() + entry = _exact_id(all_entries, needle) + if entry is not None: + return SessionResolution(status=ResolutionStatus.FOUND, entry=entry) + + global_prefix_matches = _id_prefix_matches(all_entries, needle) + if len(global_prefix_matches) == 1: + return SessionResolution(status=ResolutionStatus.FOUND, entry=global_prefix_matches[0]) + if len(global_prefix_matches) > 1: + return SessionResolution(status=ResolutionStatus.NOT_FOUND) + + name_matches = [entry for entry in all_entries if entry.name == needle] + if len(name_matches) == 1: + return SessionResolution(status=ResolutionStatus.FOUND, entry=name_matches[0]) + if len(name_matches) > 1: + return SessionResolution(status=ResolutionStatus.AMBIGUOUS_NAME, candidates=name_matches) + + return SessionResolution(status=ResolutionStatus.NOT_FOUND) + + +def _exact_id(entries: list[SessionEntry], arg: str) -> SessionEntry | None: + return next((entry for entry in entries if entry.session_id == arg), None) + + +def _id_prefix_matches(entries: list[SessionEntry], arg: str) -> list[SessionEntry]: + return [entry for entry in entries if entry.session_id.startswith(arg)] + + +def _exact_name(entries: list[SessionEntry], arg: str) -> SessionEntry | None: + return next((entry for entry in entries if entry.name == arg), None) diff --git a/src/iac_code/services/session_storage.py b/src/iac_code/services/session_storage.py index 380525f..1dc1662 100644 --- a/src/iac_code/services/session_storage.py +++ b/src/iac_code/services/session_storage.py @@ -2,9 +2,13 @@ Layout:: - ~/.iac-code/projects//.jsonl + ~/.iac-code/projects///session.jsonl + ~/.iac-code/projects///metadata.json -Each session file is a stream of two kinds of JSONL lines: +Legacy sessions at ``.jsonl`` remain readable and are +migrated to the directory format when renamed. + +Each ``session.jsonl`` file is a stream of two kinds of JSONL lines: * **Message rows** — one per :class:`Message`, with extra stamp fields (``session_id``, ``cwd``, ``git_branch``, ``version``) appended at write @@ -19,19 +23,34 @@ from __future__ import annotations import json +from datetime import datetime, timezone from pathlib import Path +from shutil import move from typing import Any from iac_code import __version__ from iac_code.agent.message import ContentBlock, Message, ToolResultBlock +from iac_code.i18n import _ +from iac_code.services.session_metadata import ( + SESSION_JSONL_FILENAME, + SessionMetadata, + normalize_session_name, + read_session_metadata, + write_session_metadata, +) from iac_code.utils.file_security import ensure_private_dir, ensure_private_file from iac_code.utils.project_paths import ( get_project_dir, get_projects_dir, get_session_path, + is_conversation_session_file, ) +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + class SessionStorage: """Persist conversation sessions partitioned by working directory.""" @@ -42,7 +61,7 @@ def __init__(self, projects_dir: Path | str | None = None) -> None: # Internal path helpers # ------------------------------------------------------------------ - def _session_path(self, cwd: str, session_id: str) -> Path: + def _legacy_session_path(self, cwd: str, session_id: str) -> Path: if self._projects_dir == get_projects_dir(): return get_session_path(cwd, session_id) from iac_code.utils.project_paths import sanitize_path @@ -56,10 +75,34 @@ def _project_dir_for(self, cwd: str) -> Path: return self._projects_dir / sanitize_path(cwd) + def _session_dir(self, cwd: str, session_id: str) -> Path: + return self._project_dir_for(cwd) / session_id + + def _directory_session_path(self, cwd: str, session_id: str) -> Path: + return self._session_dir(cwd, session_id) / SESSION_JSONL_FILENAME + + def _session_path(self, cwd: str, session_id: str) -> Path: + directory_path = self._directory_session_path(cwd, session_id) + legacy_path = self._legacy_session_path(cwd, session_id) + if directory_path.exists(): + return directory_path + if legacy_path.exists(): + return legacy_path + return directory_path + def session_path(self, cwd: str, session_id: str) -> Path: """Public accessor for the on-disk JSONL path of a session.""" return self._session_path(cwd, session_id) + def legacy_session_path(self, cwd: str, session_id: str) -> Path: + return self._legacy_session_path(cwd, session_id) + + def session_dir(self, cwd: str, session_id: str) -> Path: + return self._session_dir(cwd, session_id) + + def read_metadata(self, cwd: str, session_id: str) -> SessionMetadata | None: + return read_session_metadata(self._session_dir(cwd, session_id)) + # ------------------------------------------------------------------ # Stamp helpers # ------------------------------------------------------------------ @@ -155,6 +198,60 @@ def load(self, cwd: str, session_id: str) -> list[Message]: def exists(self, cwd: str, session_id: str) -> bool: return self._session_path(cwd, session_id).exists() + # ------------------------------------------------------------------ + # Rename / migration + # ------------------------------------------------------------------ + + def _iter_project_session_dirs(self, cwd: str) -> list[Path]: + project_dir = self._project_dir_for(cwd) + if not project_dir.exists(): + return [] + return [p for p in project_dir.iterdir() if p.is_dir() and (p / SESSION_JSONL_FILENAME).exists()] + + def _name_owner_in_project(self, cwd: str, name: str) -> str | None: + for session_dir in self._iter_project_session_dirs(cwd): + metadata = read_session_metadata(session_dir) + if metadata and metadata.name == name: + return metadata.session_id + return None + + def _ensure_directory_format(self, cwd: str, session_id: str) -> Path: + session_dir = self._session_dir(cwd, session_id) + directory_path = session_dir / SESSION_JSONL_FILENAME + if directory_path.exists(): + return session_dir + legacy_path = self._legacy_session_path(cwd, session_id) + if not legacy_path.exists(): + ensure_private_dir(session_dir) + directory_path.touch() + ensure_private_file(directory_path) + return session_dir + ensure_private_dir(session_dir) + move(str(legacy_path), str(directory_path)) + ensure_private_file(directory_path) + return session_dir + + def rename_session(self, cwd: str, session_id: str, name: str, *, git_branch: str | None = None) -> str: + normalized = normalize_session_name(name) + current = self.read_metadata(cwd, session_id) + if current and current.name == normalized: + return "unchanged" + owner = self._name_owner_in_project(cwd, normalized) + if owner is not None and owner != session_id: + raise ValueError(_("Session name already exists in this project: {name}").format(name=normalized)) + session_dir = self._ensure_directory_format(cwd, session_id) + now = _utc_now() + metadata = SessionMetadata( + session_id=session_id, + name=normalized, + cwd=cwd, + git_branch=git_branch, + created_at=current.created_at if current else now, + updated_at=now, + ) + write_session_metadata(session_dir, metadata) + return "renamed" + # ------------------------------------------------------------------ # Cross-project lookups (used by CLI --resume / --continue) # ------------------------------------------------------------------ @@ -171,10 +268,14 @@ def find_session_anywhere(self, session_id: str) -> tuple[str, Path] | None: for proj_dir in self._projects_dir.iterdir(): if not proj_dir.is_dir(): continue - candidate = proj_dir / f"{session_id}.jsonl" + candidate = proj_dir / session_id / SESSION_JSONL_FILENAME if candidate.exists(): cwd = self._read_cwd_from_file(candidate) or "" return cwd, candidate + candidate = proj_dir / f"{session_id}.jsonl" + if candidate.exists() and is_conversation_session_file(candidate): + cwd = self._read_cwd_from_file(candidate) or "" + return cwd, candidate return None def get_latest_session_anywhere(self) -> tuple[str, str] | None: @@ -185,7 +286,17 @@ def get_latest_session_anywhere(self) -> tuple[str, str] | None: for proj_dir in self._projects_dir.iterdir(): if not proj_dir.is_dir(): continue + for session_dir in proj_dir.iterdir(): + if not session_dir.is_dir(): + continue + jsonl = session_dir / SESSION_JSONL_FILENAME + if jsonl.exists(): + mtime = jsonl.stat().st_mtime + if latest is None or mtime > latest[0]: + latest = (mtime, jsonl) for jsonl in proj_dir.glob("*.jsonl"): + if not is_conversation_session_file(jsonl): + continue mtime = jsonl.stat().st_mtime if latest is None or mtime > latest[0]: latest = (mtime, jsonl) @@ -193,7 +304,7 @@ def get_latest_session_anywhere(self) -> tuple[str, str] | None: return None path = latest[1] cwd = self._read_cwd_from_file(path) or "" - session_id = path.stem + session_id = path.parent.name if path.name == SESSION_JSONL_FILENAME else path.stem return cwd, session_id @staticmethod diff --git a/src/iac_code/services/session_usage.py b/src/iac_code/services/session_usage.py new file mode 100644 index 0000000..be98982 --- /dev/null +++ b/src/iac_code/services/session_usage.py @@ -0,0 +1,176 @@ +"""Session-level provider API usage persistence.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from loguru import logger + +from iac_code.types.stream_events import Usage +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.project_paths import get_project_dir, get_projects_dir, sanitize_path + +USAGE_JSONL_FILENAME = "usage.jsonl" + + +@dataclass +class SessionUsageTotals: + """Cumulative provider-reported token usage for one session.""" + + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_input_tokens: int = 0 + cache_creation_input_tokens: int = 0 + recorded_events: int = 0 + + @property + def total_tokens(self) -> int: + return self.input_tokens + self.output_tokens + + @property + def has_recorded_usage(self) -> bool: + return self.recorded_events > 0 + + def add(self, usage: Usage) -> bool: + """Add a non-zero usage event and return whether it was recorded.""" + if _usage_is_zero(usage): + return False + self.input_tokens += int(usage.input_tokens or 0) + self.output_tokens += int(usage.output_tokens or 0) + self.cache_read_input_tokens += int(usage.cache_read_input_tokens or 0) + self.cache_creation_input_tokens += int(usage.cache_creation_input_tokens or 0) + self.recorded_events += 1 + return True + + def copy(self) -> SessionUsageTotals: + return SessionUsageTotals( + input_tokens=self.input_tokens, + output_tokens=self.output_tokens, + cache_read_input_tokens=self.cache_read_input_tokens, + cache_creation_input_tokens=self.cache_creation_input_tokens, + recorded_events=self.recorded_events, + ) + + +class SessionUsageStore: + """Persist cumulative API usage as a sidecar JSONL file.""" + + def __init__(self, projects_dir: Path | str | None = None) -> None: + self._projects_dir = Path(projects_dir) if projects_dir is not None else get_projects_dir() + + def path_for(self, cwd: str, session_id: str) -> Path: + return self._project_dir_for(cwd) / session_id / USAGE_JSONL_FILENAME + + def legacy_path_for(self, cwd: str, session_id: str) -> Path: + return self._project_dir_for(cwd) / f"{session_id}.usage.jsonl" + + def append( + self, + cwd: str, + session_id: str, + usage: Usage, + *, + provider: str | None = None, + model: str | None = None, + created_at: datetime | None = None, + ) -> bool: + """Append a non-zero provider usage event.""" + if _usage_is_zero(usage): + return False + + path = self.path_for(cwd, session_id) + ensure_private_dir(path.parent) + row = _usage_to_row(usage, provider=provider, model=model, created_at=created_at) + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + ensure_private_file(path) + return True + + def load(self, cwd: str, session_id: str) -> SessionUsageTotals: + """Load cumulative usage totals, skipping corrupt or unrelated rows.""" + totals = SessionUsageTotals() + for path in (self.path_for(cwd, session_id), self.legacy_path_for(cwd, session_id)): + self._load_path(path, totals) + return totals + + def _load_path(self, path: Path, totals: SessionUsageTotals) -> None: + if not path.exists(): + return + + try: + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + logger.debug("Skipping corrupt usage row in {}", path) + continue + if not isinstance(row, dict) or row.get("type") != "usage": + continue + totals.add(_row_to_usage(row)) + except OSError as exc: + logger.debug("Failed to load usage sidecar {}: {}", path, exc) + + def _project_dir_for(self, cwd: str) -> Path: + if self._projects_dir == get_projects_dir(): + return get_project_dir(cwd) + return self._projects_dir / sanitize_path(cwd) + + +def _usage_is_zero(usage: Usage) -> bool: + return ( + int(usage.input_tokens or 0) == 0 + and int(usage.output_tokens or 0) == 0 + and int(usage.cache_read_input_tokens or 0) == 0 + and int(usage.cache_creation_input_tokens or 0) == 0 + ) + + +def _usage_to_row( + usage: Usage, + *, + provider: str | None, + model: str | None, + created_at: datetime | None, +) -> dict[str, Any]: + timestamp = created_at or datetime.now(timezone.utc) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + timestamp = timestamp.astimezone(timezone.utc) + return { + "type": "usage", + "version": 1, + "created_at": timestamp.isoformat().replace("+00:00", "Z"), + "provider": provider, + "model": model, + "input_tokens": int(usage.input_tokens or 0), + "output_tokens": int(usage.output_tokens or 0), + "cache_read_input_tokens": int(usage.cache_read_input_tokens or 0), + "cache_creation_input_tokens": int(usage.cache_creation_input_tokens or 0), + } + + +def _row_to_usage(row: dict[str, Any]) -> Usage: + return Usage( + input_tokens=_int(row.get("input_tokens")), + output_tokens=_int(row.get("output_tokens")), + cache_read_input_tokens=_int(row.get("cache_read_input_tokens")), + cache_creation_input_tokens=_int(row.get("cache_creation_input_tokens")), + ) + + +def _int(value: Any) -> int: + if isinstance(value, bool): + return 0 + try: + number = int(value) + except (TypeError, ValueError): + return 0 + return max(number, 0) diff --git a/src/iac_code/skills/management.py b/src/iac_code/skills/management.py new file mode 100644 index 0000000..a1aa72a --- /dev/null +++ b/src/iac_code/skills/management.py @@ -0,0 +1,81 @@ +"""Build enabled/disabled skill state for REPL and management UI.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from iac_code.commands.registry import PromptCommand +from iac_code.skills.discovery import skill_to_command +from iac_code.skills.settings import normalize_skill_name +from iac_code.skills.skill_definition import SkillDefinition +from iac_code.types.skill_source import SkillSource + + +@dataclass(frozen=True) +class SkillManagementItem: + """User-facing skill state for the `/skills` picker.""" + + name: str + description: str + source: SkillSource + content_length: int + path: str + enabled: bool + locked: bool + + +@dataclass(frozen=True) +class SkillManagementState: + """Enabled commands plus disabled metadata for runtime lookups.""" + + items: list[SkillManagementItem] + enabled_commands: list[PromptCommand] + disabled_commands: dict[str, PromptCommand] + locked_skill_names: set[str] + + +def build_skill_management_state( + skills: list[SkillDefinition], + disabled_skill_names: set[str], +) -> SkillManagementState: + """Apply disabled settings to discovered skills. + + Bundled skills are locked on and ignore disabled settings. + """ + disabled = {normalize_skill_name(name) for name in disabled_skill_names} + items: list[SkillManagementItem] = [] + enabled_commands: list[PromptCommand] = [] + disabled_commands: dict[str, PromptCommand] = {} + locked_skill_names: set[str] = set() + + for skill in sorted(skills, key=lambda item: item.name): + name = normalize_skill_name(skill.name) + locked = skill.source == SkillSource.BUNDLED + enabled = locked or name not in disabled + command = skill_to_command(skill) + + if locked: + locked_skill_names.add(name) + if enabled: + enabled_commands.append(command) + else: + disabled_commands[name] = command + + items.append( + SkillManagementItem( + name=skill.name, + description=skill.description, + source=skill.source, + content_length=skill.content_length, + path=skill.skill_root, + enabled=enabled, + locked=locked, + ) + ) + + return SkillManagementState( + items=items, + enabled_commands=enabled_commands, + disabled_commands=disabled_commands, + locked_skill_names=locked_skill_names, + ) diff --git a/src/iac_code/skills/settings.py b/src/iac_code/skills/settings.py new file mode 100644 index 0000000..49ca928 --- /dev/null +++ b/src/iac_code/skills/settings.py @@ -0,0 +1,61 @@ +"""Persistence helpers for skill enable/disable settings.""" + +from __future__ import annotations + +from typing import Any + +import yaml + +from iac_code.config import get_settings_path +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file + + +def normalize_skill_name(name: str) -> str: + """Normalize skill names for settings and command lookup.""" + return name.lstrip("/$").strip().lower() + + +def _load_settings() -> dict[str, Any]: + path = get_settings_path() + if not path.exists(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def load_disabled_skills() -> set[str]: + """Return normalized disabled skill names from settings.yml.""" + raw = _load_settings().get("disabled_skills") + if not isinstance(raw, list): + return set() + + disabled: set[str] = set() + for item in raw: + if not isinstance(item, str): + continue + name = normalize_skill_name(item) + if name: + disabled.add(name) + return disabled + + +def save_disabled_skills(disabled: set[str], *, locked_skill_names: set[str] | None = None) -> None: + """Persist normalized disabled skill names, preserving unrelated settings.""" + locked = {normalize_skill_name(name) for name in (locked_skill_names or set())} + normalized = sorted( + name for name in {normalize_skill_name(item) for item in disabled} if name and name not in locked + ) + + path = get_settings_path() + data = _load_settings() + if normalized: + data["disabled_skills"] = normalized + else: + data.pop("disabled_skills", None) + + ensure_private_dir(path.parent) + path.write_text(yaml.safe_dump(data, default_flow_style=False, allow_unicode=True), encoding="utf-8") + ensure_private_file(path) diff --git a/src/iac_code/skills/skill_tool.py b/src/iac_code/skills/skill_tool.py index 12a5284..2e1042e 100644 --- a/src/iac_code/skills/skill_tool.py +++ b/src/iac_code/skills/skill_tool.py @@ -32,6 +32,7 @@ def __init__( provider_manager: Any = None, tool_registry: Any = None, system_prompt: str = "", + disabled_skills: dict[str, Any] | None = None, ) -> None: self._command_registry = command_registry self._session_id = session_id @@ -39,6 +40,9 @@ def __init__( self._provider_manager = provider_manager self._tool_registry = tool_registry self._system_prompt = system_prompt + self._disabled_skills = { + self._normalize_name(name): command for name, command in (disabled_skills or {}).items() + } @property def name(self) -> str: @@ -77,6 +81,9 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> from iac_code.commands.registry import PromptCommand + if skill_name in self._disabled_skills: + return ToolResult.error(_("Skill '{name}' is disabled. Run /skills to enable it.").format(name=skill_name)) + command = self._command_registry.get(skill_name) if not isinstance(command, PromptCommand): return ToolResult.error(f"Skill not found: '{skill_name}'") @@ -232,6 +239,12 @@ async def check_permissions(self, input: dict, context: dict | None = None) -> A from iac_code.types.permissions import PermissionResult skill_name = self._normalize_name(input.get("skill", "")) + if skill_name in self._disabled_skills: + return PermissionResult( + behavior="deny", + message=_("Skill disabled: {name}").format(name=skill_name), + ) + command = self._command_registry.get(skill_name) if not isinstance(command, PromptCommand): return PermissionResult(behavior="deny", message=f"Skill not found: {skill_name}") diff --git a/src/iac_code/tools/base.py b/src/iac_code/tools/base.py index 3f912da..b6e7b17 100644 --- a/src/iac_code/tools/base.py +++ b/src/iac_code/tools/base.py @@ -209,6 +209,10 @@ def register(self, tool: Tool) -> None: """Register a tool.""" self._tools[tool.name] = tool + def unregister(self, name: str) -> None: + """Unregister a tool if it exists.""" + self._tools.pop(name, None) + def get(self, name: str) -> Tool | None: """Get a tool by name.""" return self._tools.get(name) diff --git a/src/iac_code/tools/cloud/aliyun/aliyun_api.py b/src/iac_code/tools/cloud/aliyun/aliyun_api.py index 2646661..1ae7b0d 100644 --- a/src/iac_code/tools/cloud/aliyun/aliyun_api.py +++ b/src/iac_code/tools/cloud/aliyun/aliyun_api.py @@ -15,7 +15,8 @@ from iac_code.i18n import _ from iac_code.services.cloud_credentials import CloudCredentials -from iac_code.services.providers.aliyun import AliyunCredential +from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials +from iac_code.services.providers.aliyun_oauth import AliyunOAuthError from iac_code.services.telemetry import add_metric, log_event from iac_code.services.telemetry.names import Events, Metrics from iac_code.services.telemetry.sanitize import sanitize_error_message @@ -334,7 +335,7 @@ def _build_config(credential: AliyunCredential, endpoint: str, region_id: str) - mode = credential.mode user_agent = build_user_agent() - if mode == "StsToken": + if mode in {"StsToken", "OAuth"}: return open_api_models.Config( access_key_id=credential.access_key_id, access_key_secret=credential.access_key_secret, @@ -431,6 +432,12 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> "Run 'iac-code auth' and select 'Cloud Provider' to configure." ) + if credential.mode == "OAuth": + try: + credential = AliyunCredentials.refresh_oauth_if_needed(credential) + except AliyunOAuthError as exc: + return ToolResult.error(str(exc)) + endpoint = ( self._get_endpoint(product, region) or self._discover_endpoint(product, region, credential) diff --git a/src/iac_code/tools/cloud/aliyun/ros_client.py b/src/iac_code/tools/cloud/aliyun/ros_client.py index 787a38d..3a7c389 100644 --- a/src/iac_code/tools/cloud/aliyun/ros_client.py +++ b/src/iac_code/tools/cloud/aliyun/ros_client.py @@ -1,7 +1,8 @@ from alibabacloud_ros20190910.client import Client as RosClient from alibabacloud_tea_openapi import models as open_api_models -from iac_code.services.providers.aliyun import AliyunCredential +from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials +from iac_code.services.providers.aliyun_oauth import AliyunOAuthError from iac_code.tools.cloud.aliyun.user_agent import build_user_agent @@ -14,6 +15,12 @@ def create(credential: AliyunCredential | None, region_id: str = "") -> RosClien "Run 'iac-code auth' and select 'Cloud Provider' to configure." ) + if credential.mode == "OAuth": + try: + credential = AliyunCredentials.refresh_oauth_if_needed(credential) + except AliyunOAuthError as exc: + raise ValueError(str(exc)) from exc + effective_region = region_id or credential.region_id if not effective_region: raise ValueError("Region not configured. Run 'iac-code auth' and configure the region for Alibaba Cloud.") @@ -25,7 +32,7 @@ def _build_config(credential: AliyunCredential, region_id: str) -> open_api_mode mode = credential.mode user_agent = build_user_agent() - if mode == "StsToken": + if mode in {"StsToken", "OAuth"}: return open_api_models.Config( access_key_id=credential.access_key_id, access_key_secret=credential.access_key_secret, diff --git a/src/iac_code/tools/cloud/aliyun/ros_stack_instances.py b/src/iac_code/tools/cloud/aliyun/ros_stack_instances.py index 9730b3a..0ca95e5 100644 --- a/src/iac_code/tools/cloud/aliyun/ros_stack_instances.py +++ b/src/iac_code/tools/cloud/aliyun/ros_stack_instances.py @@ -195,12 +195,13 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> await asyncio.sleep(self.__class__.poll_interval) try: - status = await self._get_operation_status(client, operation_id, region) + poll_client = self._get_client(region) + status = await self._get_operation_status(poll_client, operation_id, region) except Exception as e: return ToolResult.error(f"[GetStackGroupOperation] {e}") try: - instances = await self._get_instances(client, stack_group_name, region) + instances = await self._get_instances(poll_client, stack_group_name, region) except Exception as e: return ToolResult.error(f"[ListStackInstances] {e}") diff --git a/src/iac_code/tools/cloud/registry.py b/src/iac_code/tools/cloud/registry.py index c062881..0b38a76 100644 --- a/src/iac_code/tools/cloud/registry.py +++ b/src/iac_code/tools/cloud/registry.py @@ -6,8 +6,18 @@ from iac_code.services.cloud_credentials import CloudCredentials from iac_code.tools.base import ToolRegistry +ALIYUN_TOOL_NAMES = ( + "aliyun_api", + "aliyun_doc_search", + "ros_stack", + "ros_stack_instances", +) + def register_cloud_tools(registry: "ToolRegistry", credentials: "CloudCredentials") -> None: + for tool_name in ALIYUN_TOOL_NAMES: + registry.unregister(tool_name) + if credentials.has_provider("aliyun"): from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi from iac_code.tools.cloud.aliyun.aliyun_doc_search import AliyunDocSearch diff --git a/src/iac_code/ui/banner.py b/src/iac_code/ui/banner.py index 91c83f4..31b745b 100644 --- a/src/iac_code/ui/banner.py +++ b/src/iac_code/ui/banner.py @@ -85,7 +85,12 @@ def _get_provider_display() -> str: return "" -def render_welcome_banner(model: str, cwd: str, session_id: str | None = None) -> Panel: +def render_welcome_banner( + model: str, + cwd: str, + session_id: str | None = None, + session_name: str | None = None, +) -> Panel: """Produce a Rich Panel for the welcome banner.""" # Username try: @@ -126,6 +131,14 @@ def render_welcome_banner(model: str, cwd: str, session_id: str | None = None) - from iac_code import __version__ + session_display: Text + if session_name and session_id: + session_display = Text(" {}: {} ({})".format(_("Session"), session_name, session_id), style="dim") + elif session_id: + session_display = Text(" {}: {}".format(_("Session"), session_id), style="dim") + else: + session_display = Text() + items = [ Text(), Text(" {} {}!".format(_("Welcome back"), username), style="bold"), @@ -135,7 +148,7 @@ def render_welcome_banner(model: str, cwd: str, session_id: str | None = None) - Text(f" iac-code v{__version__}", style="dim"), Text(f" {model_display}", style="dim") if model_display else Text(), Text(f" {cwd_display}", style="dim"), - Text(" {}: {}".format(_("Session"), session_id), style="dim") if session_id else Text(), + session_display, ] from iac_code.utils.log import is_debug_enabled diff --git a/src/iac_code/ui/dialogs/resume_picker.py b/src/iac_code/ui/dialogs/resume_picker.py index 19b0216..9f7dc62 100644 --- a/src/iac_code/ui/dialogs/resume_picker.py +++ b/src/iac_code/ui/dialogs/resume_picker.py @@ -64,11 +64,13 @@ def __init__( current_session_id: str | None, keybinding_manager: object | None = None, renderer: "Renderer | None" = None, + entries: list[SessionEntry] | None = None, ) -> None: self._index = index self._current_cwd = current_cwd self._current_session_id = current_session_id self._km = keybinding_manager + self._entries_override = entries # Live REPL renderer — reused inside the preview so the dump # uses the same tool-name translation, argument formatting, and # result-summary helpers as the live UI. @@ -285,7 +287,9 @@ def _toggle_only_current_branch(self) -> None: self._apply_filter() def _reload_entries(self) -> None: - if self._show_all_projects: + if self._entries_override is not None: + entries = list(self._entries_override) + elif self._show_all_projects: entries = self._index.list_all_projects() else: entries = self._index.list_for_cwd(self._current_cwd) @@ -304,7 +308,18 @@ def _apply_filter(self) -> None: else: scored: list[tuple[float, SessionEntry]] = [] for entry in candidates: - haystack = " ".join(part for part in (entry.title, entry.project_name, entry.git_branch or "") if part) + haystack = " ".join( + part + for part in ( + entry.name, + entry.session_id, + entry.title, + entry.auto_title, + entry.project_name, + entry.git_branch or "", + ) + if part + ) if entry.session_id.startswith(query): scored.append((1_000_000.0, entry)) continue @@ -415,6 +430,8 @@ def _render_title_line(entry: SessionEntry, is_focused: bool) -> Text: def _render_subtitle_line(entry: SessionEntry) -> Text: text = Text(" ", style="dim") parts = [_format_relative_time(entry.mtime)] + if entry.name: + parts.append(_short_session_id(entry.session_id)) if entry.git_branch: parts.append(entry.git_branch) parts.append(_format_size(entry.size_bytes)) @@ -747,3 +764,9 @@ def _format_size(size_bytes: int) -> str: return f"{mb:.1f}MB" gb = mb / 1024 return f"{gb:.1f}GB" + + +def _short_session_id(session_id: str) -> str: + if len(session_id) <= 8: + return session_id + return session_id[:8] diff --git a/src/iac_code/ui/dialogs/skills_picker.py b/src/iac_code/ui/dialogs/skills_picker.py new file mode 100644 index 0000000..70f0551 --- /dev/null +++ b/src/iac_code/ui/dialogs/skills_picker.py @@ -0,0 +1,297 @@ +"""Interactive picker for managing skills.""" + +from __future__ import annotations + +from math import ceil +from typing import Literal + +from rich.cells import cell_len +from rich.console import Console, Group, RenderableType +from rich.text import Text + +from iac_code.i18n import _ +from iac_code.skills.management import SkillManagementItem +from iac_code.skills.settings import normalize_skill_name +from iac_code.types.skill_source import SkillSource +from iac_code.ui.components.fuzzy_picker import fuzzy_match +from iac_code.ui.components.search_box import SearchBox +from iac_code.ui.core.key_event import KeyEvent + +SortMode = Literal["name", "source", "size"] +_SORT_MODES: tuple[SortMode, ...] = ("name", "source", "size") +_SOURCE_ORDER = { + SkillSource.BUNDLED: 0, + SkillSource.PROJECT: 1, + SkillSource.USER: 2, +} + + +class SkillsPicker: + """Interactive skill enable/disable picker.""" + + def __init__( + self, + items: list[SkillManagementItem], + keybinding_manager: object | None = None, + visible_count: int = 10, + ) -> None: + self._all_items = list(items) + self._km = keybinding_manager + self._visible_count = visible_count + self._sort_mode: SortMode = "name" + self._disabled: set[str] = { + normalize_skill_name(item.name) for item in items if not item.enabled and not item.locked + } + self._filtered: list[SkillManagementItem] = [] + self._focused_index = 0 + self._visible_from = 0 + self._done = False + self._result: set[str] | None = None + self._status_message = "" + self._description_matched_names: set[str] = set() + self._search_box = SearchBox(placeholder=_("Search skills..."), on_change=self._on_query_change) + self._apply_filter() + + @property + def disabled_skill_names(self) -> set[str]: + return set(self._disabled) + + @property + def filtered_items(self) -> list[SkillManagementItem]: + return list(self._filtered) + + @property + def result(self) -> set[str] | None: + return None if self._result is None else set(self._result) + + @property + def done(self) -> bool: + return self._done + + @property + def status_message(self) -> str: + return self._status_message + + @property + def sort_mode(self) -> SortMode: + return self._sort_mode + + def run(self) -> set[str] | None: + """Run the blocking terminal picker.""" + from iac_code.ui.core.in_place_render import InPlaceRenderer + from iac_code.ui.core.raw_input import RawInputCapture + + console = Console() + renderer = InPlaceRenderer(console) + self._done = False + self._result = None + + def cursor_pos() -> tuple[int, int]: + sb = self._search_box + col = 2 if not sb.value else 2 + cell_len(sb.value[: sb.cursor]) + return (3, col) + + try: + with RawInputCapture() as cap: + while not self._done: + renderer.render(self.render(), cursor_to=cursor_pos()) + key_event = cap.read_key(timeout=0.1) + if key_event is not None: + self.handle_key(key_event) + except OSError: + return None + finally: + renderer.clear() + + return self.result + + def handle_key(self, key_event: KeyEvent) -> bool: + key = key_event.key + ctrl = key_event.ctrl + + if ctrl and key == "c": + self._done = True + self._result = None + return True + + if key == "escape": + self._done = True + self._result = None + return True + + if key == "enter": + self._done = True + self._result = set(self._disabled) + return True + + if key == "up" or (ctrl and key == "p"): + self._move_focus(-1) + return True + if key == "down" or (ctrl and key == "n"): + self._move_focus(1) + return True + if key == "pageup": + self._move_focus(-self._visible_count) + return True + if key == "pagedown": + self._move_focus(self._visible_count) + return True + + if key == " ": + self._toggle_focused() + return True + + if key == "tab": + self._cycle_sort() + return True + + consumed = self._search_box.handle_key(key_event) + if consumed: + self._status_message = "" + return consumed + + def render(self) -> RenderableType: + parts: list[RenderableType] = [] + total = len(self._filtered) + focus_pos = (self._focused_index + 1) if total else 0 + + header = Text() + header.append(_("Skills"), style="bold cyan") + if total: + header.append(" (" + _("{current} of {total}").format(current=focus_pos, total=total) + ")", style="dim") + parts.append(header) + parts.append( + Text( + _("{count} skills - Space to toggle, Enter to save, Tab to sort, Esc to cancel").format( + count=len(self._all_items) + ), + style="dim", + ) + ) + parts.append(Text(_("Sort: {mode}").format(mode=_sort_mode_label(self._sort_mode)), style="dim")) + parts.append(self._search_box.render()) + parts.append(Text("")) + + if not self._filtered: + parts.append(Text(_("No skills found"), style="dim")) + else: + for item in self._filtered[self._visible_from : self._visible_from + self._visible_count]: + parts.append(self._render_item(item, item == self._filtered[self._focused_index])) + + parts.append(Text("")) + if self._status_message: + parts.append(Text(self._status_message, style="yellow")) + return Group(*parts) + + def _on_query_change(self, _query: str) -> None: + self._apply_filter() + + def _apply_filter(self, keep_focus_name: str | None = None) -> None: + query = self._search_box.value.strip() + candidates = list(self._all_items) + self._description_matched_names = set() + if query: + scored: list[tuple[float, SkillManagementItem]] = [] + for item in candidates: + haystack = f"{item.name} {item.description}" + score = fuzzy_match(query, haystack) + if score is not None: + scored.append((score, item)) + if fuzzy_match(query, item.name) is None: + self._description_matched_names.add(item.name) + scored.sort(key=lambda pair: pair[0], reverse=True) + candidates = [item for _, item in scored] + + self._filtered = self._sort_items(candidates) + if keep_focus_name is not None: + for index, item in enumerate(self._filtered): + if item.name == keep_focus_name: + self._focused_index = index + break + else: + self._focused_index = 0 + else: + self._focused_index = 0 + self._visible_from = 0 + + def _sort_items(self, items: list[SkillManagementItem]) -> list[SkillManagementItem]: + if self._sort_mode == "source": + return sorted(items, key=lambda item: (_SOURCE_ORDER.get(item.source, 99), item.name)) + if self._sort_mode == "size": + return sorted(items, key=lambda item: (item.content_length, item.name)) + return sorted(items, key=lambda item: item.name) + + def _cycle_sort(self) -> None: + current = _SORT_MODES.index(self._sort_mode) + self._sort_mode = _SORT_MODES[(current + 1) % len(_SORT_MODES)] + focused = self._filtered[self._focused_index].name if self._filtered else None + self._apply_filter(keep_focus_name=focused) + + def _move_focus(self, delta: int) -> None: + if not self._filtered: + return + self._focused_index = max(0, min(self._focused_index + delta, len(self._filtered) - 1)) + if self._focused_index < self._visible_from: + self._visible_from = self._focused_index + elif self._focused_index >= self._visible_from + self._visible_count: + self._visible_from = self._focused_index - self._visible_count + 1 + + def _toggle_focused(self) -> None: + if not self._filtered: + return + item = self._filtered[self._focused_index] + name = normalize_skill_name(item.name) + if item.locked: + self._status_message = _("Bundled skills cannot be disabled.") + return + if name in self._disabled: + self._disabled.remove(name) + else: + self._disabled.add(name) + self._status_message = "" + self._apply_filter(keep_focus_name=item.name) + + def _render_item(self, item: SkillManagementItem, is_focused: bool) -> Text: + text = Text() + text.append("> " if is_focused else " ", style="bold cyan" if is_focused else "") + enabled = item.locked or normalize_skill_name(item.name) not in self._disabled + state_marker = "- " if enabled else "x " + state_label = _("on") if enabled else _("off") + text.append("{}{} ".format(state_marker, state_label), style="green" if enabled else "red") + text.append(f" {item.name:<18}", style="bold" if is_focused else "") + + details = [_source_label(item.source)] + if item.locked: + details.append(_("locked")) + details.append(_format_token_estimate(item.content_length)) + if item.name in self._description_matched_names: + details.append(_("matched description")) + if item.source != SkillSource.BUNDLED and item.path: + details.append(item.path) + text.append(" - " + " - ".join(details), style="dim") + return text + + +def _sort_mode_label(mode: SortMode) -> str: + if mode == "source": + return _("source") + if mode == "size": + return _("size") + return _("name") + + +def _source_label(source: SkillSource) -> str: + if source == SkillSource.BUNDLED: + return _("bundled") + if source == SkillSource.PROJECT: + return _("project") + if source == SkillSource.USER: + return _("user") + return source.value + + +def _format_token_estimate(content_length: int) -> str: + tokens = max(1, ceil(content_length / 4)) + if tokens >= 1000: + return _("~{count}k tokens").format(count=f"{tokens / 1000:.1f}") + return _("~{count} tokens").format(count=tokens) diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index e1e4f79..08fd3d7 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -16,10 +16,12 @@ import os import re import signal +import subprocess import sys import time from dataclasses import dataclass from types import ModuleType +from typing import Any from loguru import logger from rich.console import Console @@ -27,12 +29,15 @@ from iac_code.agent.agent_loop import AgentLoop from iac_code.agent.system_prompt import build_system_prompt from iac_code.commands import create_default_registry -from iac_code.commands.registry import LocalCommand, PromptCommand -from iac_code.config import get_config_dir, get_history_path, load_credentials +from iac_code.commands.registry import CommandResult, LocalCommand, PromptCommand +from iac_code.config import get_active_provider_key, get_config_dir, get_history_path, load_credentials from iac_code.i18n import _ from iac_code.memory.memory_manager import MemoryManager from iac_code.providers.manager import ProviderManager +from iac_code.providers.registry import PROVIDER_REGISTRY from iac_code.services.session_index import SessionIndex +from iac_code.services.session_metadata import normalize_session_name +from iac_code.services.session_resolver import ResolutionStatus, resolve_session_argument from iac_code.services.session_storage import SessionStorage from iac_code.services.update_checker import ( PendingUpdate, @@ -41,6 +46,7 @@ start_background_update_check, suppress_version, ) +from iac_code.skills.settings import normalize_skill_name from iac_code.state import AppStateStore from iac_code.state.app_state import AppState from iac_code.tasks.notification_queue import NotificationQueue @@ -61,6 +67,7 @@ from iac_code.utils.background_housekeeping import start_background_housekeeping from iac_code.utils.image.clipboard import ClipboardImage, get_image_from_clipboard, try_read_image_from_path from iac_code.utils.image.format_detect import IMAGE_EXTENSION_REGEX +from iac_code.utils.project_paths import format_resume_command, same_project_path termios: ModuleType | None try: @@ -84,6 +91,14 @@ class CommandContext: repl: "InlineREPL" +def _normalize_command_result(result: object) -> tuple[str, bool, bool]: + if result is None: + return "", False, False + if isinstance(result, CommandResult): + return result.message, result.is_error, result.refresh_banner + return str(result), False, False + + class InlineREPL: """Inline terminal REPL integrating all subsystems.""" @@ -105,10 +120,7 @@ def __init__( self.command_registry = create_default_registry() self.tool_registry = ToolRegistry() self.tool_registry.register_default_tools() - from iac_code.services.cloud_credentials import CloudCredentials - from iac_code.tools.cloud.registry import register_cloud_tools - - register_cloud_tools(self.tool_registry, CloudCredentials()) + self.refresh_cloud_tools() self._current_model = model from iac_code.config import load_active_provider_config @@ -128,10 +140,12 @@ def __init__( self._session_storage = SessionStorage() self.session_index = SessionIndex() self._session_id = self._resolve_session_id(resume_session_id) + self._was_resumed = resume_session_id is not None from iac_code.utils.image.store import ImageStore self._image_store = ImageStore(session_id=self._session_id) self._resume_messages = self._load_resume_messages(resume_session_id) + self._session_name = self._load_current_session_name() self._task_manager = TaskManager() self._notification_queue = NotificationQueue() self._command_log: list[tuple[str, str, int, bool]] = [] @@ -163,45 +177,10 @@ def __init__( self.tool_registry.register(TaskGetTool(self._task_manager)) self.tool_registry.register(TaskStopTool(self._task_manager)) - # === Skill system initialization === - from iac_code.skills.bundled import init_bundled_skills - from iac_code.skills.discovery import discover_all_skills, skill_to_command - from iac_code.skills.listing import build_skill_listing - from iac_code.skills.skill_tool import SkillTool - - # 1. Initialize bundled skills (once) - init_bundled_skills() - - # 2. Discover all skills and register to unified CommandRegistry cwd = os.getcwd() - all_skills = discover_all_skills(cwd) - for skill in all_skills: - cmd = skill_to_command(skill) - existing = self.command_registry.get(cmd.name) - if existing is not None and not isinstance(existing, PromptCommand): - logger.warning( - "Skill '%s' (source=%s) skipped: conflicts with built-in command", - cmd.name, - cmd.source, - ) - continue - self.command_registry.register(cmd) - - # 3. Register SkillTool - self.tool_registry.register( - SkillTool( - command_registry=self.command_registry, - session_id=self._session_id, - cwd=cwd, - provider_manager=self._provider_manager, - tool_registry=self.tool_registry, - system_prompt=build_system_prompt(cwd=cwd, memory_content=memory_content), - ) - ) - - # 4. Generate skill listing for system prompt + self._memory_content = memory_content + self.refresh_skills() skill_commands = self.command_registry.get_model_invocable_skills() - self._skill_listing = build_skill_listing(skill_commands) from iac_code.services.permissions.loader import load_permission_context @@ -248,7 +227,7 @@ def __init__( cwd = os.getcwd() self._suggestion_aggregator = SuggestionAggregator( [ - CommandProvider(self.command_registry), + CommandProvider(self.command_registry, memory_manager=self._memory_manager), SkillProvider(self.command_registry), FileProvider(cwd), DirectoryProvider(cwd), @@ -275,6 +254,79 @@ def __init__( # Public entry-point # ------------------------------------------------------------------ + @property + def skill_management_items(self): + """Return all discovered skills with management state.""" + return getattr(self, "_skill_management_items", []) + + @property + def locked_skill_names(self): + """Return skill names that cannot be disabled.""" + return getattr(self, "_locked_skill_names", set()) + + def refresh_cloud_tools(self) -> None: + """Register cloud tools that are available with current cloud credentials.""" + from iac_code.services.cloud_credentials import CloudCredentials + from iac_code.tools.cloud.registry import register_cloud_tools + + register_cloud_tools(self.tool_registry, CloudCredentials()) + + def refresh_skills(self) -> None: + """Rediscover skills and refresh enabled/disabled skill state.""" + from iac_code.skills.bundled import init_bundled_skills + from iac_code.skills.discovery import discover_all_skills + from iac_code.skills.listing import build_skill_listing + from iac_code.skills.management import build_skill_management_state + from iac_code.skills.settings import load_disabled_skills + from iac_code.skills.skill_tool import SkillTool + + init_bundled_skills() + cwd = os.getcwd() + all_skills = discover_all_skills(cwd) + state = build_skill_management_state(all_skills, load_disabled_skills()) + self._skill_management_items = state.items + self._disabled_skill_commands = state.disabled_commands + self._locked_skill_names = state.locked_skill_names + + self.command_registry.clear_prompt_commands() + for cmd in state.enabled_commands: + existing = self.command_registry.get(cmd.name) + if existing is not None and not isinstance(existing, PromptCommand): + logger.warning( + "Skill '%s' (source=%s) skipped: conflicts with built-in command", + cmd.name, + cmd.source, + ) + continue + self.command_registry.register(cmd) + + memory_content = getattr(self, "_memory_content", "") + self.tool_registry.register( + SkillTool( + command_registry=self.command_registry, + disabled_skills=self._disabled_skill_commands, + session_id=self._session_id, + cwd=cwd, + provider_manager=self._provider_manager, + tool_registry=self.tool_registry, + system_prompt=build_system_prompt(cwd=cwd, memory_content=memory_content), + ) + ) + + skill_commands = self.command_registry.get_model_invocable_skills() + self._skill_listing = build_skill_listing(skill_commands) + + if hasattr(self, "_agent_loop"): + self._agent_loop.set_auto_trigger_skills(skill_commands) + self._agent_loop.set_provider( + self._provider_manager, + system_prompt=build_system_prompt( + cwd=cwd, + memory_content=memory_content, + skill_listing=self._skill_listing, + ), + ) + async def run(self, initial_prompt: str | None = None) -> None: """Run the REPL until the user exits. @@ -289,7 +341,9 @@ async def run(self, initial_prompt: str | None = None) -> None: state = self.store.get_state() if startup_update is not None: self.console.print(render_update_notice(startup_update)) - self.console.print(render_welcome_banner(state.model, state.cwd, session_id=self._session_id)) + self.console.print( + render_welcome_banner(state.model, state.cwd, session_id=self._session_id, session_name=self._session_name) + ) if self._resume_messages: self.renderer.replay_history(self._resume_messages) self.console.print() # blank line before first new user turn @@ -427,11 +481,7 @@ def _on_sigint() -> None: except (termios.error, OSError, ValueError): pass - from rich.text import Text - - self.console.print("[dim]{}[/dim]".format(_("Goodbye!"))) - self.console.print(Text(_("Resume this session with:"), style="dim")) - self.console.print(Text(f"iac-code --resume {self._session_id}", style="dim")) + self._print_exit_text() async def run_once(self, prompt: str) -> None: """Process a single prompt and exit (non-interactive mode).""" @@ -715,12 +765,16 @@ async def _handle_shell_escape(self, user_input: str) -> None: """Execute a local shell command from a leading ! REPL input.""" command = user_input[1:].strip() if not command: - self.renderer.print_system_message(_("Usage: !"), style="yellow") + message = _("Usage: !") + self._record_command_log(user_input, message, is_error=True) + self.renderer.print_system_message(message, style="yellow") return tool = self.tool_registry.get("bash") if tool is None: - self.renderer.print_system_message(_("Shell command support is unavailable."), style="red") + message = _("Shell command support is unavailable.") + self._record_command_log(user_input, message, is_error=True) + self.renderer.print_system_message(message, style="red") return tool_input = {"command": command} @@ -740,6 +794,8 @@ async def _handle_shell_escape(self, user_input: str) -> None: output = result.content.rstrip() if output: self.renderer.print_system_message(output, style="red" if result.is_error else "white") + log_result = f"$ {command}" if not output else f"$ {command}\n{output}" + self._record_command_log(user_input, log_result, is_error=result.is_error) async def _request_shell_escape_permission(self, tool, tool_input: dict) -> bool: """Check permission for a display-only shell escape before execution.""" @@ -778,11 +834,13 @@ async def _handle_command(self, user_input: str) -> None: cmd = self.command_registry.get(name) def _emit_error(message: str) -> None: - msg_count = len(self._agent_loop.context_manager.get_messages()) - self._command_log.append((user_input, message, msg_count, True)) + self._record_command_log(user_input, message, is_error=True) self.renderer.print_system_message(message, style="red") if cmd is None: + if is_skill_trigger and normalize_skill_name(name) in getattr(self, "_disabled_skill_commands", {}): + _emit_error(_("Skill '{name}' is disabled. Run /skills to enable it.").format(name=name)) + return if is_skill_trigger: _emit_error(_("Unknown skill: ${name}. Type / to list commands and skills.").format(name=name)) else: @@ -843,17 +901,20 @@ def _emit_error(message: str) -> None: self.store.set_state(is_busy=False) else: result = await handler_call - if result: - msg_count = len(self._agent_loop.context_manager.get_messages()) - self._command_log.append((user_input, result, msg_count, False)) + result_message, is_error, refresh_banner = _normalize_command_result(result) + if result_message: + self._record_command_log(user_input, result_message, is_error=is_error) # Re-render banner when model/provider actually switched new_state = self.store.get_state() new_provider_key = get_active_provider_key() - if new_state.model != prev_model or new_provider_key != prev_provider_key: + if refresh_banner or new_state.model != prev_model or new_provider_key != prev_provider_key: self._refresh_banner() else: - if result: - self.renderer.print_command_result(user_input, result) + if result_message: + if is_error: + self.renderer.print_system_message(result_message, style="red") + else: + self.renderer.print_command_result(user_input, result_message) except ExitREPLError: raise except Exception as exc: @@ -862,12 +923,24 @@ def _emit_error(message: str) -> None: style="red", ) + def _message_count(self) -> int: + try: + return len(self._agent_loop.context_manager.get_messages()) + except Exception: + return 0 + + def _record_command_log(self, user_input: str, result: str, *, is_error: bool) -> None: + if hasattr(self, "_command_log"): + self._command_log.append((user_input, result, self._message_count(), is_error)) + def _refresh_banner(self) -> None: """Clear screen and re-render the welcome banner, then replay history with commands.""" self.console.file.write("\033[H\033[2J\033[3J") self.console.file.flush() state = self.store.get_state() - self.console.print(render_welcome_banner(state.model, state.cwd, session_id=self._session_id)) + self.console.print( + render_welcome_banner(state.model, state.cwd, session_id=self._session_id, session_name=self._session_name) + ) messages = self._agent_loop.context_manager.get_messages() if not messages and not self._command_log and not self._streaming_error_log: return @@ -1075,6 +1148,17 @@ def _record_command_history(self, user_input: str) -> None: return self._history.append(user_input) + def _print_exit_text(self) -> None: + """Print the session resume hint shown when the REPL exits.""" + from rich.text import Text + + resume_arg = self._session_name or self._session_id + self.console.print("[dim]{}[/dim]".format(_("Goodbye!"))) + self.console.print(Text(_("Resume this session with:"), style="dim")) + self.console.print(Text("iac-code --resume {}".format(resume_arg), style="dim")) + if self._session_name: + self.console.print(Text("{}: {}".format(_("Session ID"), self._session_id), style="dim")) + def _apply_qwenpaw_config(self, model: str) -> None: """Apply QwenPaw config if active and env vars don't override.""" from iac_code.config import _get_env_overrides, get_llm_source @@ -1117,19 +1201,20 @@ def _resolve_session_id(self, resume: str | bool | None) -> str: if latest is None: return str(uuid.uuid4()) cwd, sid = latest - if cwd and cwd != self._original_cwd: + if cwd and not same_project_path(cwd, self._original_cwd): raise ValueError(self._cross_project_message(cwd, sid)) return sid - elif isinstance(resume, str) and resume: - if self._session_storage.exists(self._original_cwd, resume): - return resume - located = self._session_storage.find_session_anywhere(resume) - if located is None: + if isinstance(resume, str) and resume: + resolution = resolve_session_argument(self.session_index, self._original_cwd, resume) + if resolution.status == ResolutionStatus.NOT_FOUND: raise ValueError(_("Session not found: {session_id}").format(session_id=resume)) - cwd, _path = located - if cwd and cwd != self._original_cwd: - raise ValueError(self._cross_project_message(cwd, resume)) - return resume + if resolution.status == ResolutionStatus.AMBIGUOUS_NAME: + raise ValueError(self._ambiguous_resume_message(resolution.candidates)) + if resolution.entry is None: + raise ValueError(_("Session not found: {session_id}").format(session_id=resume)) + if resolution.entry.cwd and not same_project_path(resolution.entry.cwd, self._original_cwd): + raise ValueError(self._cross_project_message(resolution.entry.cwd, resolution.entry.session_id)) + return resolution.entry.session_id return str(uuid.uuid4()) def _load_resume_messages(self, resume: str | bool | None) -> list: @@ -1139,17 +1224,135 @@ def _load_resume_messages(self, resume: str | bool | None) -> list: messages = self._session_storage.load(self._original_cwd, self._session_id) return self._session_storage.repair_interrupted(messages) + def _load_current_session_name(self) -> str | None: + """Read the persisted display name for the active session.""" + metadata = self._session_storage.read_metadata(self._original_cwd, self._session_id) + return metadata.name if metadata else None + + def current_git_branch(self) -> str | None: + """Return the current git branch for the REPL's original directory.""" + try: + result = subprocess.run( + ["git", "-C", self._original_cwd, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + check=False, + text=True, + ) + except OSError: + return None + if result.returncode != 0: + return None + branch = result.stdout.strip() + return branch if branch and branch != "HEAD" else None + + def rename_current_session(self, name: str) -> str: + """Rename the active session and refresh cached session metadata.""" + result = self._session_storage.rename_session( + self._original_cwd, + self._session_id, + name, + git_branch=self.current_git_branch(), + ) + self._session_name = self._load_current_session_name() + return result + + async def prompt_for_session_name(self) -> str | None: + """Prompt until the user provides a valid session name or cancels.""" + while True: + try: + raw_name = await self._prompt_input.get_input(_("Session name: ")) + except (EOFError, KeyboardInterrupt): + return None + if raw_name is None: + return None + if not raw_name.strip(): + self.renderer.print_system_message(_("Session name cannot be empty."), style="red") + continue + try: + return normalize_session_name(raw_name) + except ValueError as exc: + self.renderer.print_system_message(str(exc), style="red") + @staticmethod def _cross_project_message(cwd: str, session_id: str) -> str: - import shlex - - cmd = f"cd {shlex.quote(cwd)} && iac-code --resume {session_id}" + cmd = format_resume_command(cwd, session_id) return _("This session belongs to a different directory.\nTo resume, run:\n {cmd}").format(cmd=cmd) + @staticmethod + def _ambiguous_resume_message(entries) -> str: + lines = [_("Multiple sessions match. Resume one by ID:"), ""] + for entry in entries: + cmd = format_resume_command(entry.cwd, entry.session_id) + lines.append(f" {cmd}") + return "\n".join(lines) + @property def session_id(self) -> str: return self._session_id + def get_status_snapshot(self) -> dict[str, Any]: + state = self.store.get_state() + messages = self._agent_loop.context_manager.get_messages() + return { + "session_id": self._session_id, + "resumed": self._was_resumed, + "provider": self._status_provider_display(), + "model": self._status_model(state.model), + "region": self._status_region(), + "cwd": self._original_cwd, + "api_usage": self._agent_loop.get_session_usage(), + "turn_count": self._count_user_turns(messages), + "max_turns": self._agent_loop.max_turns, + "context_usage": self._agent_loop.get_context_usage(), + } + + def _status_provider_display(self) -> str: + if hasattr(self._provider_manager, "get_provider_display"): + try: + display = self._provider_manager.get_provider_display() + except Exception: + display = "" + if isinstance(display, str) and display: + return display + key = get_active_provider_key() + if not key: + return "" + descriptor = PROVIDER_REGISTRY.get(key) + if descriptor is not None: + return descriptor.display_name + return key + + def _status_model(self, fallback: str) -> str: + if hasattr(self._provider_manager, "get_model_name"): + try: + model = self._provider_manager.get_model_name() + except Exception: + model = "" + if isinstance(model, str) and model: + return model + return fallback + + @staticmethod + def _status_region() -> str: + from iac_code.services.cloud_credentials import CloudCredentials + + credential = CloudCredentials().get_provider("aliyun") + return credential.region_id if credential and credential.region_id else "" + + @staticmethod + def _count_user_turns(messages: list) -> int: + from iac_code.agent.message import ToolResultBlock + + turns = 0 + for message in messages: + if getattr(message, "role", None) != "user": + continue + content = getattr(message, "content", "") + if isinstance(content, list) and any(isinstance(block, ToolResultBlock) for block in content): + continue + turns += 1 + return turns + # ------------------------------------------------------------------ # Session swap (used by /resume command) # ------------------------------------------------------------------ @@ -1160,27 +1363,34 @@ def swap_session(self, new_session_id: str) -> None: new_messages = self._session_storage.repair_interrupted(new_messages) self._agent_loop.replace_session(new_session_id, new_messages or None) self._session_id = new_session_id + self._was_resumed = True + self._session_name = self._load_current_session_name() # Clear screen + scrollback, redraw banner, replay history. self.console.file.write("\033[H\033[2J\033[3J") self.console.file.flush() state = self.store.get_state() - self.console.print(render_welcome_banner(state.model, state.cwd, session_id=new_session_id)) + self.console.print( + render_welcome_banner( + state.model, + state.cwd, + session_id=new_session_id, + session_name=self._session_name, + ) + ) if new_messages: self.renderer.replay_history(new_messages) self.console.print() async def swap_or_announce_session(self, entry) -> None: """Hot-swap if same project; otherwise print the resume command.""" - if entry.cwd and entry.cwd == self._original_cwd: + if entry.cwd and same_project_path(entry.cwd, self._original_cwd): self.swap_session(entry.session_id) return await self._announce_cross_project(entry) async def _announce_cross_project(self, entry) -> None: - import shlex - - cmd = f"cd {shlex.quote(entry.cwd)} && iac-code --resume {entry.session_id}" + cmd = format_resume_command(entry.cwd, entry.session_id) msg_lines = [ "", _("This conversation is from a different directory."), diff --git a/src/iac_code/ui/suggestions/command_provider.py b/src/iac_code/ui/suggestions/command_provider.py index c6a3d04..1aaf084 100644 --- a/src/iac_code/ui/suggestions/command_provider.py +++ b/src/iac_code/ui/suggestions/command_provider.py @@ -2,7 +2,10 @@ from __future__ import annotations +from typing import Any + from iac_code.commands.registry import CommandRegistry, LocalCommand +from iac_code.i18n import _ from iac_code.ui.suggestions.types import CompletionToken, SuggestionItem, SuggestionProvider @@ -11,14 +14,18 @@ class CommandProvider(SuggestionProvider): trigger = "/" - def __init__(self, registry: CommandRegistry) -> None: + def __init__(self, registry: CommandRegistry, memory_manager: Any | None = None) -> None: self._registry = registry + self._memory_manager = memory_manager def provide(self, token: CompletionToken) -> list[SuggestionItem]: """Return suggestions for the given completion token.""" # Strip the leading "/" to get the query query = token.text[1:] if token.text.startswith("/") else token.text + if self._is_memory_argument_query(query): + return self._memory_argument_suggestions(query) + matches = self._registry.fuzzy_search(query) items: list[SuggestionItem] = [] @@ -41,3 +48,88 @@ def provide(self, token: CompletionToken) -> list[SuggestionItem]: ) return items + + @staticmethod + def _is_memory_argument_query(query: str) -> bool: + return query.startswith("memory") and len(query) > len("memory") and query[len("memory")].isspace() + + def _memory_argument_suggestions(self, query: str) -> list[SuggestionItem]: + arg_text = query[len("memory") :].lstrip() + has_trailing_space = bool(arg_text) and arg_text[-1].isspace() + parts = arg_text.split() + + if not parts: + return self._memory_first_argument_suggestions("") + + action = parts[0].lower() + if action == "delete" and (has_trailing_space or len(parts) > 1): + prefix = parts[1] if len(parts) > 1 else "" + return self._memory_name_suggestions(prefix, command_prefix="/memory delete ") + + if action == "search" and has_trailing_space: + return [] + + if len(parts) == 1 and not has_trailing_space: + return self._memory_first_argument_suggestions(parts[0]) + + return [] + + def _memory_first_argument_suggestions(self, prefix: str) -> list[SuggestionItem]: + suggestions = [ + self._memory_action_item("search", _("Search saved memories"), "/memory search ", prefix), + self._memory_action_item("delete", _("Delete a saved memory"), "/memory delete ", prefix), + self._memory_action_item("help", _("Show memory command help"), "/memory help", prefix), + ] + suggestions.extend(self._memory_name_suggestions(prefix, command_prefix="/memory ")) + return [item for item in suggestions if item is not None] + + def _memory_action_item( + self, + name: str, + description: str, + completion: str, + prefix: str, + ) -> SuggestionItem | None: + if not self._matches_prefix(name, prefix): + return None + return SuggestionItem( + id=f"cmd:memory:{name}", + display_text=name, + completion=completion, + description=description, + icon="/", + source="command", + score=1000.0 - len(name), + ) + + def _memory_name_suggestions(self, prefix: str, *, command_prefix: str) -> list[SuggestionItem]: + items: list[SuggestionItem] = [] + for memory in self._memory_entries(): + name = str(memory.get("name", "")) + if not name or not self._matches_prefix(name, prefix): + continue + items.append( + SuggestionItem( + id=f"cmd:memory:{name}", + display_text=name, + completion=f"{command_prefix}{name}", + description=str(memory.get("description") or _("Saved memory")), + icon="/", + source="command", + score=500.0 - len(name), + ) + ) + return sorted(items, key=lambda item: item.display_text) + + def _memory_entries(self) -> list[dict[str, Any]]: + if self._memory_manager is None: + return [] + try: + memories = self._memory_manager.list_memories() + except (OSError, ValueError): + return [] + return [memory for memory in memories if isinstance(memory, dict)] + + @staticmethod + def _matches_prefix(value: str, prefix: str) -> bool: + return value.casefold().startswith(prefix.casefold()) diff --git a/src/iac_code/ui/suggestions/token_extractor.py b/src/iac_code/ui/suggestions/token_extractor.py index 65e6bd6..23d99f8 100644 --- a/src/iac_code/ui/suggestions/token_extractor.py +++ b/src/iac_code/ui/suggestions/token_extractor.py @@ -28,6 +28,10 @@ def extract(self, text: str, cursor_pos: int) -> CompletionToken | None: # Clamp cursor_pos to valid range end = min(cursor_pos, len(text)) + slash_token = self._extract_slash_command(text, end) + if slash_token is not None: + return slash_token + # Walk backwards to find start of token token_start = end while token_start > 0 and _is_token_char(text[token_start - 1]): @@ -86,3 +90,19 @@ def extract(self, text: str, cursor_pos: int) -> CompletionToken | None: return None return None + + @staticmethod + def _extract_slash_command(text: str, end: int) -> CompletionToken | None: + """Return a slash-command token spanning arguments on the current line.""" + line_start = text.rfind("\n", 0, end) + 1 + for index in range(line_start, end): + if text[index] != "/": + continue + if index == 0 or text[index - 1] in (" ", "\t", "\n"): + return CompletionToken( + text=text[index:end], + start=index, + end=end, + trigger="/", + ) + return None diff --git a/src/iac_code/utils/project_paths.py b/src/iac_code/utils/project_paths.py index 70818b4..83d806e 100644 --- a/src/iac_code/utils/project_paths.py +++ b/src/iac_code/utils/project_paths.py @@ -9,8 +9,11 @@ from __future__ import annotations +import ntpath import os import re +import shlex +import sys from hashlib import blake2b from pathlib import Path @@ -18,6 +21,7 @@ MAX_SANITIZED_LENGTH = 200 _NON_ALNUM = re.compile(r"[^a-zA-Z0-9]") +_WINDOWS_DRIVE_PATH = re.compile(r"^[a-zA-Z]:[\\/]") def sanitize_path(name: str) -> str: @@ -48,6 +52,46 @@ def get_session_path(cwd: str, session_id: str) -> Path: return get_project_dir(cwd) / f"{session_id}.jsonl" +def is_conversation_session_file(path: Path) -> bool: + """Return True for real conversation session JSONL files.""" + return path.name.endswith(".jsonl") and not path.name.endswith(".usage.jsonl") + + +def same_project_path(left: str, right: str) -> bool: + """Return whether two cwd strings identify the same project directory.""" + return _canonical_project_path(left) == _canonical_project_path(right) + + +def format_resume_command(cwd: str, session_id: str, *, platform: str | None = None) -> str: + """Build a copy-paste resume command for the current platform.""" + if (platform or sys.platform).startswith("win"): + return 'cd /d "{}" && iac-code --resume {}'.format(_escape_cmd_double_quotes(cwd), session_id) + return "cd {cwd} && iac-code --resume {session_id}".format( + cwd=shlex.quote(cwd), + session_id=shlex.quote(session_id), + ) + + +def _canonical_project_path(value: str) -> str: + expanded = os.path.expanduser(value) + if _looks_like_windows_path(expanded): + return ntpath.normcase(ntpath.normpath(expanded)) + try: + path = Path(expanded).resolve(strict=False) + except (OSError, RuntimeError): + path = Path(os.path.abspath(expanded)) + normalized = os.path.normpath(str(path)) + return os.path.normcase(normalized) + + +def _looks_like_windows_path(value: str) -> bool: + return bool(_WINDOWS_DRIVE_PATH.match(value)) or value.startswith(("\\\\", "//")) + + +def _escape_cmd_double_quotes(value: str) -> str: + return value.replace('"', '\\"') + + def _resolve_git_dir(worktree_root: str) -> str | None: """Given a worktree root, return the absolute path of its git dir. diff --git a/tests/acp/test_mcp.py b/tests/acp/test_mcp.py index 1f28d28..20dd256 100644 --- a/tests/acp/test_mcp.py +++ b/tests/acp/test_mcp.py @@ -10,6 +10,8 @@ from iac_code.acp.mcp import convert_mcp_configs from iac_code.acp.server import ACPServer +from iac_code.services.session_index import SessionEntry +from iac_code.services.session_resolver import ResolutionStatus, SessionResolution # --------------------------------------------------------------------------- # Helper factories @@ -232,6 +234,24 @@ async def test_resume_session_with_mcp_servers(self, monkeypatch) -> None: # that resume_session skips history injection into agent_loop. mock_storage_cls.repair_interrupted.return_value = [] monkeypatch.setattr("iac_code.acp.server.SessionStorage", mock_storage_cls) + monkeypatch.setattr( + "iac_code.acp.server.resolve_session_argument", + lambda index, cwd, arg: SessionResolution( + status=ResolutionStatus.FOUND, + entry=SessionEntry( + session_id="test-session", + cwd="/tmp", + project_name="-tmp", + git_branch=None, + title="test-session", + mtime=0.0, + size_bytes=0, + name=None, + auto_title=None, + is_legacy=False, + ), + ), + ) sse = _make_sse_server(name="resumed-sse") await server.resume_session(cwd="/tmp", session_id="test-session", mcp_servers=[sse]) diff --git a/tests/acp/test_scenarios.py b/tests/acp/test_scenarios.py index 96b8a5c..b81716a 100644 --- a/tests/acp/test_scenarios.py +++ b/tests/acp/test_scenarios.py @@ -1415,6 +1415,10 @@ async def test_pushed_commands_include_input_hint(monkeypatch: pytest.MonkeyPatc assert debug_cmd.input is not None assert debug_cmd.input.root.hint == "[on|off]" + rename_cmd = commands_by_name["rename"] + assert rename_cmd.input is not None + assert rename_cmd.input.root.hint == "" + # Only ACP-supported commands are pushed, model/effort are excluded assert "model" not in commands_by_name assert "effort" not in commands_by_name diff --git a/tests/acp/test_server_coverage.py b/tests/acp/test_server_coverage.py index c9b7c23..ea67f8a 100644 --- a/tests/acp/test_server_coverage.py +++ b/tests/acp/test_server_coverage.py @@ -32,6 +32,7 @@ ) from iac_code.acp.session import ACPSession from iac_code.agent.message import Message +from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import MessageEndEvent, TextDeltaEvent, Usage # --------------------------------------------------------------------------- @@ -117,6 +118,24 @@ async def test_list_sessions_with_cwd_project_dir_exists(monkeypatch, tmp_path) assert resp.next_cursor is None +@pytest.mark.asyncio +async def test_list_sessions_with_cwd_includes_directory_sessions_and_names(monkeypatch, tmp_path) -> None: + """list_sessions with cwd includes directory-format sessions and uses metadata names as titles.""" + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + storage = SessionStorage() + storage.save("/tmp", "named-dir-session", [Message(role="user", content="hello")]) + storage.rename_session("/tmp", "named-dir-session", "deploy-prod", git_branch="main") + + server = ACPServer() + resp = await server.list_sessions(cwd="/tmp") + + sessions_by_id = {session.session_id: session for session in resp.sessions} + assert "named-dir-session" in sessions_by_id + assert sessions_by_id["named-dir-session"].title == "deploy-prod" + assert sessions_by_id["named-dir-session"].cwd == "/tmp" + + @pytest.mark.asyncio async def test_list_sessions_with_cwd_project_dir_not_exists(monkeypatch, tmp_path) -> None: """list_sessions with cwd but project_dir does not exist → empty list.""" diff --git a/tests/acp/test_sessions.py b/tests/acp/test_sessions.py index 83ed1f1..3ac12b8 100644 --- a/tests/acp/test_sessions.py +++ b/tests/acp/test_sessions.py @@ -14,7 +14,10 @@ from iac_code.acp.server import SESSION_IDLE_TIMEOUT, ACPServer from iac_code.acp.session import ACPSession, _current_turn_id from iac_code.acp.state import TurnState +from iac_code.agent.message import Message, TextBlock +from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import MessageEndEvent, TextDeltaEvent, Usage +from iac_code.utils.project_paths import format_resume_command class FakeConn: @@ -189,6 +192,24 @@ async def test_acp_session_streams_text_update() -> None: assert conn.updates[0][1].session_update == "agent_message_chunk" +class _SessionMemoryManager: + def list_memories(self): + return [{"name": "user-role", "type": "user", "description": "Role", "content": "Senior engineer"}] + + +@pytest.mark.asyncio +async def test_acp_session_slash_memory_uses_session_memory_manager() -> None: + conn = _RecordingFakeConn() + session = ACPSession("s-memory", _RecordingFakeLoop(), conn, memory_manager=_SessionMemoryManager()) + + response = await session.prompt([acp.schema.TextContentBlock(type="text", text="/memory")]) + + assert response.stop_reason == "end_turn" + assert conn.updates[0][0] == "s-memory" + assert conn.updates[0][1].session_update == "agent_message_chunk" + assert "user-role - Role" in conn.updates[0][1].content.text + + # --------------------------------------------------------------------------- # ContextVar isolation tests (from test_context_var.py) # --------------------------------------------------------------------------- @@ -428,9 +449,10 @@ async def run_streaming(self, prompt: str): class _ResumeRuntime: - def __init__(self, session_id: str = "test-session") -> None: + def __init__(self, session_id: str = "test-session", cwd: str | None = None) -> None: self.session_id = session_id self.agent_loop = _ResumeLoop() + self.agent_loop._cwd = cwd self.tool_registry = None @@ -438,7 +460,7 @@ def _patch_resume_server(monkeypatch: pytest.MonkeyPatch, session_id: str = "tes monkeypatch.setattr("iac_code.acp.server.load_saved_model", lambda: "fake-model") monkeypatch.setattr( "iac_code.acp.server.create_agent_runtime", - lambda options: _ResumeRuntime(session_id=options.session_id or session_id), + lambda options: _ResumeRuntime(session_id=options.session_id or session_id, cwd=options.cwd), ) monkeypatch.setattr( "iac_code.acp.server.replace_bash_with_acp_terminal", @@ -462,6 +484,76 @@ async def test_resume_active_session_returns_immediately(monkeypatch: pytest.Mon assert sid in server.sessions +@pytest.mark.asyncio +async def test_resume_active_session_accepts_windows_equivalent_cwd(monkeypatch: pytest.MonkeyPatch) -> None: + """Windows path case and separator differences should not trip project ownership checks.""" + _patch_resume_server(monkeypatch) + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + + resp = await server.new_session(cwd=r"C:\Users\Me\Repo") + sid = resp.session_id + + result = await server.resume_session(cwd="c:/Users/Me/Repo", session_id=sid) + + assert isinstance(result, acp.schema.ResumeSessionResponse) + assert sid in server.sessions + + +@pytest.mark.asyncio +async def test_resume_active_session_from_other_cwd_raises_hint(monkeypatch: pytest.MonkeyPatch) -> None: + """An in-memory active session follows the same project boundary as persisted sessions.""" + _patch_resume_server(monkeypatch) + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + + resp = await server.new_session(cwd="/source project;unsafe") + sid = resp.session_id + + with pytest.raises(acp.RequestError) as exc_info: + await server.resume_session(cwd="/other", session_id=sid) + + assert isinstance(exc_info.value.data, dict) + assert exc_info.value.data["cwd"] == "/source project;unsafe" + expected_hint = format_resume_command("/source project;unsafe", "test-session") + assert exc_info.value.data["hint"] == expected_hint + assert sid in str(exc_info.value) + assert expected_hint in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_resume_resolved_name_rejects_active_session_from_other_cwd( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """The post-resolution active-session fast path also enforces project ownership.""" + _patch_resume_server(monkeypatch, session_id="same-id") + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + storage = SessionStorage() + storage.save( + "/current", + "same-id", + [Message(role="user", content=[TextBlock(text="hello")])], + ) + storage.rename_session("/current", "same-id", "deploy-prod", git_branch=None) + + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + await server.new_session(cwd="/other project;unsafe") + + with pytest.raises(acp.RequestError) as exc_info: + await server.resume_session(cwd="/current", session_id="deploy-prod") + + assert isinstance(exc_info.value.data, dict) + assert exc_info.value.data["cwd"] == "/other project;unsafe" + expected_hint = format_resume_command("/other project;unsafe", "same-id") + assert exc_info.value.data["hint"] == expected_hint + assert expected_hint in str(exc_info.value) + + @pytest.mark.asyncio async def test_resume_nonexistent_session_raises_error(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: """Resuming a session that doesn't exist in memory or storage raises RequestError.""" @@ -501,6 +593,117 @@ async def test_resume_from_storage(monkeypatch: pytest.MonkeyPatch, tmp_path) -> assert len(ctx.loaded_messages) == 1 +@pytest.mark.asyncio +async def test_resume_session_accepts_name(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Resuming by session name resolves to the persisted session id.""" + _patch_resume_server(monkeypatch) + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + storage = SessionStorage() + storage.save( + "/tmp", + "stored-named-session", + [Message(role="user", content=[TextBlock(text="hello")])], + ) + storage.rename_session("/tmp", "stored-named-session", "deploy-prod", git_branch="main") + + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + + result = await server.resume_session(cwd="/tmp", session_id="deploy-prod") + + assert isinstance(result, acp.schema.ResumeSessionResponse) + assert "deploy-prod" not in server.sessions + assert "stored-named-session" in server.sessions + resumed_session = server.sessions["stored-named-session"] + assert resumed_session.id == "stored-named-session" + ctx = resumed_session.agent_loop.context_manager + assert len(ctx.loaded_messages) == 1 + + +@pytest.mark.asyncio +async def test_resume_session_accepts_id_prefix(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Resuming by unique session id prefix resolves to the full persisted session id.""" + _patch_resume_server(monkeypatch) + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + storage = SessionStorage() + storage.save( + "/tmp", + "prefix-session-123", + [Message(role="user", content=[TextBlock(text="hello")])], + ) + + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + + result = await server.resume_session(cwd="/tmp", session_id="prefix-session") + + assert isinstance(result, acp.schema.ResumeSessionResponse) + assert "prefix-session" not in server.sessions + assert "prefix-session-123" in server.sessions + + +@pytest.mark.asyncio +async def test_resume_session_single_cross_project_match_raises_hint(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """A single foreign-project match is rejected with a concrete resume command hint.""" + _patch_resume_server(monkeypatch) + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + storage = SessionStorage() + foreign_cwd = "/other project;unsafe" + storage.save( + foreign_cwd, + "foreign-session-123", + [Message(role="user", content=[TextBlock(text="hello")])], + ) + storage.rename_session(foreign_cwd, "foreign-session-123", "foreign-deploy", git_branch=None) + + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + + with pytest.raises(acp.RequestError) as exc_info: + await server.resume_session(cwd="/tmp", session_id="foreign-deploy") + + assert isinstance(exc_info.value.data, dict) + expected_hint = format_resume_command("/other project;unsafe", "foreign-session-123") + assert exc_info.value.data["hint"] == expected_hint + assert "foreign-session-123" in str(exc_info.value) + assert expected_hint in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_resume_session_ambiguous_name_raises_candidates(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """ACP resume reports candidate ids when a name exists in multiple projects.""" + _patch_resume_server(monkeypatch) + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + storage = SessionStorage() + message = Message(role="user", content=[TextBlock(text="hello")]) + storage.save("/project a;bad", "candidate-a", [message]) + storage.rename_session("/project a;bad", "candidate-a", "deploy-prod", git_branch=None) + storage.save("/project-b", "candidate-b", [message]) + storage.rename_session("/project-b", "candidate-b", "deploy-prod", git_branch=None) + + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + + with pytest.raises(acp.RequestError) as exc_info: + await server.resume_session(cwd="/current", session_id="deploy-prod") + + assert "candidate-a" in str(exc_info.value) + assert "candidate-b" in str(exc_info.value) + assert isinstance(exc_info.value.data, dict) + candidates = exc_info.value.data["candidates"] + commands_by_id = {candidate["session_id"]: candidate["command"] for candidate in candidates} + assert commands_by_id["candidate-a"] == format_resume_command("/project a;bad", "candidate-a") + assert commands_by_id["candidate-b"] == format_resume_command("/project-b", "candidate-b") + + @pytest.mark.asyncio async def test_resume_session_can_prompt_after_restore(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: """A resumed session can accept new prompts normally.""" diff --git a/tests/acp/test_slash_registry.py b/tests/acp/test_slash_registry.py index a932e76..29e08d8 100644 --- a/tests/acp/test_slash_registry.py +++ b/tests/acp/test_slash_registry.py @@ -197,3 +197,155 @@ async def test_debug_off(registry: ACPSlashRegistry) -> None: async def test_debug_invalid_arg(registry: ACPSlashRegistry) -> None: result = await registry.execute("/debug foo", agent_loop=None) assert "usage" in result.lower() or "/debug" in result.lower() + + +# --------------------------------------------------------------------------- +# execute — /memory +# --------------------------------------------------------------------------- + + +class _MemoryManager: + def __init__(self): + self.memories = { + "user-role": {"name": "user-role", "type": "user", "description": "Role", "content": "Senior engineer"}, + "feedback-testing": { + "name": "feedback-testing", + "type": "feedback", + "description": "Testing", + "content": "Prefer integration tests", + }, + } + self.deleted: list[str] = [] + + def list_memories(self): + return list(self.memories.values()) + + def load(self, name): + if name == "../escape": + raise ValueError("Invalid memory name: '../escape'") + return self.memories.get(name) + + def delete(self, name): + self.deleted.append(name) + self.memories.pop(name, None) + + def search(self, query): + query = query.casefold() + return [ + memory + for memory in self.memories.values() + if query + in "\n".join(str(memory.get(field, "")) for field in ("name", "description", "type", "content")).casefold() + ] + + +@pytest.mark.asyncio +async def test_memory_without_manager_returns_unavailable(registry: ACPSlashRegistry) -> None: + result = await registry.execute("/memory", agent_loop=None) + assert result == "Memory manager is unavailable." + + +@pytest.mark.asyncio +async def test_memory_list_view_search_delete(registry: ACPSlashRegistry) -> None: + memory_manager = _MemoryManager() + + listed = await registry.execute("/memory", agent_loop=None, memory_manager=memory_manager) + viewed = await registry.execute("/memory user-role", agent_loop=None, memory_manager=memory_manager) + searched = await registry.execute("/memory search integration", agent_loop=None, memory_manager=memory_manager) + deleted = await registry.execute("/memory delete user-role", agent_loop=None, memory_manager=memory_manager) + + assert "Saved memories:" in listed + assert viewed == "[user] Role\n\nSenior engineer" + assert searched == "Matching memories:\n - feedback-testing - Testing" + assert deleted == "Memory 'user-role' deleted." + assert memory_manager.deleted == ["user-role"] + + +@pytest.mark.asyncio +async def test_memory_help_missing_invalid_name_and_unknown_usage(registry: ACPSlashRegistry) -> None: + memory_manager = _MemoryManager() + + helped = await registry.execute("/memory help", agent_loop=None, memory_manager=memory_manager) + missing = await registry.execute("/memory missing", agent_loop=None, memory_manager=memory_manager) + invalid = await registry.execute("/memory ../escape", agent_loop=None, memory_manager=memory_manager) + unknown = await registry.execute("/memory remove user-role", agent_loop=None, memory_manager=memory_manager) + + assert helped == "Usage: /memory [|search |delete |help]" + assert missing == "Memory 'missing' not found." + assert invalid == "Invalid memory name: '../escape'" + assert unknown == "Usage: /memory [|search |delete |help]" + + +# --------------------------------------------------------------------------- +# execute — /rename +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_rename_success_calls_storage_with_session_context(registry: ACPSlashRegistry) -> None: + agent_loop = MagicMock() + agent_loop._cwd = "/project" + agent_loop._session_id = "session-1" + agent_loop._current_git_branch = "main" + + storage = MagicMock() + storage.rename_session.return_value = "renamed" + + with patch("iac_code.acp.slash_registry.SessionStorage", return_value=storage): + result = await registry.execute("/rename deploy-prod", agent_loop=agent_loop) + + storage.rename_session.assert_called_once_with( + "/project", + "session-1", + "deploy-prod", + git_branch="main", + ) + assert result == "Renamed session to deploy-prod" + + +@pytest.mark.asyncio +async def test_rename_requires_name(registry: ACPSlashRegistry) -> None: + result = await registry.execute("/rename", agent_loop=MagicMock()) + + assert result == "Usage: /rename " + + +@pytest.mark.asyncio +async def test_rename_rejects_multi_token_name(registry: ACPSlashRegistry) -> None: + agent_loop = MagicMock() + + result = await registry.execute("/rename deploy prod", agent_loop=agent_loop) + + assert result == "Usage: /rename " + + +@pytest.mark.asyncio +async def test_rename_value_error_returns_message(registry: ACPSlashRegistry) -> None: + agent_loop = MagicMock() + agent_loop._cwd = "/project" + agent_loop._session_id = "session-1" + agent_loop._current_git_branch = None + + storage = MagicMock() + storage.rename_session.side_effect = ValueError("Session name already exists in this project: deploy-prod") + + with patch("iac_code.acp.slash_registry.SessionStorage", return_value=storage): + result = await registry.execute("/rename deploy-prod", agent_loop=agent_loop) + + assert result == "Session name already exists in this project: deploy-prod" + + +@pytest.mark.asyncio +async def test_rename_unchanged_message(registry: ACPSlashRegistry) -> None: + agent_loop = MagicMock() + agent_loop._cwd = "/project" + agent_loop._session_id = "session-1" + agent_loop._current_git_branch = None + + storage = MagicMock() + storage.rename_session.return_value = "unchanged" + + with patch("iac_code.acp.slash_registry.SessionStorage", return_value=storage): + result = await registry.execute("/rename deploy-prod", agent_loop=agent_loop) + + assert result == "Session is already named deploy-prod" diff --git a/tests/agent/test_agent_loop_new.py b/tests/agent/test_agent_loop_new.py index b668c72..d85fa53 100644 --- a/tests/agent/test_agent_loop_new.py +++ b/tests/agent/test_agent_loop_new.py @@ -60,6 +60,22 @@ def test_get_tool_definitions(self, mock_provider): assert defs[0].name == "read_file" assert defs[0].description == "Read file" + def test_set_auto_trigger_skills_refreshes_candidates(self, mock_provider, mock_registry): + old_command = SimpleNamespace(name="old-skill") + new_command = SimpleNamespace(name="new-skill") + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + auto_trigger_skills=[old_command], + ) + loop._auto_loaded_skills.add("old-skill") + + loop.set_auto_trigger_skills([new_command]) + + assert loop._auto_trigger_skills == [new_command] + assert loop._auto_loaded_skills == {"old-skill"} + def test_get_provider_messages_converts_strings_and_blocks(self, mock_provider, mock_registry): loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) loop.context_manager = MagicMock() @@ -125,6 +141,165 @@ async def fake_stream(messages, system, tools=None, max_tokens=8192): result = await loop.run("Hi") assert result == "Hello!" + async def test_records_non_zero_message_end_usage(self, mock_provider, mock_registry, tmp_path): + async def fake_stream(messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="Hello!") + yield MessageEndEvent( + stop_reason="end_turn", + usage=Usage( + input_tokens=10, + output_tokens=5, + cache_read_input_tokens=3, + cache_creation_input_tokens=2, + ), + ) + + from iac_code.services.session_usage import SessionUsageStore + + mock_provider.stream = fake_stream + store = SessionUsageStore(projects_dir=tmp_path) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="usage-session", + cwd="/tmp/status-project", + session_usage_store=store, + ) + + events = [e async for e in loop.run_streaming("Hi")] + + assert any(isinstance(e, MessageEndEvent) for e in events) + totals = loop.get_session_usage() + assert totals.input_tokens == 10 + assert totals.output_tokens == 5 + assert totals.cache_read_input_tokens == 3 + assert totals.cache_creation_input_tokens == 2 + assert totals.recorded_events == 1 + assert store.load("/tmp/status-project", "usage-session").total_tokens == 15 + + async def test_records_usage_with_runtime_provider_key(self, mock_provider, mock_registry, tmp_path, monkeypatch): + async def fake_stream(messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="Hello!") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage(input_tokens=10, output_tokens=5)) + + from iac_code.services.session_usage import SessionUsageStore + + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "openai") + mock_provider.stream = fake_stream + mock_provider.get_provider_key.return_value = "dashscope_token_plan" + mock_provider.get_model_name.return_value = "runtime-model" + store = SessionUsageStore(projects_dir=tmp_path) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="runtime-session", + cwd="/tmp/status-project", + session_usage_store=store, + ) + + await loop.run("Hi") + + row = store.path_for("/tmp/status-project", "runtime-session").read_text(encoding="utf-8") + assert '"provider": "dashscope_token_plan"' in row + assert '"model": "runtime-model"' in row + + async def test_records_usage_from_multiple_model_calls_in_one_prompt(self, mock_provider, mock_registry, tmp_path): + call_count = 0 + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + nonlocal call_count + call_count += 1 + if call_count == 1: + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="toolu_1", name="read_file") + yield ToolUseEndEvent(tool_use_id="toolu_1", name="read_file", input={"path": "a.txt"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage(input_tokens=10, output_tokens=5)) + return + + yield MessageStartEvent(message_id="m2") + yield TextDeltaEvent(text="After tool") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage(input_tokens=7, output_tokens=3)) + + from iac_code.services.session_usage import SessionUsageStore + + mock_provider.stream = fake_stream + mock_registry.list_tools.return_value = [SimpleNamespace(name="read_file", description="Read", input_schema={})] + store = SessionUsageStore(projects_dir=tmp_path) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="multi-call-session", + cwd="/tmp/status-project", + session_usage_store=store, + ) + loop._result_storage = MagicMock() + loop._result_storage.process.return_value = SimpleNamespace(content="processed result") + loop._tool_executor.execute_batch = AsyncMock(return_value=[ToolResult(content="raw result", is_error=False)]) + + events = [e async for e in loop.run_streaming("Hi")] + + assert call_count == 2 + assert any(isinstance(e, ToolResultEvent) for e in events) + totals = loop.get_session_usage() + assert totals.input_tokens == 17 + assert totals.output_tokens == 8 + assert totals.recorded_events == 2 + assert store.load("/tmp/status-project", "multi-call-session").total_tokens == 25 + + async def test_does_not_record_zero_message_end_usage(self, mock_provider, mock_registry, tmp_path): + async def fake_stream(messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="Hello!") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + from iac_code.services.session_usage import SessionUsageStore + + mock_provider.stream = fake_stream + store = SessionUsageStore(projects_dir=tmp_path) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="zero-session", + cwd="/tmp/status-project", + session_usage_store=store, + ) + + await loop.run("Hi") + + assert loop.get_session_usage().has_recorded_usage is False + assert not store.path_for("/tmp/status-project", "zero-session").exists() + + async def test_replace_session_reloads_usage_totals(self, mock_provider, mock_registry, tmp_path): + from iac_code.services.session_usage import SessionUsageStore + + store = SessionUsageStore(projects_dir=tmp_path) + store.append("/tmp/status-project", "old-session", Usage(input_tokens=1, output_tokens=2)) + store.append("/tmp/status-project", "new-session", Usage(input_tokens=7, output_tokens=8)) + + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="old-session", + cwd="/tmp/status-project", + session_usage_store=store, + ) + + assert loop.get_session_usage().total_tokens == 3 + + loop.replace_session("new-session", resume_messages=None) + + assert loop.session_id == "new-session" + assert loop.get_session_usage().input_tokens == 7 + assert loop.get_session_usage().output_tokens == 8 + assert loop.get_session_usage().total_tokens == 15 + async def test_run_streaming_executes_tools_and_applies_extensions(self, mock_provider, mock_registry): call_count = 0 @@ -506,6 +681,38 @@ async def test_auto_compact_success(self, mock_provider, mock_registry): assert event.original_tokens == 1200 assert event.compacted_tokens == 400 + async def test_auto_compact_records_response_usage(self, mock_provider, mock_registry, tmp_path): + from iac_code.services.session_usage import SessionUsageStore + + mock_provider.complete = AsyncMock( + return_value=SimpleNamespace( + text="summary", + usage=Usage(input_tokens=11, output_tokens=4, cache_read_input_tokens=2), + ) + ) + store = SessionUsageStore(projects_dir=tmp_path) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="auto-compact-usage", + cwd="/tmp/status-project", + session_usage_store=store, + ) + loop.context_manager = MagicMock() + loop.context_manager.build_compaction_prompt.return_value = "compact me" + loop.context_manager.apply_compaction.return_value = (1200, 400) + + event = await loop._auto_compact() + + assert isinstance(event, CompactionEvent) + totals = loop.get_session_usage() + assert totals.input_tokens == 11 + assert totals.output_tokens == 4 + assert totals.cache_read_input_tokens == 2 + assert totals.recorded_events == 1 + assert store.load("/tmp/status-project", "auto-compact-usage").total_tokens == 15 + async def test_auto_compact_returns_none_without_prompt(self, mock_provider, mock_registry): loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) loop.context_manager = MagicMock() @@ -526,6 +733,39 @@ async def test_compact_returns_success_with_tokens(self, mock_provider, mock_reg assert result.status == "success" assert (result.original_tokens, result.compacted_tokens) == (900, 300) + async def test_compact_records_response_usage(self, mock_provider, mock_registry, tmp_path): + from iac_code.services.session_usage import SessionUsageStore + + mock_provider.complete = AsyncMock( + return_value=SimpleNamespace( + text="summary", + usage=Usage(input_tokens=13, output_tokens=6, cache_creation_input_tokens=3), + ) + ) + store = SessionUsageStore(projects_dir=tmp_path) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="manual-compact-usage", + cwd="/tmp/status-project", + session_usage_store=store, + ) + loop.context_manager = MagicMock() + loop.context_manager.get_messages.return_value = [object()] + loop.context_manager.build_compaction_prompt.return_value = "compact me" + loop.context_manager.apply_compaction.return_value = (900, 300) + + result = await loop.compact() + + assert result.status == "success" + totals = loop.get_session_usage() + assert totals.input_tokens == 13 + assert totals.output_tokens == 6 + assert totals.cache_creation_input_tokens == 3 + assert totals.recorded_events == 1 + assert store.load("/tmp/status-project", "manual-compact-usage").total_tokens == 19 + async def test_compact_returns_empty_when_no_messages(self, mock_provider, mock_registry): loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) loop.context_manager = MagicMock() diff --git a/tests/cli/test_headless.py b/tests/cli/test_headless.py index 286634d..e026487 100644 --- a/tests/cli/test_headless.py +++ b/tests/cli/test_headless.py @@ -15,6 +15,8 @@ from iac_code.cli.headless import EXIT_ERROR, EXIT_MAX_TURNS, EXIT_OK, HeadlessRunner from iac_code.cli.output_formats import OutputFormat from iac_code.providers.manager import ProviderNotConfiguredError +from iac_code.skills.frontmatter import SkillFrontmatter +from iac_code.skills.skill_definition import SkillDefinition from iac_code.types.stream_events import ( ErrorEvent, MessageEndEvent, @@ -757,6 +759,10 @@ def __init__(self, name="prompt", **kwargs): "iac_code.skills.discovery.skill_to_command", lambda skill: SimpleNamespace(name=skill.name), ) + monkeypatch.setattr( + "iac_code.skills.management.skill_to_command", + lambda skill: SimpleNamespace(name=skill.name), + ) monkeypatch.setattr("iac_code.skills.listing.build_skill_listing", lambda skill_commands: "skill listing") monkeypatch.setattr( "iac_code.agent.system_prompt.build_system_prompt", @@ -806,7 +812,12 @@ def test_create_agent_loop_builds_expected_dependencies(monkeypatch): def test_create_agent_loop_handles_credential_load_failure_and_skill_conflict(monkeypatch): runner = _make_runner() existing_cmd = {"skill-one": object()} - skill = SimpleNamespace(name="skill-one") + skill = SkillDefinition( + name="skill-one", + description="skill-one description", + frontmatter=SkillFrontmatter(description="skill-one description"), + content="", + ) captured, fake_registry, fake_command_registry = _install_headless_fakes( monkeypatch, creds=None, diff --git a/tests/commands/test_auth_flows.py b/tests/commands/test_auth_flows.py index b4e5f9f..e6e0371 100644 --- a/tests/commands/test_auth_flows.py +++ b/tests/commands/test_auth_flows.py @@ -1,5 +1,6 @@ """Tests for auth_command and _auth_flow orchestration.""" +from datetime import datetime from unittest.mock import MagicMock import pytest @@ -7,11 +8,14 @@ from iac_code.commands.auth import ( _BACK, _aliyun_auth_flow, + _aliyun_credential_flow, _aliyun_region_flow, _auth_flow, _cloud_auth_flow, _cloud_provider_display, _llm_auth_flow, + _oauth_escape_cancel_event, + _render_credential_info, auth_command, ) @@ -38,8 +42,7 @@ async def test_no_context_no_console_in_kwargs(self): @pytest.mark.asyncio async def test_reinitialize_provider_called_after_auth(self, monkeypatch): - """After auth completes, provider should be reinitialized so - credential changes take effect immediately.""" + """After auth completes, provider and cloud tools refresh immediately.""" monkeypatch.setattr( "iac_code.commands.auth._auth_flow", lambda console, store: "Configured: test", @@ -62,6 +65,7 @@ class FakeContext: assert result == "Configured: test" repl._reinitialize_provider.assert_called_once_with("test-model") + repl.refresh_cloud_tools.assert_called_once_with() class TestAuthFlow: @@ -341,6 +345,31 @@ def select_side_effect(title, options, default_index=0): assert _aliyun_auth_flow() is _BACK assert calls["select"] == 2 + def test_render_credential_info_formats_oauth_expiration_as_local_datetime(self, monkeypatch): + from iac_code.services.providers.aliyun import AliyunCredential + + writes: list[str] = [] + monkeypatch.setattr("iac_code.commands.auth._write", writes.append) + credential = AliyunCredential( + mode="OAuth", + region_id="cn-hangzhou", + oauth_site_type="CN", + oauth_access_token="access-token", + oauth_refresh_token="refresh-token", + oauth_access_token_expire=1780397040, + sts_expiration=1780397041, + ) + + _render_credential_info(credential, "iac-code") + + output = "".join(writes) + expected_access_time = datetime.fromtimestamp(1780397040).astimezone().strftime("%Y-%m-%d %H:%M:%S") + expected_sts_time = datetime.fromtimestamp(1780397041).astimezone().strftime("%Y-%m-%d %H:%M:%S") + assert expected_access_time in output + assert expected_sts_time in output + assert "1780397040" not in output + assert "1780397041" not in output + def test_region_flow_updates_existing_credential(self, monkeypatch): from iac_code.services.providers.aliyun import AliyunCredential @@ -389,6 +418,258 @@ def test_region_flow_creates_new_credential_when_missing(self, monkeypatch): assert "Configured" in result assert saved["credential"].region_id == "cn-hangzhou" + def test_aliyun_credential_flow_shows_oauth_mode(self, monkeypatch): + options_seen = [] + + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", + lambda: None, + ) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.load_from_aliyun_cli", + lambda config_path=None: None, + ) + + def fake_select(title, options, default_index=0): + if "credential type" in title.lower(): + options_seen.extend(options) + return None + + monkeypatch.setattr("iac_code.commands.auth._select", fake_select) + + assert _aliyun_credential_flow() is _BACK + assert "OAuth Login (Browser)" in options_seen + + def test_aliyun_credential_flow_oauth_login_saves_credentials(self, monkeypatch): + from iac_code.services.providers.aliyun_oauth import OAuthStsCredentials, OAuthToken + + saved = {} + + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", + lambda: None, + ) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.load_from_aliyun_cli", + lambda config_path=None: None, + ) + + def fake_select(title, options, default_index=0): + if "credential type" in title.lower(): + return options.index("OAuth Login (Browser)") + if "site type" in title.lower(): + return options.index("China") + return None + + class FakeOAuthClient: + def __init__(self, site): + self.site = site + + def exchange_access_token_for_sts(self, access_token): + assert access_token == "access-token" + return OAuthStsCredentials("tmp-ak", "tmp-sk", "tmp-sts", 1798794000) + + def fake_browser_oauth_flow(site_type, oauth_client=None, cancel_event=None): + assert site_type == "CN" + assert isinstance(oauth_client, FakeOAuthClient) + assert cancel_event is not None + return OAuthToken("access-token", "refresh-token", 1798790400, 1822320000) + + monkeypatch.setattr("iac_code.commands.auth._select", fake_select) + monkeypatch.setattr( + "iac_code.services.providers.aliyun_oauth.run_browser_oauth_flow", + fake_browser_oauth_flow, + ) + monkeypatch.setattr("iac_code.services.providers.aliyun_oauth.AliyunOAuthClient", FakeOAuthClient) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.save", + lambda credential: saved.setdefault("credential", credential), + ) + + result = _aliyun_credential_flow() + + assert result == "Configured: Alibaba Cloud OAuth credentials saved" + credential = saved["credential"] + assert credential.mode == "OAuth" + assert credential.region_id == "cn-hangzhou" + assert credential.oauth_site_type == "CN" + assert credential.oauth_access_token == "access-token" + assert credential.oauth_refresh_token == "refresh-token" + assert credential.oauth_access_token_expire == 1798790400 + assert credential.oauth_refresh_token_expire == 1822320000 + assert credential.access_key_id == "tmp-ak" + assert credential.access_key_secret == "tmp-sk" + assert credential.sts_token == "tmp-sts" + assert credential.sts_expiration == 1798794000 + + def test_aliyun_credential_flow_oauth_preserves_existing_region(self, monkeypatch): + from iac_code.services.providers.aliyun import AliyunCredential + from iac_code.services.providers.aliyun_oauth import OAuthStsCredentials, OAuthToken + + existing = AliyunCredential(region_id="cn-shanghai") + saved = {} + + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", + lambda: existing, + ) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.load_from_aliyun_cli", + lambda config_path=None: None, + ) + monkeypatch.setattr( + "iac_code.commands.auth._select_with_info", + lambda title, options, info_renderer=None, default_index=0: 0, + ) + + def fake_select(title, options, default_index=0): + if "credential type" in title.lower(): + return options.index("OAuth Login (Browser)") + if "site type" in title.lower(): + return options.index("International") + return None + + class FakeOAuthClient: + def __init__(self, site): + self.site = site + + def exchange_access_token_for_sts(self, access_token): + assert access_token == "access-token" + return OAuthStsCredentials("tmp-ak", "tmp-sk", "tmp-sts", 1798794000) + + def fake_browser_oauth_flow(site_type, oauth_client=None, cancel_event=None): + assert site_type == "INTL" + assert isinstance(oauth_client, FakeOAuthClient) + assert cancel_event is not None + return OAuthToken("access-token", "refresh-token", 1798790400, 1822320000) + + monkeypatch.setattr("iac_code.commands.auth._select", fake_select) + monkeypatch.setattr( + "iac_code.services.providers.aliyun_oauth.run_browser_oauth_flow", + fake_browser_oauth_flow, + ) + monkeypatch.setattr("iac_code.services.providers.aliyun_oauth.AliyunOAuthClient", FakeOAuthClient) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.save", + lambda credential: saved.setdefault("credential", credential), + ) + + _aliyun_credential_flow() + + assert saved["credential"].region_id == "cn-shanghai" + assert saved["credential"].oauth_site_type == "INTL" + + def test_aliyun_credential_flow_oauth_error_returns_message_without_saving(self, monkeypatch): + from iac_code.services.providers.aliyun_oauth import AliyunOAuthError + + saved = {} + + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", + lambda: None, + ) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.load_from_aliyun_cli", + lambda config_path=None: None, + ) + + def fake_select(title, options, default_index=0): + if "credential type" in title.lower(): + return options.index("OAuth Login (Browser)") + if "site type" in title.lower(): + return options.index("China") + return None + + def fail_oauth(site_type, oauth_client=None, cancel_event=None): + assert site_type == "CN" + assert oauth_client is not None + assert cancel_event is not None + raise AliyunOAuthError("No available callback port") + + monkeypatch.setattr("iac_code.commands.auth._select", fake_select) + monkeypatch.setattr("iac_code.services.providers.aliyun_oauth.run_browser_oauth_flow", fail_oauth) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.save", + lambda credential: saved.setdefault("credential", credential), + ) + + result = _aliyun_credential_flow() + + assert result == "Alibaba Cloud OAuth login failed: No available callback port" + assert saved == {} + + def test_aliyun_credential_flow_oauth_cancel_returns_to_mode_selection(self, monkeypatch): + from iac_code.services.providers.aliyun_oauth import AliyunOAuthCancelledError + + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", + lambda: None, + ) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.load_from_aliyun_cli", + lambda config_path=None: None, + ) + + selections = iter(["credential", "site", "cancel"]) + + def fake_select(title, options, default_index=0): + step = next(selections) + if step == "credential": + return options.index("OAuth Login (Browser)") + if step == "site": + return options.index("China") + return None + + def cancel_oauth(site_type, oauth_client=None, cancel_event=None): + assert site_type == "CN" + assert cancel_event is not None + raise AliyunOAuthCancelledError("OAuth login cancelled.") + + monkeypatch.setattr("iac_code.commands.auth._select", fake_select) + monkeypatch.setattr("iac_code.services.providers.aliyun_oauth.run_browser_oauth_flow", cancel_oauth) + + assert _aliyun_credential_flow() is _BACK + + def test_oauth_escape_cancel_event_uses_cbreak_mode_to_preserve_output_newlines(self, monkeypatch): + termios = pytest.importorskip("termios") + tty = pytest.importorskip("tty") + calls: list[tuple] = [] + + class FakeStdin: + def isatty(self): + return True + + def fileno(self): + return 42 + + class FakeThread: + def __init__(self, target, args, daemon=False): + calls.append(("thread", target.__name__, daemon)) + + def start(self): + calls.append(("start",)) + + def join(self, timeout=None): + calls.append(("join", timeout)) + + def fail_setraw(fd): + raise AssertionError("OAuth Esc listener should not use raw mode because it breaks terminal newlines") + + monkeypatch.setattr("iac_code.commands.auth._IS_WIN32", False) + monkeypatch.setattr("iac_code.commands.auth.sys.stdin", FakeStdin()) + monkeypatch.setattr("iac_code.commands.auth.threading.Thread", FakeThread) + monkeypatch.setattr(termios, "tcgetattr", lambda fd: calls.append(("tcgetattr", fd)) or "old-settings") + monkeypatch.setattr(termios, "tcsetattr", lambda fd, when, settings: calls.append(("tcsetattr", fd, settings))) + monkeypatch.setattr(tty, "setraw", fail_setraw) + monkeypatch.setattr(tty, "setcbreak", lambda fd: calls.append(("setcbreak", fd))) + + with _oauth_escape_cancel_event(): + calls.append(("body",)) + + assert ("setcbreak", 42) in calls + assert ("body",) in calls + assert ("tcsetattr", 42, "old-settings") in calls + class TestAuthLlmSourceLock: def test_auth_flow_always_shows_category_selection(self, monkeypatch): diff --git a/tests/commands/test_clear.py b/tests/commands/test_clear.py index a2a7398..be947ca 100644 --- a/tests/commands/test_clear.py +++ b/tests/commands/test_clear.py @@ -56,8 +56,8 @@ async def test_clear_with_console_writes_ansi_and_banner(monkeypatch): calls = [] - def fake_banner(model, cwd): - calls.append((model, cwd)) + def fake_banner(model, cwd, *, session_id=None, session_name=None): + calls.append((model, cwd, session_id, session_name)) return "BANNER" monkeypatch.setattr("iac_code.ui.banner.render_welcome_banner", fake_banner) @@ -66,4 +66,30 @@ def fake_banner(model, cwd): # ANSI escape written console.file.write.assert_called() console.print.assert_called_with("BANNER") - assert calls == [("claude-sonnet-4-6", "/tmp")] + assert calls == [("claude-sonnet-4-6", "/tmp", None, None)] + + +@pytest.mark.asyncio +async def test_clear_banner_preserves_repl_session_identity(monkeypatch): + store = MagicMock() + state = MagicMock(model="claude-sonnet-4-6", cwd="/tmp") + store.get_state.return_value = state + + console = MagicMock() + console.file = MagicMock() + repl = MagicMock(_session_id="session-123", _session_name="deploy-prod") + context = MagicMock(store=store, console=console, repl=repl) + + calls = [] + + def fake_banner(model, cwd, *, session_id=None, session_name=None): + calls.append((model, cwd, session_id, session_name)) + return "BANNER" + + monkeypatch.setattr("iac_code.ui.banner.render_welcome_banner", fake_banner) + + result = await clear_command(context=context) + + assert result == "" + console.print.assert_called_with("BANNER") + assert calls == [("claude-sonnet-4-6", "/tmp", "session-123", "deploy-prod")] diff --git a/tests/commands/test_memory.py b/tests/commands/test_memory.py new file mode 100644 index 0000000..00b3649 --- /dev/null +++ b/tests/commands/test_memory.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import pytest + +from iac_code.commands.memory import execute_memory_command, memory_command +from iac_code.memory.memory_manager import MemoryManager + + +@pytest.fixture +def manager(tmp_path): + mgr = MemoryManager(memory_dir=str(tmp_path)) + mgr.save("user-role", "Senior cloud engineer", memory_type="user", description="Role") + mgr.save("feedback-testing", "Prefer integration tests", memory_type="feedback", description="Testing") + return mgr + + +class _Context: + def __init__(self, manager): + self.repl = type("Repl", (), {"_memory_manager": manager})() + + +def test_execute_memory_command_lists_memories(manager): + output = execute_memory_command(manager, []) + assert "Saved memories:" in output + assert "feedback-testing - Testing" in output + assert "user-role - Role" in output + + +def test_execute_memory_command_lists_empty(tmp_path): + output = execute_memory_command(MemoryManager(memory_dir=str(tmp_path)), []) + assert output == "No memories saved yet." + + +def test_execute_memory_command_views_memory(manager): + output = execute_memory_command(manager, ["user-role"]) + assert output == "[user] Role\n\nSenior cloud engineer" + + +def test_execute_memory_command_missing_memory(manager): + output = execute_memory_command(manager, ["missing"]) + assert output == "Memory 'missing' not found." + + +def test_execute_memory_command_searches_memories(manager): + output = execute_memory_command(manager, ["search", "integration"]) + assert output == "Matching memories:\n - feedback-testing - Testing" + + +def test_execute_memory_command_search_no_matches(manager): + output = execute_memory_command(manager, ["search", "nope"]) + assert output == "No matching memories." + + +def test_execute_memory_command_search_without_query_shows_help(manager): + output = execute_memory_command(manager, ["search"]) + assert "Usage: /memory" in output + + +def test_execute_memory_command_deletes_memory(manager): + output = execute_memory_command(manager, ["delete", "user-role"]) + assert output == "Memory 'user-role' deleted." + assert manager.load("user-role") is None + + +def test_execute_memory_command_delete_missing(manager): + output = execute_memory_command(manager, ["delete", "missing"]) + assert output == "Memory 'missing' not found." + + +def test_execute_memory_command_invalid_name(manager): + output = execute_memory_command(manager, ["../escape"]) + assert "Invalid memory name" in output + + +def test_execute_memory_command_help_and_unknown_multi_token(manager): + assert "Usage: /memory" in execute_memory_command(manager, ["help"]) + assert "Usage: /memory" in execute_memory_command(manager, ["remove", "user-role"]) + + +@pytest.mark.asyncio +async def test_memory_command_uses_repl_memory_manager(manager): + output = await memory_command(context=_Context(manager), args=["user-role"]) + assert output == "[user] Role\n\nSenior cloud engineer" + + +@pytest.mark.asyncio +async def test_memory_command_missing_context_manager(): + output = await memory_command(context=object(), args=[]) + assert output == "Memory manager is unavailable." diff --git a/tests/commands/test_registry.py b/tests/commands/test_registry.py index 3a8cedd..5924d86 100644 --- a/tests/commands/test_registry.py +++ b/tests/commands/test_registry.py @@ -1,7 +1,7 @@ """Tests for the commands/registry module.""" from iac_code.commands import create_default_registry -from iac_code.commands.registry import CommandRegistry, LocalCommand, _subsequence_score +from iac_code.commands.registry import CommandRegistry, LocalCommand, PromptCommand, _subsequence_score async def dummy_handler(**kwargs): @@ -190,6 +190,21 @@ def test_get_completions_sorted(self): completions = registry.get_completions("m") assert completions == ["map", "mark", "model"] + def test_clear_prompt_commands_preserves_local_commands(self): + """Test clearing prompt commands removes skills while preserving built-ins.""" + registry = CommandRegistry() + local = LocalCommand(name="help", description="Help", handler=dummy_handler, aliases=["?"]) + skill = PromptCommand(name="deploy", description="Deploy", aliases=["d"]) + registry.register(local) + registry.register(skill) + + registry.clear_prompt_commands() + + assert registry.get("help") is local + assert registry.get("?") is local + assert registry.get("deploy") is None + assert registry.get("d") is None + class TestCreateDefaultRegistry: """Tests for create_default_registry function.""" @@ -199,18 +214,40 @@ def test_create_default_registry_returns_registry(self): registry = create_default_registry() assert isinstance(registry, CommandRegistry) - def test_create_default_registry_has_9_commands(self): - """Test create_default_registry has 9 commands.""" + def test_create_default_registry_has_13_commands(self): + """Test create_default_registry has 13 commands.""" registry = create_default_registry() all_cmds = registry.get_all() - assert len(all_cmds) == 9 + assert len(all_cmds) == 13 def test_create_default_registry_command_names(self): """Test create_default_registry has expected command names.""" registry = create_default_registry() all_cmds = registry.get_all() names = {c.name for c in all_cmds} - assert names == {"help", "clear", "model", "compact", "exit", "auth", "debug", "effort", "resume"} + assert names == { + "help", + "clear", + "model", + "compact", + "exit", + "auth", + "debug", + "effort", + "resume", + "memory", + "skills", + "status", + "rename", + } + + def test_default_registry_includes_rename(self): + """Test rename command metadata.""" + registry = create_default_registry() + rename_cmd = registry.get("rename") + assert rename_cmd is not None + assert rename_cmd.arg_hint == "" + assert rename_cmd.history_mode == "session" def test_help_command_has_alias(self): """Test help command has ? alias.""" diff --git a/tests/commands/test_rename.py b/tests/commands/test_rename.py new file mode 100644 index 0000000..d25ca98 --- /dev/null +++ b/tests/commands/test_rename.py @@ -0,0 +1,120 @@ +"""Tests for the /rename command.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from iac_code.commands.rename import rename_command + + +@pytest.mark.asyncio +async def test_rename_inline_success() -> None: + repl = MagicMock() + repl.rename_current_session = MagicMock(return_value="renamed") + context = MagicMock(repl=repl) + + result = await rename_command(context=context, args=["deploy-prod"]) + + assert "deploy-prod" in result.message + assert "renamed" in result.message.lower() + assert result.is_error is False + assert result.refresh_banner is True + repl.rename_current_session.assert_called_once_with("deploy-prod") + + +@pytest.mark.asyncio +async def test_rename_multiple_args_returns_usage() -> None: + repl = MagicMock() + repl.rename_current_session = MagicMock() + context = MagicMock(repl=repl) + + result = await rename_command(context=context, args=["deploy", "prod"]) + + assert result.message == "Usage: /rename " + assert result.is_error is True + assert result.refresh_banner is False + repl.rename_current_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_rename_invalid_name_returns_validation_message() -> None: + repl = MagicMock() + repl.rename_current_session = MagicMock() + context = MagicMock(repl=repl) + + result = await rename_command(context=context, args=["bad name"]) + + assert result.message == "Session name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$" + assert result.is_error is True + assert result.refresh_banner is False + repl.rename_current_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_rename_duplicate_value_error_returns_message() -> None: + repl = MagicMock() + repl.rename_current_session = MagicMock(side_effect=ValueError("Session name already exists: deploy-prod")) + context = MagicMock(repl=repl) + + result = await rename_command(context=context, args=["deploy-prod"]) + + assert result.message == "Session name already exists: deploy-prod" + assert result.is_error is True + assert result.refresh_banner is False + + +@pytest.mark.asyncio +async def test_rename_unchanged_message() -> None: + repl = MagicMock() + repl.rename_current_session = MagicMock(return_value="unchanged") + context = MagicMock(repl=repl) + + result = await rename_command(context=context, args=["deploy-prod"]) + + assert "already" in result.message.lower() + assert "deploy-prod" in result.message + assert result.is_error is False + assert result.refresh_banner is False + + +@pytest.mark.asyncio +async def test_rename_interactive_success() -> None: + repl = MagicMock() + repl.prompt_for_session_name = AsyncMock(return_value=" deploy-prod ") + repl.rename_current_session = MagicMock(return_value="renamed") + context = MagicMock(repl=repl) + + result = await rename_command(context=context, args=[]) + + assert "deploy-prod" in result.message + assert "renamed" in result.message.lower() + assert result.is_error is False + assert result.refresh_banner is True + repl.prompt_for_session_name.assert_awaited_once() + repl.rename_current_session.assert_called_once_with("deploy-prod") + + +@pytest.mark.asyncio +async def test_rename_interactive_cancelled() -> None: + repl = MagicMock() + repl.prompt_for_session_name = AsyncMock(return_value=None) + repl.rename_current_session = MagicMock() + context = MagicMock(repl=repl) + + result = await rename_command(context=context, args=[]) + + assert "cancel" in result.message.lower() + assert result.is_error is False + assert result.refresh_banner is False + repl.rename_current_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_rename_no_interactive_context_returns_message() -> None: + result = await rename_command(context=None, args=["deploy-prod"]) + + assert "interactive" in result.message.lower() + assert result.is_error is True + assert result.refresh_banner is False diff --git a/tests/commands/test_resume.py b/tests/commands/test_resume.py index 094e5b2..1e7f1bc 100644 --- a/tests/commands/test_resume.py +++ b/tests/commands/test_resume.py @@ -6,8 +6,10 @@ import pytest +import iac_code.commands.resume as resume_module from iac_code.commands.resume import resume_command from iac_code.services.session_index import SessionEntry +from iac_code.services.session_resolver import ResolutionStatus, SessionResolution def _entry(**overrides) -> SessionEntry: @@ -31,29 +33,124 @@ async def test_resume_no_context_returns_message(): @pytest.mark.asyncio -async def test_resume_with_id_swaps_when_found(): +async def test_resume_with_id_swaps_when_found(monkeypatch): entry = _entry() repl = MagicMock() - repl.session_index.find_by_id_or_prefix.return_value = entry + repl._original_cwd = "/proj/x" repl.swap_or_announce_session = AsyncMock() context = MagicMock(repl=repl) + monkeypatch.setattr( + resume_module, + "resolve_session_argument", + MagicMock(return_value=SessionResolution(status=ResolutionStatus.FOUND, entry=entry)), + raising=False, + ) result = await resume_command(context=context, args=["abc-1"]) assert result == "" - repl.session_index.find_by_id_or_prefix.assert_called_once_with("abc-1") repl.swap_or_announce_session.assert_awaited_once_with(entry) @pytest.mark.asyncio -async def test_resume_with_id_not_found_returns_error(): +async def test_resume_with_name_uses_resolver_and_swaps_when_found(monkeypatch): + entry = _entry(name="deploy-prod", title="deploy-prod") repl = MagicMock() - repl.session_index.find_by_id_or_prefix.return_value = None + repl._original_cwd = "/proj/x" + repl.swap_or_announce_session = AsyncMock() context = MagicMock(repl=repl) + resolve_session_argument = MagicMock( + return_value=SessionResolution(status=ResolutionStatus.FOUND, entry=entry), + ) + monkeypatch.setattr(resume_module, "resolve_session_argument", resolve_session_argument, raising=False) + + result = await resume_command(context=context, args=["deploy-prod"]) + + assert result == "" + resolve_session_argument.assert_called_once_with(repl.session_index, "/proj/x", "deploy-prod") + repl.session_index.find_by_id_or_prefix.assert_not_called() + repl.swap_or_announce_session.assert_awaited_once_with(entry) + + +@pytest.mark.asyncio +async def test_resume_with_id_not_found_returns_error(monkeypatch): + repl = MagicMock() + repl._original_cwd = "/proj/x" + context = MagicMock(repl=repl) + monkeypatch.setattr( + resume_module, + "resolve_session_argument", + MagicMock(return_value=SessionResolution(status=ResolutionStatus.NOT_FOUND)), + raising=False, + ) + result = await resume_command(context=context, args=["nope"]) assert "not found" in result.lower() + repl.session_index.find_by_id_or_prefix.assert_not_called() + + +@pytest.mark.asyncio +async def test_resume_with_ambiguous_name_opens_picker_with_candidates_and_swaps(monkeypatch): + selected = _entry(session_id="picked", name="deploy-prod", title="deploy-prod") + candidates = [ + selected, + _entry(session_id="other", cwd="/proj/y", project_name="y", name="deploy-prod", title="deploy-prod"), + ] + repl = MagicMock() + repl._original_cwd = "/proj/x" + repl.session_id = "current" + repl._keybinding_manager = object() + repl.renderer = object() + repl.swap_or_announce_session = AsyncMock() + context = MagicMock(repl=repl) + monkeypatch.setattr( + resume_module, + "resolve_session_argument", + MagicMock(return_value=SessionResolution(status=ResolutionStatus.AMBIGUOUS_NAME, candidates=candidates)), + raising=False, + ) + + fake_picker_instance = MagicMock() + fake_picker_instance.run.return_value = selected + fake_picker_cls = MagicMock(return_value=fake_picker_instance) + monkeypatch.setattr("iac_code.ui.dialogs.resume_picker.ResumePicker", fake_picker_cls) + + result = await resume_command(context=context, args=["deploy-prod"]) + + assert result == "" + fake_picker_cls.assert_called_once_with( + index=repl.session_index, + current_cwd="/proj/x", + current_session_id="current", + keybinding_manager=repl._keybinding_manager, + renderer=repl.renderer, + entries=candidates, + ) + repl.swap_or_announce_session.assert_awaited_once_with(selected) + + +@pytest.mark.asyncio +async def test_resume_with_unknown_resolution_status_returns_error(monkeypatch): + repl = MagicMock() + repl._original_cwd = "/proj/x" + repl.swap_or_announce_session = AsyncMock() + context = MagicMock(repl=repl) + resolution = MagicMock() + resolution.status = "future-status" + monkeypatch.setattr( + resume_module, + "resolve_session_argument", + MagicMock(return_value=resolution), + raising=False, + ) + + result = await resume_command(context=context, args=["deploy-prod"]) + + assert result + assert "unable" in result.lower() + repl.swap_or_announce_session.assert_not_awaited() @pytest.mark.asyncio diff --git a/tests/commands/test_skills.py b/tests/commands/test_skills.py new file mode 100644 index 0000000..6804165 --- /dev/null +++ b/tests/commands/test_skills.py @@ -0,0 +1,52 @@ +"""Tests for the /skills command.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from iac_code.commands.skills import skills_command + + +@pytest.mark.asyncio +async def test_skills_no_context_returns_message(): + result = await skills_command(context=None, args=[]) + + assert "interactive" in result.lower() + + +@pytest.mark.asyncio +async def test_skills_cancel_does_not_save(monkeypatch): + repl = MagicMock() + repl.skill_management_items = [] + context = MagicMock(repl=repl) + fake_picker = MagicMock() + fake_picker.run.return_value = None + monkeypatch.setattr("iac_code.ui.dialogs.skills_picker.SkillsPicker", MagicMock(return_value=fake_picker)) + save_mock = MagicMock() + monkeypatch.setattr("iac_code.commands.skills.save_disabled_skills", save_mock) + + result = await skills_command(context=context, args=[]) + + assert "cancel" in result.lower() + save_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_skills_save_persists_and_refreshes(monkeypatch): + repl = MagicMock() + repl.skill_management_items = [] + repl.locked_skill_names = {"iac-aliyun"} + context = MagicMock(repl=repl) + fake_picker = MagicMock() + fake_picker.run.return_value = {"team-review"} + monkeypatch.setattr("iac_code.ui.dialogs.skills_picker.SkillsPicker", MagicMock(return_value=fake_picker)) + save_mock = MagicMock() + monkeypatch.setattr("iac_code.commands.skills.save_disabled_skills", save_mock) + + result = await skills_command(context=context, args=[]) + + assert "updated" in result.lower() + save_mock.assert_called_once_with({"team-review"}, locked_skill_names={"iac-aliyun"}) + repl.refresh_skills.assert_called_once() diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py new file mode 100644 index 0000000..dac9a22 --- /dev/null +++ b/tests/commands/test_status.py @@ -0,0 +1,227 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from rich.cells import cell_len +from rich.console import Console + +from iac_code.commands.status import status_command +from iac_code.i18n import setup_i18n + + +def _usage(**overrides): + values = { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 0, + "recorded_events": 0, + "has_recorded_usage": False, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _render_text(renderable) -> str: + console = Console(record=True, width=120, color_system=None) + console.print(renderable) + return console.export_text() + + +def _cell_index_before(rendered: str, value: str) -> int: + for line in rendered.splitlines(): + if value in line: + return cell_len(line.split(value, 1)[0]) + raise AssertionError(f"{value!r} not found in rendered output") + + +@pytest.mark.asyncio +async def test_status_requires_context() -> None: + result = await status_command() + assert "context" in result.lower() + + +@pytest.mark.asyncio +async def test_status_requires_repl() -> None: + context = MagicMock() + context.repl = None + result = await status_command(context=context) + assert "repl" in result.lower() + + +@pytest.mark.asyncio +async def test_status_prints_recorded_usage_panel() -> None: + console = MagicMock() + repl = MagicMock() + repl.get_status_snapshot.return_value = { + "session_id": "abc123", + "resumed": True, + "provider": "Alibaba Cloud Bailian", + "model": "qwen3.7-max", + "region": "cn-beijing", + "cwd": "/tmp/status-project", + "api_usage": _usage( + input_tokens=12450, + output_tokens=3280, + cache_read_input_tokens=8200, + cache_creation_input_tokens=10, + total_tokens=15730, + recorded_events=3, + has_recorded_usage=True, + ), + "turn_count": 7, + "max_turns": 100, + "context_usage": { + "total_tokens": 58000, + "context_window": 128000, + "usage_percent": 45.3125, + }, + } + context = MagicMock(console=console, repl=repl) + + result = await status_command(context=context) + + assert result is None + console.print.assert_called_once() + rendered = _render_text(console.print.call_args.args[0]) + assert "Session Status" in rendered + assert "abc123 (resumed)" in rendered + assert "Alibaba Cloud Bailian" in rendered + assert "qwen3.7-max" in rendered + assert "cn-beijing" in rendered + assert "12,450" in rendered + assert "3,280" in rendered + assert "8,200" in rendered + assert "15,730" in rendered + assert "Cache create" not in rendered + assert "7 / 100" in rendered + assert "45%" in rendered + + +@pytest.mark.asyncio +async def test_status_prints_no_recorded_usage_message() -> None: + console = MagicMock() + repl = MagicMock() + repl.get_status_snapshot.return_value = { + "session_id": "fresh", + "resumed": False, + "provider": "", + "model": "test-model", + "region": "", + "cwd": "/tmp/status-project", + "api_usage": _usage(), + "turn_count": 0, + "max_turns": 100, + "context_usage": { + "total_tokens": 0, + "context_window": 128000, + "usage_percent": 0.0, + }, + } + context = MagicMock(console=console, repl=repl) + + await status_command(context=context) + + rendered = _render_text(console.print.call_args.args[0]) + assert "not configured" in rendered + assert "No recorded API usage" in rendered + + +@pytest.mark.asyncio +async def test_status_uses_compiled_translations(monkeypatch) -> None: + monkeypatch.setenv("LANGUAGE", "zh") + setup_i18n() + try: + console = MagicMock() + repl = MagicMock() + repl.get_status_snapshot.return_value = { + "session_id": "abc123", + "resumed": True, + "provider": "dashscope", + "model": "qwen", + "region": "cn-beijing", + "cwd": "/tmp/status-project", + "api_usage": _usage(input_tokens=10, output_tokens=5, total_tokens=15, has_recorded_usage=True), + "turn_count": 1, + "max_turns": 100, + "context_usage": { + "total_tokens": 1000, + "context_window": 128000, + "usage_percent": 1.0, + }, + } + context = MagicMock(console=console, repl=repl) + + await status_command(context=context) + + rendered = _render_text(console.print.call_args.args[0]) + assert "会话状态" in rendered + assert "abc123(已恢复)" in rendered + assert "API Token 用量(已记录)" in rendered + assert "输入" in rendered + assert "缓存创建" not in rendered + finally: + monkeypatch.setenv("LANGUAGE", "en") + setup_i18n() + + +@pytest.mark.asyncio +async def test_status_aligns_translated_labels_by_display_width(monkeypatch) -> None: + monkeypatch.setenv("LANGUAGE", "zh") + setup_i18n() + try: + console = MagicMock() + repl = MagicMock() + repl.get_status_snapshot.return_value = { + "session_id": "abc123", + "resumed": True, + "provider": "dashscope", + "model": "qwen", + "region": "cn-beijing", + "cwd": "/tmp/status-project", + "api_usage": _usage( + input_tokens=43210, + output_tokens=5678, + cache_read_input_tokens=9012, + total_tokens=48888, + has_recorded_usage=True, + ), + "turn_count": 7, + "max_turns": 100, + "context_usage": { + "total_tokens": 1000, + "context_window": 128000, + "usage_percent": 1.0, + }, + } + context = MagicMock(console=console, repl=repl) + + await status_command(context=context) + + rendered = _render_text(console.print.call_args.args[0]) + main_values = [ + "abc123", + "dashscope", + "qwen", + "cn-beijing", + "/tmp/status-project", + "7 / 100", + "已使用 1%", + ] + main_starts = {_cell_index_before(rendered, value) for value in main_values} + usage_starts = { + _cell_index_before(rendered, value) + for value in [ + "43,210", + "5,678", + "9,012", + "48,888", + ] + } + + assert len(main_starts) == 1 + assert len(usage_starts) == 1 + finally: + monkeypatch.setenv("LANGUAGE", "en") + setup_i18n() diff --git a/tests/memory/test_memory_manager.py b/tests/memory/test_memory_manager.py index 03c5083..61f3e1c 100644 --- a/tests/memory/test_memory_manager.py +++ b/tests/memory/test_memory_manager.py @@ -91,3 +91,68 @@ def test_legacy_invalid_memory_file_does_not_break_listing_or_index_update(self, assert [memory["name"] for memory in memories] == ["old memory"] assert "old memory" in manager.get_index_content() assert "new-safe" in manager.get_index_content() + + def test_search_matches_name_description_type_and_content_case_insensitive(self, manager): + manager.save("user-role", content="Senior cloud engineer", memory_type="user", description="Role") + manager.save("feedback-testing", content="Prefer integration tests", memory_type="feedback", description="QA") + manager.save("project-deadline", content="Freeze on 2026-06-15", memory_type="project", description="Schedule") + + assert [m["name"] for m in manager.search("ROLE")] == ["user-role"] + assert [m["name"] for m in manager.search("integration")] == ["feedback-testing"] + assert [m["name"] for m in manager.search("project")] == ["project-deadline"] + assert [m["name"] for m in manager.search("schedule")] == ["project-deadline"] + + def test_search_empty_query_and_no_matches(self, manager): + manager.save("user-role", content="Senior cloud engineer", memory_type="user", description="Role") + + assert manager.search("") == [] + assert manager.search(" ") == [] + assert manager.search("does-not-exist") == [] + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink permissions vary on Windows") + def test_list_and_search_ignore_symlinked_memory_files(self, manager, tmp_path): + outside = tmp_path.parent / "outside.md" + outside.write_text("---\nname: leaked\ndescription: Secret\ntype: user\n---\n\nsecret outside content\n") + (tmp_path / "leaked.md").symlink_to(outside) + + assert manager.list_memories() == [] + assert manager.search("secret") == [] + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink permissions vary on Windows") + def test_load_does_not_follow_symlinked_memory_file(self, manager, tmp_path): + outside = tmp_path.parent / "outside.md" + outside.write_text("---\nname: leaked\ndescription: Secret\ntype: user\n---\n\nsecret outside content\n") + (tmp_path / "leaked.md").symlink_to(outside) + + assert manager.load("leaked") is None + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink permissions vary on Windows") + def test_save_does_not_overwrite_symlinked_memory_file(self, manager, tmp_path): + outside = tmp_path.parent / "outside.md" + outside.write_text("original") + (tmp_path / "leaked.md").symlink_to(outside) + + with pytest.raises(ValueError, match="Invalid memory path"): + manager.save("leaked", content="new", memory_type="user", description="bad") + + assert outside.read_text() == "original" + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink permissions vary on Windows") + def test_get_index_content_does_not_follow_symlinked_index(self, manager, tmp_path): + outside = tmp_path.parent / "outside-index.md" + outside.write_text("secret index") + (tmp_path / "MEMORY.md").symlink_to(outside) + + assert manager.get_index_content() == "" + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink permissions vary on Windows") + def test_save_does_not_overwrite_symlinked_index(self, manager, tmp_path): + outside = tmp_path.parent / "outside-index.md" + outside.write_text("original index") + (tmp_path / "MEMORY.md").symlink_to(outside) + + with pytest.raises(ValueError, match="Invalid memory path"): + manager.save("safe", content="content", memory_type="user", description="safe") + + assert outside.read_text() == "original index" + assert not (tmp_path / "safe.md").exists() diff --git a/tests/memory/test_memory_tools.py b/tests/memory/test_memory_tools.py index f3fcc92..a14cc21 100644 --- a/tests/memory/test_memory_tools.py +++ b/tests/memory/test_memory_tools.py @@ -143,3 +143,10 @@ async def test_write_memory_returns_error_for_invalid_name(self, tmp_path): async def test_is_read_only(self): assert WriteMemoryTool(FakeMemoryManager()).is_read_only() is False + + async def test_description_guides_explicit_remember_requests(self): + description = WriteMemoryTool(FakeMemoryManager()).description + + assert "explicitly asks" in description + assert "remember" in description + assert "concise" in description diff --git a/tests/providers/test_manager.py b/tests/providers/test_manager.py index 424b190..3be398c 100644 --- a/tests/providers/test_manager.py +++ b/tests/providers/test_manager.py @@ -114,6 +114,18 @@ def test_unknown_model_no_fallback(self, monkeypatch): m = ProviderManager(model="some-model-without-fallback", credentials={}) assert m._get_fallback_model() is None + def test_provider_key_and_display_use_runtime_override(self, monkeypatch): + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "openai") + monkeypatch.setattr("iac_code.config.get_provider_config", lambda name: {}) + m = ProviderManager( + model="qwen3.6-plus", + credentials={"dashscope_token_plan": "tp-key"}, + provider_key_override="dashscope_token_plan", + ) + + assert m.get_provider_key() == "dashscope_token_plan" + assert m.get_provider_display() == "Alibaba Cloud Bailian Token Plan" + def test_reconfigure_swaps_model_and_credentials(self, monkeypatch): monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "anthropic") m = ProviderManager(model="claude-sonnet-4-6", credentials={"anthropic": "old"}) diff --git a/tests/services/providers/test_aliyun.py b/tests/services/providers/test_aliyun.py index 5dcff4f..f1a05f4 100644 --- a/tests/services/providers/test_aliyun.py +++ b/tests/services/providers/test_aliyun.py @@ -2,6 +2,7 @@ import os from unittest.mock import patch +import pytest import yaml from iac_code.services.providers.aliyun import ( @@ -9,6 +10,7 @@ AliyunCredentials, mask_sensitive, ) +from iac_code.services.providers.aliyun_oauth import AliyunOAuthReloginRequired, OAuthStsCredentials, OAuthToken class TestAliyunCredential: @@ -58,6 +60,33 @@ def test_ram_role_arn_mode(self): assert cred.ram_role_arn == "acs:ram::123:role/test" assert cred.ram_session_name == "session1" + def test_oauth_mode_fields(self): + cred = AliyunCredential( + mode="OAuth", + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + sts_expiration=1798794000, + oauth_site_type="CN", + oauth_access_token="oauth-access", + oauth_refresh_token="oauth-refresh", + oauth_access_token_expire=1798790400, + oauth_refresh_token_expire=1801382400, + region_id="cn-hangzhou", + ) + + assert cred.mode == "OAuth" + assert cred.access_key_id == "tmp-ak" + assert cred.access_key_secret == "tmp-sk" + assert cred.sts_token == "tmp-sts" + assert cred.sts_expiration == 1798794000 + assert cred.oauth_site_type == "CN" + assert cred.oauth_access_token == "oauth-access" + assert cred.oauth_refresh_token == "oauth-refresh" + assert cred.oauth_access_token_expire == 1798790400 + assert cred.oauth_refresh_token_expire == 1801382400 + assert cred.region_id == "cn-hangzhou" + class TestMaskSensitive: def test_mask_normal_string(self): @@ -286,6 +315,48 @@ def test_load_from_config_file(self, tmp_path): assert cred.region_id == "cn-shenzhen" assert cred.mode == "AK" + def test_load_oauth_from_aliyun_cli_default_profile(self, tmp_path): + config_file = tmp_path / "config.json" + config = { + "current": "default", + "profiles": [ + { + "name": "default", + "mode": "OAuth", + "access_key_id": "tmp-ak", + "access_key_secret": "tmp-sk", + "sts_token": "tmp-sts", + "sts_expiration": 1798794000, + "oauth_site_type": "CN", + "oauth_access_token": "oauth-access", + "oauth_refresh_token": "oauth-refresh", + "oauth_access_token_expire": 1798790400, + "oauth_refresh_token_expire": 1801382400, + "region_id": "cn-hangzhou", + } + ], + } + config_file.write_text(json.dumps(config)) + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("ALIBABA_CLOUD_ACCESS_KEY_ID", None) + os.environ.pop("ALIBABA_CLOUD_ACCESS_KEY_SECRET", None) + os.environ.pop("ALIBABA_CLOUD_REGION_ID", None) + cred = AliyunCredentials.load(config_path=str(config_file)) + + assert cred is not None + assert cred.mode == "OAuth" + assert cred.access_key_id == "tmp-ak" + assert cred.access_key_secret == "tmp-sk" + assert cred.sts_token == "tmp-sts" + assert cred.sts_expiration == 1798794000 + assert cred.oauth_site_type == "CN" + assert cred.oauth_access_token == "oauth-access" + assert cred.oauth_refresh_token == "oauth-refresh" + assert cred.oauth_access_token_expire == 1798790400 + assert cred.oauth_refresh_token_expire == 1801382400 + assert cred.region_id == "cn-hangzhou" + def test_load_ram_role_arn_from_config_file(self, tmp_path): config_file = tmp_path / "config.json" config = { @@ -408,6 +479,41 @@ def test_load_from_iac_code_config(self, tmp_path): assert cred.access_key_secret == "iac_secret" assert cred.region_id == "cn-beijing" + def test_load_oauth_from_iac_code_config(self, tmp_path): + cloud_creds_file = tmp_path / ".cloud-credentials.yml" + data = { + "aliyun": { + "mode": "OAuth", + "region_id": "cn-hangzhou", + "oauth_site_type": "CN", + "oauth_access_token": "oauth-access", + "oauth_refresh_token": "oauth-refresh", + "oauth_access_token_expire": 1798790400, + "oauth_refresh_token_expire": 1801382400, + "access_key_id": "tmp-ak", + "access_key_secret": "tmp-sk", + "sts_token": "tmp-sts", + "sts_expiration": 1798794000, + } + } + cloud_creds_file.write_text(yaml.dump(data)) + + with patch("iac_code.services.providers.aliyun.get_cloud_credentials_path", return_value=cloud_creds_file): + cred = AliyunCredentials._load_from_iac_code_config() + + assert cred is not None + assert cred.mode == "OAuth" + assert cred.region_id == "cn-hangzhou" + assert cred.oauth_site_type == "CN" + assert cred.oauth_access_token == "oauth-access" + assert cred.oauth_refresh_token == "oauth-refresh" + assert cred.oauth_access_token_expire == 1798790400 + assert cred.oauth_refresh_token_expire == 1801382400 + assert cred.access_key_id == "tmp-ak" + assert cred.access_key_secret == "tmp-sk" + assert cred.sts_token == "tmp-sts" + assert cred.sts_expiration == 1798794000 + def test_load_from_iac_code_returns_none_when_no_file(self, tmp_path): cloud_creds_file = tmp_path / ".cloud-credentials.yml" @@ -522,6 +628,40 @@ def test_save_ram_role_arn_to_iac_code_config(self, tmp_path): assert data["aliyun"]["ram_role_arn"] == "acs:ram::123:role/test" assert data["aliyun"]["ram_session_name"] == "session1" + def test_save_oauth_to_iac_code_config(self, tmp_path): + cloud_creds_file = tmp_path / ".cloud-credentials.yml" + + with patch("iac_code.services.providers.aliyun.get_cloud_credentials_path", return_value=cloud_creds_file): + cred = AliyunCredential( + mode="OAuth", + region_id="cn-hangzhou", + oauth_site_type="INTL", + oauth_access_token="oauth-access", + oauth_refresh_token="oauth-refresh", + oauth_access_token_expire=1798790400, + oauth_refresh_token_expire=1801382400, + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + sts_expiration=1798794000, + ) + AliyunCredentials.save(cred) + + data = yaml.safe_load(cloud_creds_file.read_text()) + assert data["aliyun"] == { + "mode": "OAuth", + "region_id": "cn-hangzhou", + "oauth_site_type": "INTL", + "oauth_access_token": "oauth-access", + "oauth_refresh_token": "oauth-refresh", + "oauth_access_token_expire": 1798790400, + "oauth_refresh_token_expire": 1801382400, + "access_key_id": "tmp-ak", + "access_key_secret": "tmp-sk", + "sts_token": "tmp-sts", + "sts_expiration": 1798794000, + } + def test_save_to_aliyun_cli_format(self, tmp_path): """Test save with config_path (aliyun CLI format, for testing).""" config_file = tmp_path / "config.json" @@ -612,6 +752,167 @@ def test_save_does_not_write_to_aliyun_cli_config(self, tmp_path): assert not aliyun_cli_file.exists() +class TestAliyunCredentialsOAuthRefresh: + def test_refresh_oauth_uses_unexpired_sts_without_network(self, monkeypatch): + cred = AliyunCredential( + mode="OAuth", + oauth_site_type="CN", + oauth_access_token="access", + oauth_refresh_token="refresh", + oauth_access_token_expire=2000, + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + sts_expiration=1900, + ) + + class FailingClient: + def refresh_access_token(self, refresh_token, *, now=None): + raise AssertionError("refresh_access_token should not be called") + + def exchange_access_token_for_sts(self, access_token): + raise AssertionError("exchange_access_token_for_sts should not be called") + + monkeypatch.setattr( + AliyunCredentials, + "save", + lambda credential: (_ for _ in ()).throw(AssertionError("save should not be called")), + ) + + refreshed = AliyunCredentials.refresh_oauth_if_needed(cred, oauth_client=FailingClient(), now=1000) + + assert refreshed is cred + assert cred.access_key_id == "tmp-ak" + assert cred.access_key_secret == "tmp-sk" + assert cred.sts_token == "tmp-sts" + assert cred.sts_expiration == 1900 + + def test_refresh_oauth_exchanges_expired_sts_with_current_access_token(self, monkeypatch): + cred = AliyunCredential( + mode="OAuth", + oauth_site_type="CN", + oauth_access_token="access", + oauth_refresh_token="refresh", + oauth_access_token_expire=2000, + access_key_id="old-ak", + access_key_secret="old-sk", + sts_token="old-sts", + sts_expiration=900, + ) + saved: list[AliyunCredential] = [] + + class FakeClient: + def exchange_access_token_for_sts(self, access_token): + assert access_token == "access" + return OAuthStsCredentials("new-ak", "new-sk", "new-sts", 2500) + + monkeypatch.setattr(AliyunCredentials, "save", saved.append) + + refreshed = AliyunCredentials.refresh_oauth_if_needed(cred, oauth_client=FakeClient(), now=1000) + + assert refreshed is cred + assert saved == [cred] + assert cred.access_key_id == "new-ak" + assert cred.access_key_secret == "new-sk" + assert cred.sts_token == "new-sts" + assert cred.sts_expiration == 2500 + + def test_refresh_oauth_refreshes_access_token_before_exchange(self, monkeypatch): + cred = AliyunCredential( + mode="OAuth", + oauth_site_type="CN", + oauth_access_token="old-access", + oauth_refresh_token="old-refresh", + oauth_access_token_expire=900, + access_key_id="old-ak", + access_key_secret="old-sk", + sts_token="old-sts", + sts_expiration=900, + ) + saved: list[AliyunCredential] = [] + + class FakeClient: + def refresh_access_token(self, refresh_token, *, now=None): + assert refresh_token == "old-refresh" + assert now == 1000 + return OAuthToken("new-access", "new-refresh", 4600, 0) + + def exchange_access_token_for_sts(self, access_token): + assert access_token == "new-access" + return OAuthStsCredentials("new-ak", "new-sk", "new-sts", 2500) + + monkeypatch.setattr(AliyunCredentials, "save", saved.append) + + refreshed = AliyunCredentials.refresh_oauth_if_needed(cred, oauth_client=FakeClient(), now=1000) + + assert refreshed is cred + assert saved == [cred] + assert cred.oauth_access_token == "new-access" + assert cred.oauth_refresh_token == "new-refresh" + assert cred.oauth_access_token_expire == 4600 + assert cred.oauth_refresh_token_expire == 0 + assert cred.access_key_id == "new-ak" + assert cred.access_key_secret == "new-sk" + assert cred.sts_token == "new-sts" + assert cred.sts_expiration == 2500 + + def test_refresh_oauth_requires_relogin_when_refresh_token_missing(self): + cred = AliyunCredential( + mode="OAuth", + oauth_site_type="CN", + oauth_access_token="old-access", + oauth_access_token_expire=900, + access_key_id="old-ak", + access_key_secret="old-sk", + sts_token="old-sts", + sts_expiration=900, + ) + + with pytest.raises(AliyunOAuthReloginRequired, match="/auth"): + AliyunCredentials.refresh_oauth_if_needed(cred, oauth_client=object(), now=1000) + + def test_refresh_oauth_returns_non_oauth_credentials_without_network(self, monkeypatch): + cred = AliyunCredential( + mode="AK", + access_key_id="ak", + access_key_secret="sk", + ) + + class FailingClient: + def refresh_access_token(self, refresh_token, *, now=None): + raise AssertionError("refresh_access_token should not be called") + + def exchange_access_token_for_sts(self, access_token): + raise AssertionError("exchange_access_token_for_sts should not be called") + + monkeypatch.setattr( + AliyunCredentials, + "save", + lambda credential: (_ for _ in ()).throw(AssertionError("save should not be called")), + ) + + refreshed = AliyunCredentials.refresh_oauth_if_needed(cred, oauth_client=FailingClient(), now=1000) + + assert refreshed is cred + assert cred.access_key_id == "ak" + assert cred.access_key_secret == "sk" + + def test_refresh_oauth_requires_relogin_when_oauth_site_type_missing(self): + cred = AliyunCredential( + mode="OAuth", + oauth_access_token="access", + oauth_refresh_token="refresh", + oauth_access_token_expire=2000, + access_key_id="old-ak", + access_key_secret="old-sk", + sts_token="old-sts", + sts_expiration=900, + ) + + with pytest.raises(AliyunOAuthReloginRequired, match="/auth"): + AliyunCredentials.refresh_oauth_if_needed(cred, oauth_client=object(), now=1000) + + class TestAliyunCredentialsIsConfigured: def test_is_configured_true_with_env_vars(self): env = { diff --git a/tests/services/providers/test_aliyun_oauth.py b/tests/services/providers/test_aliyun_oauth.py new file mode 100644 index 0000000..567faf8 --- /dev/null +++ b/tests/services/providers/test_aliyun_oauth.py @@ -0,0 +1,605 @@ +import socket +import threading +from urllib.error import HTTPError +from urllib.parse import parse_qs, urlparse +from urllib.request import urlopen + +import httpx +import pytest + +from iac_code.services.providers.aliyun_oauth import ( + AliyunOAuthCancelledError, + AliyunOAuthClient, + AliyunOAuthError, + AliyunOAuthReloginRequired, + OAuthCallbackServer, + OAuthStsCredentials, + OAuthToken, + build_authorization_url, + generate_code_challenge, + get_oauth_site, + is_epoch_expired, + parse_sts_exchange_response, + run_browser_oauth_flow, +) + + +def _free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_get_cn_site_config(): + site = get_oauth_site("China") + + assert site.site_type == "CN" + assert site.display_name == "China" + assert site.client_id == "4038181954557748008" + assert site.signin_base_url == "https://signin.aliyun.com" + assert site.oauth_base_url == "https://oauth.aliyun.com" + + +def test_get_intl_site_config_accepts_international_alias(): + site = get_oauth_site("International") + + assert site.site_type == "INTL" + assert site.display_name == "International" + assert site.client_id == "4103531455503354461" + assert site.signin_base_url == "https://signin.alibabacloud.com" + assert site.oauth_base_url == "https://oauth.alibabacloud.com" + + +def test_generate_code_challenge_matches_rfc7636_example(): + verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + assert generate_code_challenge(verifier) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + +def test_build_authorization_url_uses_signin_host_and_pkce(): + site = get_oauth_site("CN") + url = build_authorization_url( + site, + redirect_uri="http://127.0.0.1:12345/cli/callback", + state="fake-state", + code_challenge="fake-challenge", + ) + + parsed = urlparse(url) + query = parse_qs(parsed.query) + assert parsed.scheme == "https" + assert parsed.netloc == "signin.aliyun.com" + assert parsed.path == "/oauth2/v1/auth" + assert query == { + "response_type": ["code"], + "client_id": ["4038181954557748008"], + "redirect_uri": ["http://127.0.0.1:12345/cli/callback"], + "state": ["fake-state"], + "code_challenge": ["fake-challenge"], + "code_challenge_method": ["S256"], + } + + +def test_callback_server_accepts_matching_state(): + server = OAuthCallbackServer(ports=(_free_loopback_port(),), timeout_seconds=1) + server.start("expected-state") + + try: + with urlopen("{}?state=expected-state&code=auth-code".format(server.redirect_uri), timeout=1) as response: + body = response.read() + + assert response.status == 200 + assert b"Authorization successful" in body + assert server.wait_for_code() == "auth-code" + finally: + server.close() + + +def test_callback_server_rejects_invalid_state(): + server = OAuthCallbackServer(ports=(_free_loopback_port(),), timeout_seconds=1) + server.start("expected-state") + + try: + with pytest.raises(HTTPError) as exc_info: + urlopen("{}?state=wrong-state&code=auth-code".format(server.redirect_uri), timeout=1) + + assert exc_info.value.code == 400 + with pytest.raises(AliyunOAuthError, match="invalid state"): + server.wait_for_code() + finally: + server.close() + + +def test_callback_server_rejects_missing_code(): + server = OAuthCallbackServer(ports=(_free_loopback_port(),), timeout_seconds=1) + server.start("expected-state") + + try: + with pytest.raises(HTTPError) as exc_info: + urlopen("{}?state=expected-state".format(server.redirect_uri), timeout=1) + + assert exc_info.value.code == 400 + with pytest.raises(AliyunOAuthError, match="code not found"): + server.wait_for_code() + finally: + server.close() + + +def test_callback_server_timeout_includes_assignment_troubleshooting(): + server = OAuthCallbackServer(ports=(_free_loopback_port(),), timeout_seconds=0) + + with pytest.raises(AliyunOAuthError) as exc_info: + server.wait_for_code() + + message = str(exc_info.value) + assert "Timed out waiting for OAuth callback" in message + assert "official-cli" in message + assert "RAM user" in message + assert "RAM role" in message + assert "sign out" in message + assert "OAuth Login (Browser)" in message + + +def test_callback_server_wait_for_code_can_be_cancelled(): + server = OAuthCallbackServer(ports=(_free_loopback_port(),), timeout_seconds=30) + cancel_event = threading.Event() + cancel_event.set() + + with pytest.raises(AliyunOAuthCancelledError, match="cancelled"): + server.wait_for_code(cancel_event=cancel_event) + + +def test_run_browser_oauth_flow_prints_url_opens_browser_and_exchanges_code(): + opened_urls: list[str] = [] + lines: list[str] = [] + + class FakeServer: + redirect_uri = "http://127.0.0.1:12345/cli/callback" + + def start(self, expected_state: str) -> None: + assert expected_state + + def wait_for_code(self) -> str: + return "auth-code" + + def close(self) -> None: + pass + + class FakeClient: + def exchange_code_for_token( + self, + code: str, + redirect_uri: str, + code_verifier: str, + now: int | None = None, + ) -> OAuthToken: + assert code == "auth-code" + assert redirect_uri == FakeServer.redirect_uri + assert code_verifier + assert now == 1000 + return OAuthToken( + access_token="fake-access", + refresh_token="fake-refresh", + access_token_expire=4600, + ) + + def browser_opener(url: str) -> bool: + opened_urls.append(url) + return True + + token = run_browser_oauth_flow( + "CN", + oauth_client=FakeClient(), + browser_opener=browser_opener, + callback_server_factory=FakeServer, + writer=lines.append, + now=1000, + ) + + assert token.access_token == "fake-access" + assert token.refresh_token == "fake-refresh" + assert opened_urls + assert parse_qs(urlparse(opened_urls[0]).query)["redirect_uri"] == [FakeServer.redirect_uri] + assert lines[0] == "" + assert lines[1] == " Waiting for browser authorization" + assert lines[2].startswith(" 1. ") + assert lines[3].startswith(" 2. ") + assert lines[4].startswith(" 3. ") + assert lines[5].startswith(" 4. ") + assert "official-cli" in lines[2] + assert "RAM user" in lines[3] + assert "RAM role" in lines[3] + assert "User groups are not supported" in lines[3] + assert "Press Esc" in lines[6] + assert lines[-2] == " Open in your browser:" + assert lines[-1].startswith(" https://signin.aliyun.com/oauth2/v1/auth?") + assert not any("SignIn url" in line for line in lines) + + +def test_run_browser_oauth_flow_passes_cancel_event_to_callback_wait(): + cancel_event = threading.Event() + + class FakeServer: + redirect_uri = "http://127.0.0.1:12345/cli/callback" + + def start(self, expected_state: str) -> None: + assert expected_state + + def wait_for_code(self, *, cancel_event: threading.Event | None = None) -> str: + assert cancel_event is not None + assert cancel_event is expected_cancel_event + return "auth-code" + + def close(self) -> None: + pass + + expected_cancel_event = cancel_event + + class FakeClient: + def exchange_code_for_token( + self, + code: str, + redirect_uri: str, + code_verifier: str, + now: int | None = None, + ) -> OAuthToken: + return OAuthToken( + access_token="fake-access", + refresh_token="fake-refresh", + access_token_expire=4600, + ) + + token = run_browser_oauth_flow( + "CN", + oauth_client=FakeClient(), + browser_opener=lambda url: True, + callback_server_factory=FakeServer, + writer=lambda line: None, + cancel_event=cancel_event, + ) + + assert token.access_token == "fake-access" + + +def test_run_browser_oauth_flow_continues_when_browser_open_raises(): + class FakeServer: + redirect_uri = "http://127.0.0.1:12345/cli/callback" + + def start(self, expected_state: str) -> None: + assert expected_state + + def wait_for_code(self) -> str: + return "auth-code" + + def close(self) -> None: + pass + + class FakeClient: + def exchange_code_for_token( + self, + code: str, + redirect_uri: str, + code_verifier: str, + now: int | None = None, + ) -> OAuthToken: + assert code == "auth-code" + assert redirect_uri == FakeServer.redirect_uri + assert code_verifier + return OAuthToken( + access_token="fake-access", + refresh_token="fake-refresh", + access_token_expire=4600, + ) + + def browser_opener(url: str) -> bool: + raise RuntimeError("browser unavailable") + + token = run_browser_oauth_flow( + "CN", + oauth_client=FakeClient(), + browser_opener=browser_opener, + callback_server_factory=FakeServer, + writer=lambda line: None, + ) + + assert token.access_token == "fake-access" + + +def test_parse_sts_exchange_response_accepts_camel_case(): + credentials = parse_sts_exchange_response( + { + "accessKeyId": "fake-ak", + "accessKeySecret": "fake-secret", + "securityToken": "fake-sts", + "expiration": "2026-01-01T01:00:00Z", + } + ) + + assert credentials == OAuthStsCredentials( + access_key_id="fake-ak", + access_key_secret="fake-secret", + sts_token="fake-sts", + sts_expiration=1767229200, + ) + + +def test_parse_sts_exchange_response_accepts_pascal_case(): + credentials = parse_sts_exchange_response( + { + "AccessKeyId": "fake-ak", + "AccessKeySecret": "fake-secret", + "SecurityToken": "fake-sts", + "Expiration": "1767229200", + } + ) + + assert credentials == OAuthStsCredentials( + access_key_id="fake-ak", + access_key_secret="fake-secret", + sts_token="fake-sts", + sts_expiration=1767229200, + ) + + +def test_is_epoch_expired_uses_skew(): + assert is_epoch_expired(1061, now=1000, skew_seconds=60) is False + assert is_epoch_expired(1060, now=1000, skew_seconds=60) is True + assert is_epoch_expired(0, now=1000, skew_seconds=60) is True + + +def test_exchange_code_for_token_posts_authorization_code_form(): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "access_token": "fake-access", + "refresh_token": "fake-refresh", + "expires_in": 3600, + "refresh_expires_in": 86400, + }, + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + token = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client).exchange_code_for_token( + code="fake-code", + redirect_uri="http://127.0.0.1:12345/cli/callback", + code_verifier="fake-verifier", + now=1000, + ) + + assert token.access_token == "fake-access" + assert token.refresh_token == "fake-refresh" + assert token.access_token_expire == 4600 + assert token.refresh_token_expire == 87400 + assert requests[0].method == "POST" + assert str(requests[0].url) == "https://oauth.aliyun.com/v1/token" + assert parse_qs(requests[0].content.decode()) == { + "grant_type": ["authorization_code"], + "code": ["fake-code"], + "client_id": ["4038181954557748008"], + "redirect_uri": ["http://127.0.0.1:12345/cli/callback"], + "code_verifier": ["fake-verifier"], + } + + +def test_refresh_access_token_preserves_existing_refresh_token_when_response_omits_it(): + def handler(request: httpx.Request) -> httpx.Response: + assert parse_qs(request.content.decode()) == { + "grant_type": ["refresh_token"], + "refresh_token": ["existing-refresh"], + "client_id": ["4038181954557748008"], + } + return httpx.Response( + 200, + json={ + "access_token": "new-access", + "expires_in": 1800, + }, + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + token = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client).refresh_access_token( + "existing-refresh", + now=2000, + ) + + assert token.access_token == "new-access" + assert token.refresh_token == "existing-refresh" + assert token.access_token_expire == 3800 + assert token.refresh_token_expire == 0 + + +def test_exchange_access_token_for_sts_sends_bearer_header(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["authorization"] == "Bearer fake-access" + assert request.headers["content-type"] == "application/json" + return httpx.Response( + 200, + json={ + "accessKeyId": "fake-ak", + "accessKeySecret": "fake-secret", + "securityToken": "fake-sts", + "expiration": 1767229200, + }, + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + credentials = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client).exchange_access_token_for_sts( + "fake-access" + ) + + assert credentials == OAuthStsCredentials( + access_key_id="fake-ak", + access_key_secret="fake-secret", + sts_token="fake-sts", + sts_expiration=1767229200, + ) + + +def test_permanent_oauth_error_raises_relogin_required(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={"error": "invalid_grant", "error_description": "authorization code expired"}, + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client) + with pytest.raises(AliyunOAuthReloginRequired) as exc_info: + client.refresh_access_token("fake-refresh") + + assert exc_info.value.error_code == "invalid_grant" + assert exc_info.value.status_code == 400 + assert "refresh access token failed with status 400" in str(exc_info.value) + assert "invalid_grant" in str(exc_info.value) + assert "authorization code expired" in str(exc_info.value) + assert "/auth" in str(exc_info.value) + assert "OAuth Login (Browser)" in str(exc_info.value) + assert "fake-refresh" not in str(exc_info.value) + + +def test_refresh_access_token_redacts_refresh_token_from_error_description(): + refresh_token = "refresh-token-secret" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={ + "error": "invalid_grant", + "error_description": "refresh token refresh-token-secret is expired", + }, + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client) + with pytest.raises(AliyunOAuthReloginRequired) as exc_info: + client.refresh_access_token(refresh_token) + + message = str(exc_info.value) + assert "[REDACTED]" in message + assert refresh_token not in message + assert "refresh token" in message + assert "is expired" in message + assert "/auth" in message + assert "OAuth Login (Browser)" in message + + +def test_exchange_access_token_for_sts_redacts_access_token_from_error_description(): + access_token = "access-token-secret" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 500, + json={ + "error": "server_error", + "error_description": "bearer access-token-secret failed validation", + }, + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client) + with pytest.raises(AliyunOAuthError) as exc_info: + client.exchange_access_token_for_sts(access_token) + + message = str(exc_info.value) + assert "[REDACTED]" in message + assert access_token not in message + assert "bearer" in message + assert "failed validation" in message + + +def test_non_permanent_oauth_error_raises_oauth_error(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 500, + json={"error": "server_error", "error_description": "temporary outage"}, + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client) + with pytest.raises(AliyunOAuthError) as exc_info: + client.exchange_access_token_for_sts("fake-access") + + assert not isinstance(exc_info.value, AliyunOAuthReloginRequired) + assert exc_info.value.error_code == "server_error" + assert exc_info.value.status_code == 500 + assert "exchange access token for STS failed with status 500" in str(exc_info.value) + assert "server_error" in str(exc_info.value) + assert "temporary outage" in str(exc_info.value) + assert "fake-access" not in str(exc_info.value) + + +def test_exchange_code_for_token_wraps_http_error_and_redacts_sensitive_values(): + auth_code = "auth-code-secret" + code_verifier = "verifier-secret" + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError( + "connection failed for auth-code-secret with verifier-secret", + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client) + with pytest.raises(AliyunOAuthError) as exc_info: + client.exchange_code_for_token( + code=auth_code, + redirect_uri="http://127.0.0.1:12345/cli/callback", + code_verifier=code_verifier, + ) + + message = str(exc_info.value) + assert "exchange authorization code for token request failed" in message + assert "[REDACTED]" in message + assert auth_code not in message + assert code_verifier not in message + + +def test_refresh_access_token_wraps_http_error_and_redacts_refresh_token(): + refresh_token = "refresh-token-secret" + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException( + "timeout while using refresh-token-secret", + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client) + with pytest.raises(AliyunOAuthError) as exc_info: + client.refresh_access_token(refresh_token) + + message = str(exc_info.value) + assert "refresh access token request failed" in message + assert "[REDACTED]" in message + assert refresh_token not in message + + +def test_exchange_access_token_for_sts_wraps_http_error_and_redacts_access_token(): + access_token = "access-token-secret" + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError( + "connection failed for access-token-secret", + request=request, + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http_client: + client = AliyunOAuthClient(get_oauth_site("CN"), http_client=http_client) + with pytest.raises(AliyunOAuthError) as exc_info: + client.exchange_access_token_for_sts(access_token) + + message = str(exc_info.value) + assert "exchange access token for STS request failed" in message + assert "[REDACTED]" in message + assert access_token not in message diff --git a/tests/services/test_agent_factory.py b/tests/services/test_agent_factory.py index 91755ab..c97e031 100644 --- a/tests/services/test_agent_factory.py +++ b/tests/services/test_agent_factory.py @@ -79,3 +79,54 @@ def test_create_agent_runtime_auto_session_id(tmp_path, monkeypatch) -> None: assert runtime.session_id is not None assert len(runtime.session_id) == 8 # uuid4()[:8] + + +def test_create_agent_runtime_respects_disabled_skills(tmp_path, monkeypatch) -> None: + from iac_code.skills.frontmatter import SkillFrontmatter + from iac_code.skills.skill_definition import SkillDefinition + from iac_code.types.skill_source import SkillSource + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + + enabled_skill = SkillDefinition( + name="enabled-skill", + description="Enabled skill", + frontmatter=SkillFrontmatter(description="Enabled skill", auto_trigger={"script": "auto_trigger.py"}), + content="Enabled body", + source=SkillSource.PROJECT, + ) + disabled_skill = SkillDefinition( + name="disabled-skill", + description="Disabled skill", + frontmatter=SkillFrontmatter(description="Disabled skill", auto_trigger={"script": "auto_trigger.py"}), + content="Disabled body", + source=SkillSource.PROJECT, + ) + + monkeypatch.setattr( + "iac_code.skills.discovery.discover_all_skills", + lambda cwd: [enabled_skill, disabled_skill], + ) + monkeypatch.setattr("iac_code.skills.settings.load_disabled_skills", lambda: {"disabled-skill"}) + + captured_listing = {} + + def fake_build_skill_listing(commands): + captured_listing["names"] = [command.name for command in commands] + return "skill listing" + + monkeypatch.setattr("iac_code.skills.listing.build_skill_listing", fake_build_skill_listing) + + runtime = create_agent_runtime( + AgentFactoryOptions(model="qwen3.6-plus", session_id="skill-runtime", cwd=str(tmp_path)) + ) + + assert runtime.command_registry.get("enabled-skill") is not None + assert runtime.command_registry.get("disabled-skill") is None + assert captured_listing["names"] == ["enabled-skill"] + assert [command.name for command in runtime.agent_loop._auto_trigger_skills] == ["enabled-skill"] + + skill_tool = runtime.tool_registry.get("skill") + assert skill_tool is not None + assert "disabled-skill" in skill_tool._disabled_skills diff --git a/tests/services/test_session_index.py b/tests/services/test_session_index.py index 0fd7f69..ee8f152 100644 --- a/tests/services/test_session_index.py +++ b/tests/services/test_session_index.py @@ -14,7 +14,10 @@ extract_last_json_string_field, read_lite_metadata, ) +from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SessionMetadata, write_session_metadata from iac_code.services.session_storage import SessionStorage +from iac_code.services.session_usage import SessionUsageStore +from iac_code.types.stream_events import Usage # --------------------------------------------------------------------------- # Field extraction helpers @@ -108,6 +111,77 @@ def test_list_all_projects_includes_everything(self, tmp_path): ids = {e.session_id for e in index.list_all_projects()} assert ids == {"id-a", "id-b"} + def test_list_all_projects_includes_legacy_sessions(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + legacy_path = storage.legacy_session_path("/legacy", "legacy-id") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old","cwd":"/legacy"}\n', encoding="utf-8") + + index = SessionIndex(projects_dir=tmp_path) + entries = index.list_all_projects() + + assert [(e.session_id, e.cwd, e.title) for e in entries] == [("legacy-id", "/legacy", "old")] + + def test_directory_session_metadata_name_takes_precedence(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/p", "named", Message(role="user", content="first prompt"), git_branch=None) + storage.rename_session("/p", "named", "deploy-prod", git_branch=None) + + entry = SessionIndex(projects_dir=tmp_path).list_for_cwd("/p")[0] + + assert entry.session_id == "named" + assert entry.name == "deploy-prod" + assert entry.title == "deploy-prod" + assert entry.auto_title == "first prompt" + assert entry.is_legacy is False + + def test_legacy_session_still_indexed(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + legacy_path = storage.legacy_session_path("/legacy", "legacy") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old","cwd":"/legacy"}\n', encoding="utf-8") + + entry = SessionIndex(projects_dir=tmp_path).list_for_cwd("/legacy")[0] + + assert entry.session_id == "legacy" + assert entry.name is None + assert entry.title == "old" + assert entry.is_legacy is True + + def test_directory_session_ignores_stale_metadata_session_id(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/p", "actual", Message(role="user", content="first prompt"), git_branch=None) + storage.rename_session("/p", "actual", "deploy-prod", git_branch=None) + write_session_metadata( + storage.session_dir("/p", "actual"), + SessionMetadata(session_id="stale", name="copied-name", cwd="/p", git_branch=None), + ) + + entry = SessionIndex(projects_dir=tmp_path).list_for_cwd("/p")[0] + + assert entry.session_id == "actual" + assert entry.name is None + assert entry.title == "first prompt" + assert entry.auto_title == "first prompt" + assert entry.is_legacy is False + + def test_duplicate_legacy_and_directory_session_id_prefers_directory(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + legacy_path = storage.legacy_session_path("/p", "same") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"/p"}\n', encoding="utf-8") + + session_dir = storage.session_dir("/p", "same") + session_dir.mkdir(parents=True, exist_ok=True) + (session_dir / SESSION_JSONL_FILENAME).write_text( + '{"role":"user","content":"directory","cwd":"/p"}\n', + encoding="utf-8", + ) + + entries = SessionIndex(projects_dir=tmp_path).list_for_cwd("/p") + + assert [(entry.session_id, entry.title, entry.is_legacy) for entry in entries] == [("same", "directory", False)] + def test_list_sorted_by_mtime_desc(self, tmp_path): storage = SessionStorage(projects_dir=tmp_path) storage.append("/p", "older", Message(role="user", content="o"), git_branch=None) @@ -143,3 +217,16 @@ def test_find_by_id_or_prefix_exact_overrides_ambiguity(self, tmp_path): index = SessionIndex(projects_dir=tmp_path) entry = index.find_by_id_or_prefix("abc") assert entry is not None and entry.session_id == "abc" + + def test_ignores_usage_sidecars(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + usage_store = SessionUsageStore(projects_dir=tmp_path) + storage.append("/p", "abc", Message(role="user", content="x"), git_branch=None) + usage_store.append("/p", "abc", Usage(input_tokens=10, output_tokens=5), provider="dashscope", model="qwen") + + index = SessionIndex(projects_dir=tmp_path) + + assert [entry.session_id for entry in index.list_for_cwd("/p")] == ["abc"] + assert {entry.session_id for entry in index.list_all_projects()} == {"abc"} + assert index.find_by_id_or_prefix("abc").session_id == "abc" + assert index.find_by_id_or_prefix("abc.usage") is None diff --git a/tests/services/test_session_metadata.py b/tests/services/test_session_metadata.py new file mode 100644 index 0000000..3fc3827 --- /dev/null +++ b/tests/services/test_session_metadata.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json + +import pytest + +from iac_code.services.session_metadata import ( + SESSION_METADATA_FILENAME, + SESSION_NAME_PATTERN, + SessionMetadata, + normalize_session_name, + read_session_metadata, + validate_session_name, + write_session_metadata, +) + + +@pytest.mark.parametrize("name", ["deploy", "deploy-prod", "prod_1", "release.2026", "A" * 200]) +def test_validate_session_name_accepts_slug_names(name: str) -> None: + assert validate_session_name(name) == name + + +@pytest.mark.parametrize("name", ["", " ", "deploy prod", "中文", "-bad", ".bad", "_bad", "A" * 201]) +def test_validate_session_name_rejects_invalid_names(name: str) -> None: + with pytest.raises(ValueError): + validate_session_name(name) + + +def test_normalize_session_name_strips_then_validates() -> None: + assert normalize_session_name(" deploy-prod ") == "deploy-prod" + + +def test_session_name_pattern_is_exported() -> None: + assert SESSION_NAME_PATTERN.pattern == r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$" + + +@pytest.mark.parametrize("schema_version", ["1", {}]) +def test_metadata_from_dict_defaults_non_int_schema_version(schema_version: object) -> None: + metadata = SessionMetadata.from_dict({"session_id": "abc123", "schema_version": schema_version}) + + assert metadata is not None + assert metadata.schema_version == 1 + + +def test_metadata_from_dict_defaults_bool_schema_version() -> None: + metadata = SessionMetadata.from_dict({"session_id": "abc123", "schema_version": True}) + + assert metadata is not None + assert type(metadata.schema_version) is int + assert metadata.schema_version == 1 + + +def test_metadata_round_trip(tmp_path) -> None: + session_dir = tmp_path / "abc123" + metadata = SessionMetadata( + session_id="abc123", + name="deploy-prod", + cwd="/project", + git_branch="main", + created_at="2026-06-02T12:00:00Z", + updated_at="2026-06-02T12:01:00Z", + ) + + write_session_metadata(session_dir, metadata) + + raw = json.loads((session_dir / SESSION_METADATA_FILENAME).read_text(encoding="utf-8")) + assert raw["schema_version"] == 1 + assert raw["name"] == "deploy-prod" + assert read_session_metadata(session_dir) == metadata + + +def test_read_session_metadata_ignores_corrupt_json(tmp_path) -> None: + session_dir = tmp_path / "abc123" + session_dir.mkdir() + (session_dir / SESSION_METADATA_FILENAME).write_text("{not-json", encoding="utf-8") + + assert read_session_metadata(session_dir) is None diff --git a/tests/services/test_session_resolver.py b/tests/services/test_session_resolver.py new file mode 100644 index 0000000..499eebb --- /dev/null +++ b/tests/services/test_session_resolver.py @@ -0,0 +1,74 @@ +"""Tests for shared session argument resolution.""" + +from __future__ import annotations + +from iac_code.agent.message import Message +from iac_code.services.session_index import SessionIndex +from iac_code.services.session_resolver import ResolutionStatus, resolve_session_argument +from iac_code.services.session_storage import SessionStorage + + +def test_resolves_current_project_name(tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/p", "session-a", Message(role="user", content="hello"), git_branch=None) + storage.rename_session("/p", "session-a", "deploy-prod", git_branch=None) + + result = resolve_session_argument(SessionIndex(projects_dir=tmp_path), "/p", "deploy-prod") + + assert result.status is ResolutionStatus.FOUND + assert result.entry is not None + assert result.entry.session_id == "session-a" + + +def test_current_project_id_wins_over_cross_project_name(tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/current", "deploy-prod", Message(role="user", content="current id"), git_branch=None) + storage.append("/other", "other-id", Message(role="user", content="other name"), git_branch=None) + storage.rename_session("/other", "other-id", "deploy-prod", git_branch=None) + + result = resolve_session_argument(SessionIndex(projects_dir=tmp_path), "/current", "deploy-prod") + + assert result.status is ResolutionStatus.FOUND + assert result.entry is not None + assert result.entry.session_id == "deploy-prod" + assert result.entry.cwd == "/current" + + +def test_cross_project_duplicate_name_is_ambiguous(tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/a", "session-a", Message(role="user", content="a"), git_branch=None) + storage.append("/b", "session-b", Message(role="user", content="b"), git_branch=None) + storage.rename_session("/a", "session-a", "deploy-prod", git_branch=None) + storage.rename_session("/b", "session-b", "deploy-prod", git_branch=None) + + result = resolve_session_argument(SessionIndex(projects_dir=tmp_path), "/c", "deploy-prod") + + assert result.status is ResolutionStatus.AMBIGUOUS_NAME + assert result.entry is None + assert {entry.session_id for entry in result.candidates} == {"session-a", "session-b"} + + +def test_ambiguous_id_prefix_is_not_found(tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/p", "abc-one", Message(role="user", content="one"), git_branch=None) + storage.append("/p", "abc-two", Message(role="user", content="two"), git_branch=None) + + result = resolve_session_argument(SessionIndex(projects_dir=tmp_path), "/p", "abc") + + assert result.status is ResolutionStatus.NOT_FOUND + assert result.entry is None + assert result.candidates == [] + + +def test_ambiguous_id_prefix_is_not_resolved_as_name(tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/p", "abc-one", Message(role="user", content="one"), git_branch=None) + storage.append("/p", "abc-two", Message(role="user", content="two"), git_branch=None) + storage.append("/p", "named", Message(role="user", content="named"), git_branch=None) + storage.rename_session("/p", "named", "abc", git_branch=None) + + result = resolve_session_argument(SessionIndex(projects_dir=tmp_path), "/p", "abc") + + assert result.status is ResolutionStatus.NOT_FOUND + assert result.entry is None + assert result.candidates == [] diff --git a/tests/services/test_session_storage.py b/tests/services/test_session_storage.py index 8dac0fc..a698026 100644 --- a/tests/services/test_session_storage.py +++ b/tests/services/test_session_storage.py @@ -4,7 +4,10 @@ import pytest from iac_code.agent.message import Message, TextBlock, ToolResultBlock, ToolUseBlock +from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME from iac_code.services.session_storage import SessionStorage +from iac_code.services.session_usage import SessionUsageStore +from iac_code.types.stream_events import Usage CWD = "/tmp/proj-x" @@ -103,7 +106,7 @@ def test_find_session_anywhere(self, storage): assert result is not None cwd, path = result assert cwd == "/tmp/b" - assert path.name == "id-bb.jsonl" + assert path.name == SESSION_JSONL_FILENAME assert storage.find_session_anywhere("missing") is None def test_get_latest_session_anywhere(self, storage): @@ -120,6 +123,18 @@ def test_get_latest_session_anywhere(self, storage): result = storage.get_latest_session_anywhere() assert result == ("/tmp/b", "newer") + def test_cross_project_lookup_ignores_usage_sidecars(self, storage): + import os + + usage_store = SessionUsageStore(projects_dir=storage._projects_dir) + storage.append(CWD, "real", Message(role="user", content="real"), git_branch=None) + usage_store.append(CWD, "real", Usage(input_tokens=10, output_tokens=5), provider="dashscope", model="qwen") + usage_path = usage_store.path_for(CWD, "real") + os.utime(usage_path, (usage_path.stat().st_atime, usage_path.stat().st_mtime + 100)) + + assert storage.find_session_anywhere("real.usage") is None + assert storage.get_latest_session_anywhere() == (CWD, "real") + def test_repair_interrupted_inserts_synthetic_results(self, storage): storage.append( CWD, @@ -141,3 +156,67 @@ def test_repair_interrupted_inserts_synthetic_results(self, storage): repaired = SessionStorage.repair_interrupted(loaded) assert repaired[-1].role == "user" assert any(getattr(b, "is_error", False) for b in repaired[-1].content) + + +def test_new_session_uses_directory_format(storage): + storage.append(CWD, "dir-session", Message(role="user", content="hi"), git_branch="main") + + legacy_path = storage.legacy_session_path(CWD, "dir-session") + session_dir = storage.session_dir(CWD, "dir-session") + + assert session_dir.is_dir() + assert (session_dir / SESSION_JSONL_FILENAME).exists() + assert not legacy_path.exists() + assert storage.load(CWD, "dir-session") == [Message(role="user", content="hi")] + + +def test_existing_legacy_session_stays_legacy_until_rename(storage): + legacy_path = storage.legacy_session_path(CWD, "legacy") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + + storage.append(CWD, "legacy", Message(role="assistant", content="next"), git_branch=None) + + assert legacy_path.exists() + assert not storage.session_dir(CWD, "legacy").exists() + assert [m.role for m in storage.load(CWD, "legacy")] == ["user", "assistant"] + + +def test_rename_legacy_session_migrates_to_directory(storage): + legacy_path = storage.legacy_session_path(CWD, "legacy-rename") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + + result = storage.rename_session(CWD, "legacy-rename", "deploy-prod", git_branch="main") + + session_dir = storage.session_dir(CWD, "legacy-rename") + assert result == "renamed" + assert not legacy_path.exists() + assert (session_dir / SESSION_JSONL_FILENAME).exists() + assert (session_dir / SESSION_METADATA_FILENAME).exists() + assert storage.read_metadata(CWD, "legacy-rename").name == "deploy-prod" + assert storage.load(CWD, "legacy-rename")[0].content == "old" + + +def test_rename_rejects_same_project_duplicate_name(storage): + storage.append(CWD, "one", Message(role="user", content="one"), git_branch=None) + storage.append(CWD, "two", Message(role="user", content="two"), git_branch=None) + storage.rename_session(CWD, "one", "deploy-prod", git_branch=None) + + with pytest.raises(ValueError, match="already exists"): + storage.rename_session(CWD, "two", "deploy-prod", git_branch=None) + + +def test_rename_allows_same_name_in_different_projects(storage): + storage.append("/p1", "one", Message(role="user", content="one"), git_branch=None) + storage.append("/p2", "two", Message(role="user", content="two"), git_branch=None) + + assert storage.rename_session("/p1", "one", "deploy-prod", git_branch=None) == "renamed" + assert storage.rename_session("/p2", "two", "deploy-prod", git_branch=None) == "renamed" + + +def test_rename_to_existing_name_is_noop(storage): + storage.append(CWD, "same", Message(role="user", content="one"), git_branch=None) + storage.rename_session(CWD, "same", "deploy-prod", git_branch=None) + + assert storage.rename_session(CWD, "same", "deploy-prod", git_branch=None) == "unchanged" diff --git a/tests/services/test_session_usage.py b/tests/services/test_session_usage.py new file mode 100644 index 0000000..a507f30 --- /dev/null +++ b/tests/services/test_session_usage.py @@ -0,0 +1,124 @@ +import json + +from iac_code.services.session_usage import SessionUsageStore, SessionUsageTotals +from iac_code.types.stream_events import Usage + +CWD = "/tmp/status-project" + + +def test_totals_adds_usage_and_tracks_record_count() -> None: + totals = SessionUsageTotals() + + totals.add(Usage(input_tokens=10, output_tokens=5, cache_read_input_tokens=3, cache_creation_input_tokens=2)) + totals.add(Usage(input_tokens=7, output_tokens=1)) + + assert totals.input_tokens == 17 + assert totals.output_tokens == 6 + assert totals.cache_read_input_tokens == 3 + assert totals.cache_creation_input_tokens == 2 + assert totals.total_tokens == 23 + assert totals.recorded_events == 2 + assert totals.has_recorded_usage is True + + +def test_all_zero_usage_is_not_recorded(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + + recorded = store.append(CWD, "s1", Usage(), provider="dashscope", model="qwen3.7-max") + + assert recorded is False + assert store.load(CWD, "s1").has_recorded_usage is False + assert not store.path_for(CWD, "s1").exists() + + +def test_append_and_load_round_trip(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + + assert store.append(CWD, "s2", Usage(input_tokens=12, output_tokens=3), provider="dashscope", model="qwen3.7-max") + assert store.append( + CWD, + "s2", + Usage(input_tokens=5, output_tokens=2, cache_read_input_tokens=4, cache_creation_input_tokens=1), + provider="dashscope", + model="qwen3.7-max", + ) + + totals = store.load(CWD, "s2") + assert totals.input_tokens == 17 + assert totals.output_tokens == 5 + assert totals.cache_read_input_tokens == 4 + assert totals.cache_creation_input_tokens == 1 + assert totals.total_tokens == 22 + assert totals.recorded_events == 2 + + lines = store.path_for(CWD, "s2").read_text(encoding="utf-8").splitlines() + row = json.loads(lines[0]) + assert row["type"] == "usage" + assert row["version"] == 1 + assert row["provider"] == "dashscope" + assert row["model"] == "qwen3.7-max" + assert row["created_at"].endswith("Z") + + +def test_load_skips_corrupt_and_unrelated_rows(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + path = store.path_for(CWD, "s3") + path.parent.mkdir(parents=True) + path.write_text( + "\n".join( + [ + '{"type":"usage","version":1,"input_tokens":4,"output_tokens":6,' + '"cache_read_input_tokens":1,"cache_creation_input_tokens":0}', + "not json", + '{"type":"last-prompt","last_prompt":"ignored"}', + '{"type":"usage","version":1,"input_tokens":3,"output_tokens":2}', + ] + ) + + "\n", + encoding="utf-8", + ) + + totals = store.load(CWD, "s3") + + assert totals.input_tokens == 7 + assert totals.output_tokens == 8 + assert totals.cache_read_input_tokens == 1 + assert totals.cache_creation_input_tokens == 0 + assert totals.total_tokens == 15 + assert totals.recorded_events == 2 + + +def test_path_for_uses_directory_session_layout(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + + path = store.path_for(CWD, "s4") + + assert path == tmp_path / "-tmp-status-project" / "s4" / "usage.jsonl" + + +def test_load_reads_new_and_legacy_sidecars(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + new_path = store.path_for(CWD, "s5") + legacy_path = store.legacy_path_for(CWD, "s5") + new_path.parent.mkdir(parents=True) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + + new_path.write_text( + '{"type":"usage","version":1,"input_tokens":4,"output_tokens":6,' + '"cache_read_input_tokens":1,"cache_creation_input_tokens":0}\n', + encoding="utf-8", + ) + legacy_path.write_text( + '{"type":"usage","version":1,"input_tokens":3,"output_tokens":2,' + '"cache_read_input_tokens":5,"cache_creation_input_tokens":7}\n', + encoding="utf-8", + ) + + totals = store.load(CWD, "s5") + + assert totals.input_tokens == 7 + assert totals.output_tokens == 8 + assert totals.cache_read_input_tokens == 6 + assert totals.cache_creation_input_tokens == 7 + assert totals.total_tokens == 15 + assert totals.recorded_events == 2 diff --git a/tests/services/test_update_checker.py b/tests/services/test_update_checker.py index adcf87e..5f56a05 100644 --- a/tests/services/test_update_checker.py +++ b/tests/services/test_update_checker.py @@ -392,7 +392,13 @@ def fail_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fail_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is not None assert state.pending.version == "0.4.0" @@ -433,7 +439,13 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fake_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is not None assert state.pending.version == "0.3.2" @@ -465,7 +477,13 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fake_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is not None assert state.pending.version == "100.0.0" @@ -502,7 +520,13 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fake_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is None assert state.last_successful_check_at is None @@ -520,6 +544,7 @@ def failed_run(*args, **kwargs): check_for_updates_once( path=path, + current_version="0.3.0", http_client=first_http_client, now=1000.0, python_executable="/python", @@ -540,6 +565,7 @@ def fail_run(*args, **kwargs): second_state = check_for_updates_once( path=path, + current_version="0.3.0", http_client=second_http_client, now=1001.0, python_executable="/python", @@ -600,7 +626,13 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fake_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is None assert state.last_successful_check_at == 1000.0 @@ -655,7 +687,13 @@ def fail_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fail_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is not None assert state.pending.version == "0.5.0" @@ -837,7 +875,13 @@ def fail_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fail_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is not None assert state.pending.version == "0.3.2" @@ -1105,7 +1149,13 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fake_run) - state = check_for_updates_once(path=path, http_client=http_client, now=1000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=1000.0, + python_executable="/python", + ) assert state.pending is not None assert state.pending.version == "0.4.0" @@ -1131,7 +1181,13 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update_checker.subprocess, "run", fake_run) - state = check_for_updates_once(path=path, http_client=http_client, now=8000.0, python_executable="/python") + state = check_for_updates_once( + path=path, + current_version="0.3.0", + http_client=http_client, + now=8000.0, + python_executable="/python", + ) assert state.pending == PendingUpdate(**_pending_update_data()) assert state.last_successful_check_at == 500.0 diff --git a/tests/skills/test_management.py b/tests/skills/test_management.py new file mode 100644 index 0000000..268e90d --- /dev/null +++ b/tests/skills/test_management.py @@ -0,0 +1,40 @@ +"""Tests for skill management state construction.""" + +from __future__ import annotations + +from iac_code.skills.frontmatter import SkillFrontmatter +from iac_code.skills.management import build_skill_management_state +from iac_code.skills.skill_definition import SkillDefinition +from iac_code.types.skill_source import SkillSource + + +def _skill(name: str, source: SkillSource, *, content: str = "abcd") -> SkillDefinition: + return SkillDefinition( + name=name, + description=f"{name} description", + frontmatter=SkillFrontmatter(description=f"{name} description"), + content=content, + content_length=len(content), + source=source, + skill_root=f"/repo/{name}", + ) + + +def test_disabled_non_bundled_skills_are_split_out(): + state = build_skill_management_state( + [_skill("team-review", SkillSource.PROJECT), _skill("iac-aliyun", SkillSource.BUNDLED)], + {"team-review"}, + ) + + assert [cmd.name for cmd in state.enabled_commands] == ["iac-aliyun"] + assert state.disabled_commands["team-review"].name == "team-review" + assert {item.name: item.enabled for item in state.items} == {"team-review": False, "iac-aliyun": True} + + +def test_bundled_skills_ignore_disabled_setting_and_are_locked(): + state = build_skill_management_state([_skill("iac-aliyun", SkillSource.BUNDLED)], {"iac-aliyun"}) + + assert [cmd.name for cmd in state.enabled_commands] == ["iac-aliyun"] + assert state.disabled_commands == {} + assert state.items[0].enabled is True + assert state.items[0].locked is True diff --git a/tests/skills/test_settings.py b/tests/skills/test_settings.py new file mode 100644 index 0000000..f776709 --- /dev/null +++ b/tests/skills/test_settings.py @@ -0,0 +1,50 @@ +"""Tests for skill settings persistence.""" + +from __future__ import annotations + +import yaml + +from iac_code.skills.settings import load_disabled_skills, save_disabled_skills + + +def test_load_disabled_skills_missing_file(monkeypatch, tmp_path): + settings = tmp_path / "settings.yml" + monkeypatch.setattr("iac_code.skills.settings.get_settings_path", lambda: settings) + + assert load_disabled_skills() == set() + + +def test_load_disabled_skills_ignores_invalid_yaml(monkeypatch, tmp_path): + settings = tmp_path / "settings.yml" + settings.write_text("[", encoding="utf-8") + monkeypatch.setattr("iac_code.skills.settings.get_settings_path", lambda: settings) + + assert load_disabled_skills() == set() + + +def test_load_disabled_skills_ignores_non_list(monkeypatch, tmp_path): + settings = tmp_path / "settings.yml" + settings.write_text(yaml.safe_dump({"disabled_skills": "demo"}), encoding="utf-8") + monkeypatch.setattr("iac_code.skills.settings.get_settings_path", lambda: settings) + + assert load_disabled_skills() == set() + + +def test_load_disabled_skills_normalizes_strings(monkeypatch, tmp_path): + settings = tmp_path / "settings.yml" + settings.write_text(yaml.safe_dump({"disabled_skills": [" Demo ", "", 7, "Other"]}), encoding="utf-8") + monkeypatch.setattr("iac_code.skills.settings.get_settings_path", lambda: settings) + + assert load_disabled_skills() == {"demo", "other"} + + +def test_save_disabled_skills_preserves_other_settings_and_excludes_locked(monkeypatch, tmp_path): + settings = tmp_path / "settings.yml" + settings.write_text(yaml.safe_dump({"activeProvider": "dashscope"}), encoding="utf-8") + monkeypatch.setattr("iac_code.skills.settings.get_settings_path", lambda: settings) + + save_disabled_skills({"beta", "alpha", "iac-aliyun"}, locked_skill_names={"iac-aliyun"}) + + data = yaml.safe_load(settings.read_text(encoding="utf-8")) + assert data["activeProvider"] == "dashscope" + assert data["disabled_skills"] == ["alpha", "beta"] diff --git a/tests/skills/test_skill_tool.py b/tests/skills/test_skill_tool.py index 116653b..4722a57 100644 --- a/tests/skills/test_skill_tool.py +++ b/tests/skills/test_skill_tool.py @@ -77,6 +77,17 @@ async def test_execute_not_found(self): assert result.is_error assert "not found" in result.content.lower() + @pytest.mark.asyncio + async def test_execute_disabled_skill_returns_disabled_error(self): + registry = CommandRegistry() + tool = SkillTool(command_registry=registry, disabled_skills={"demo": object()}) + + result = await tool.execute(tool_input={"skill": "demo"}, context=ToolContext()) + + assert result.is_error + assert "disabled" in result.content.lower() + assert "/skills" in result.content + @pytest.mark.asyncio async def test_execute_with_args(self): registry = _make_registry_with_skill(content="Process $ARGUMENTS") @@ -237,6 +248,16 @@ async def test_nonexistent_skill_denied(self): result = await tool.check_permissions({"skill": "nonexistent"}) assert result.behavior == "deny" + @pytest.mark.asyncio + async def test_disabled_skill_denied(self): + registry = CommandRegistry() + tool = SkillTool(command_registry=registry, disabled_skills={"demo": object()}) + + result = await tool.check_permissions({"skill": "demo"}) + + assert result.behavior == "deny" + assert "disabled" in result.message.lower() + @pytest.mark.asyncio async def test_permission_message_includes_project_source(self): registry = _make_registry_with_skill(source=SkillSource.PROJECT, allowed_tools=["bash(*)"]) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 92d7336..42afa8f 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -12,7 +12,7 @@ import pytest from babel.messages.pofile import read_po -from iac_code.i18n import DEFAULT_LANGUAGE +from iac_code.i18n import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES # Get the project root directory PROJECT_ROOT = Path(__file__).parent.parent @@ -20,6 +20,23 @@ POT_FILE = I18N_DIR / "messages.pot" LOCALES_DIR = I18N_DIR / "locales" +MEMORY_COMMAND_MSGIDS = { + "Usage: /memory [|search |delete |help]", + "Saved memories:", + "No memories saved yet.", + "Matching memories:", + "No matching memories.", + "Memory '{name}' not found.", + "Memory '{name}' deleted.", + "Memory manager is unavailable.", + "View and manage persistent memories", + "[|search |delete |help]", + "Search saved memories", + "Delete a saved memory", + "Show memory command help", + "Saved memory", +} + def _get_all_msgids_from_pot(pot_file: Path) -> set[str]: """Extract all msgids from a .pot template file. @@ -142,6 +159,15 @@ def test_all_languages_have_po_files(): assert not missing_po_files, f"Missing .po files for languages: {missing_po_files}" +def test_supported_languages_match_locale_dirs(): + """Verify supported languages are the default language plus locale directories.""" + language_dirs = _discover_language_dirs() + locale_codes = {lang_dir.name for lang_dir in language_dirs} + + assert len(SUPPORTED_LANGUAGES) == 7 + assert set(SUPPORTED_LANGUAGES) == {DEFAULT_LANGUAGE, *locale_codes} + + def test_mo_files_up_to_date(): """Verify that .mo files are compiled and newer than their .po files. @@ -276,6 +302,78 @@ def test_translation_completeness(): pytest.fail("\n".join(error_messages)) +@pytest.mark.skipif(sys.platform == "win32", reason="messages.pot not generated on Windows") +def test_memory_command_translations_are_complete(): + """Verify /memory-specific strings are translated, not copied as placeholders.""" + assert POT_FILE.exists(), f"POT file not found at {POT_FILE}" + pot_msgids = _get_all_msgids_from_pot(POT_FILE) + missing_from_pot = MEMORY_COMMAND_MSGIDS - pot_msgids + assert not missing_from_pot, f"/memory msgids missing from messages.pot: {sorted(missing_from_pot)}" + + language_dirs = _discover_language_dirs() + assert language_dirs, "No language directories found" + + errors = [] + for lang_dir in language_dirs: + po_file = lang_dir / "LC_MESSAGES" / "messages.po" + translations = _get_all_translations_from_po(po_file) + for msgid in sorted(MEMORY_COMMAND_MSGIDS): + msgstr = translations.get(msgid, "").strip() + if not msgstr: + errors.append(f"{lang_dir.name}: missing translation for {msgid!r}") + elif msgstr == msgid: + errors.append(f"{lang_dir.name}: untranslated placeholder for {msgid!r}") + + assert not errors, "\n".join(errors) + + +@pytest.mark.skipif(sys.platform == "win32", reason="messages.pot not generated on Windows") +def test_aliyun_credential_labels_are_translatable(): + """Aliyun auth menu labels come from data tables, so guard against dynamic gettext misses.""" + from iac_code.services.providers.aliyun import MODE_DISPLAY_NAMES, MODE_FIELDS + + required_msgids = set(MODE_DISPLAY_NAMES.values()) + for mode_fields in MODE_FIELDS.values(): + required_msgids.update(label for _field_name, label, _sensitive in mode_fields) + + pot_msgids = _get_all_msgids_from_pot(POT_FILE) + missing_from_pot = sorted(required_msgids - pot_msgids) + assert not missing_from_pot, "Aliyun credential labels missing from messages.pot: {}".format(missing_from_pot) + + missing_or_empty_by_language: dict[str, list[str]] = {} + for lang_dir in _discover_language_dirs(): + translations = _get_all_translations_from_po(lang_dir / "LC_MESSAGES" / "messages.po") + missing_or_empty = sorted(msgid for msgid in required_msgids if not translations.get(msgid)) + if missing_or_empty: + missing_or_empty_by_language[lang_dir.name] = missing_or_empty + + assert not missing_or_empty_by_language, "Aliyun credential labels missing translations: {}".format( + missing_or_empty_by_language + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="messages.pot not generated on Windows") +def test_session_name_error_messages_are_translated(): + """Session rename validation errors are user-facing and must not stay English-only.""" + required_msgids = { + "Session name must match {pattern}", + "Session name already exists in this project: {name}", + } + language_dirs = _discover_language_dirs() + if not language_dirs: + pytest.skip("No language directories found") + + untranslated: list[str] = [] + for lang_dir in language_dirs: + translations = _get_all_translations_from_po(lang_dir / "LC_MESSAGES" / "messages.po") + for msgid in sorted(required_msgids): + msgstr = translations.get(msgid, "") + if not msgstr.strip() or msgstr == msgid: + untranslated.append(f"{lang_dir.name}: {msgid!r}") + + assert not untranslated + + class TestDetectWindowsUILanguage: """_detect_windows_ui_language wraps GetUserDefaultLocaleName via ctypes.""" diff --git a/tests/tools/cloud/aliyun/test_aliyun_api.py b/tests/tools/cloud/aliyun/test_aliyun_api.py index e8c897a..af4cd38 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api.py @@ -8,6 +8,7 @@ import pytest from iac_code.services.providers.aliyun import AliyunCredential +from iac_code.services.providers.aliyun_oauth import AliyunOAuthError, AliyunOAuthReloginRequired from iac_code.tools.base import ToolContext from iac_code.tools.cloud.aliyun import aliyun_api as aliyun_api_module from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi @@ -424,6 +425,122 @@ async def test_params_serialized_in_request(self, api: AliyunApi, context: ToolC assert request.query["PageSize"] == "10" assert request.query["DryRun"] == "true" + @pytest.mark.asyncio + async def test_execute_refreshes_oauth_before_endpoint_discovery( + self, api: AliyunApi, context: ToolContext + ) -> None: + oauth_cred = AliyunCredential( + mode="OAuth", + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + region_id="cn-hangzhou", + oauth_access_token="access-token", + oauth_refresh_token="refresh-token", + ) + refreshed = AliyunCredential( + mode="OAuth", + access_key_id="new-ak", + access_key_secret="new-sk", + sts_token="new-sts", + region_id="cn-hangzhou", + oauth_access_token="access-token", + oauth_refresh_token="refresh-token", + ) + mock_client = MagicMock() + mock_client.call_api.side_effect = [ + {"body": {"Endpoints": {"Endpoint": [{"Type": "openAPI", "Endpoint": "custom.aliyuncs.com"}]}}}, + {"body": {"Instances": []}}, + ] + + with ( + patch("iac_code.tools.cloud.aliyun.aliyun_api.CloudCredentials") as cloud_credentials, + patch.object( + aliyun_api_module.AliyunCredentials, "refresh_oauth_if_needed", return_value=refreshed + ) as refresh, + patch("iac_code.tools.cloud.aliyun.aliyun_api.OpenApiClient", return_value=mock_client) as client_cls, + ): + cloud_credentials.return_value.get_provider.return_value = oauth_cred + result = await api.execute( + tool_input={ + "product": "custom-svc", + "action": "DescribeInstances", + "version": "2023-01-01", + "region_id": "cn-hangzhou", + }, + context=context, + ) + + assert result.is_error is False + refresh.assert_called_once_with(oauth_cred) + discovery_config = client_cls.call_args_list[0].args[0] + call_config = client_cls.call_args_list[1].args[0] + assert discovery_config.access_key_id == "new-ak" + assert discovery_config.security_token == "new-sts" + assert call_config.access_key_id == "new-ak" + assert call_config.security_token == "new-sts" + + @pytest.mark.asyncio + async def test_execute_returns_relogin_error_when_oauth_refresh_requires_login( + self, api: AliyunApi, context: ToolContext + ) -> None: + oauth_cred = AliyunCredential( + mode="OAuth", + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + region_id="cn-hangzhou", + oauth_access_token="access-token", + oauth_refresh_token="refresh-token", + ) + + with ( + patch("iac_code.tools.cloud.aliyun.aliyun_api.CloudCredentials") as cloud_credentials, + patch.object( + aliyun_api_module.AliyunCredentials, + "refresh_oauth_if_needed", + side_effect=AliyunOAuthReloginRequired("Run /auth and choose OAuth Login (Browser)."), + ), + ): + cloud_credentials.return_value.get_provider.return_value = oauth_cred + result = await api.execute( + tool_input={"product": "ecs", "action": "DescribeInstances", "region_id": "cn-hangzhou"}, + context=context, + ) + + assert result.is_error is True + assert "/auth" in result.content + assert "OAuth Login (Browser)" in result.content + + @pytest.mark.asyncio + async def test_execute_returns_oauth_error_when_refresh_fails(self, api: AliyunApi, context: ToolContext) -> None: + oauth_cred = AliyunCredential( + mode="OAuth", + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + region_id="cn-hangzhou", + oauth_access_token="access-token", + oauth_refresh_token="refresh-token", + ) + + with ( + patch("iac_code.tools.cloud.aliyun.aliyun_api.CloudCredentials") as cloud_credentials, + patch.object( + aliyun_api_module.AliyunCredentials, + "refresh_oauth_if_needed", + side_effect=AliyunOAuthError("temporary oauth refresh failure"), + ), + ): + cloud_credentials.return_value.get_provider.return_value = oauth_cred + result = await api.execute( + tool_input={"product": "ecs", "action": "DescribeInstances", "region_id": "cn-hangzhou"}, + context=context, + ) + + assert result.is_error is True + assert "temporary oauth refresh failure" in result.content + class TestAliyunApiProductNormalization: @pytest.mark.asyncio @@ -527,6 +644,22 @@ def test_sts_token_mode(self) -> None: assert config.region_id == "cn-beijing" assert config.user_agent and config.user_agent.startswith("iac-code/") + def test_oauth_mode_builds_sts_config(self) -> None: + credential = AliyunCredential( + mode="OAuth", + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + region_id="cn-hangzhou", + ) + config = AliyunApi._build_config(credential, "ecs.aliyuncs.com", "cn-hangzhou") + assert config.access_key_id == "tmp-ak" + assert config.access_key_secret == "tmp-sk" + assert config.security_token == "tmp-sts" + assert config.endpoint == "ecs.aliyuncs.com" + assert config.region_id == "cn-hangzhou" + assert config.user_agent and config.user_agent.startswith("iac-code/") + def test_ram_role_arn_mode(self) -> None: credential = AliyunCredential( mode="RamRoleArn", diff --git a/tests/tools/cloud/aliyun/test_ros_client.py b/tests/tools/cloud/aliyun/test_ros_client.py index dc77daa..2870e5d 100644 --- a/tests/tools/cloud/aliyun/test_ros_client.py +++ b/tests/tools/cloud/aliyun/test_ros_client.py @@ -68,6 +68,61 @@ def test_sts_token_mode_builds_config(self): assert config.region_id == "cn-hangzhou" assert config.user_agent and config.user_agent.startswith("iac-code/") + def test_oauth_mode_builds_sts_config(self): + from iac_code.services.providers.aliyun import AliyunCredential + from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory + + cred = AliyunCredential( + mode="OAuth", + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + region_id="cn-hangzhou", + ) + config = RosClientFactory._build_config(cred, "cn-hangzhou") + assert config.access_key_id == "tmp-ak" + assert config.access_key_secret == "tmp-sk" + assert config.security_token == "tmp-sts" + assert config.region_id == "cn-hangzhou" + assert config.user_agent and config.user_agent.startswith("iac-code/") + + def test_create_refreshes_oauth_before_building_client(self): + from unittest.mock import patch + + from iac_code.services.providers.aliyun import AliyunCredential + from iac_code.tools.cloud.aliyun import ros_client + + oauth_cred = AliyunCredential( + mode="OAuth", + access_key_id="tmp-ak", + access_key_secret="tmp-sk", + sts_token="tmp-sts", + region_id="cn-hangzhou", + oauth_access_token="access-token", + oauth_refresh_token="refresh-token", + ) + refreshed = AliyunCredential( + mode="OAuth", + access_key_id="new-ak", + access_key_secret="new-sk", + sts_token="new-sts", + region_id="cn-hangzhou", + oauth_access_token="access-token", + oauth_refresh_token="refresh-token", + ) + + with ( + patch.object(ros_client.AliyunCredentials, "refresh_oauth_if_needed", return_value=refreshed) as refresh, + patch.object(ros_client, "RosClient") as client_cls, + ): + ros_client.RosClientFactory.create(oauth_cred) + + refresh.assert_called_once_with(oauth_cred) + config = client_cls.call_args.args[0] + assert config.access_key_id == "new-ak" + assert config.access_key_secret == "new-sk" + assert config.security_token == "new-sts" + def test_ram_role_arn_mode_builds_config(self): from iac_code.services.providers.aliyun import AliyunCredential from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory diff --git a/tests/tools/cloud/aliyun/test_ros_stack_instances.py b/tests/tools/cloud/aliyun/test_ros_stack_instances.py index 58d8420..f7ca1ac 100644 --- a/tests/tools/cloud/aliyun/test_ros_stack_instances.py +++ b/tests/tools/cloud/aliyun/test_ros_stack_instances.py @@ -26,10 +26,9 @@ def mock_credentials(): @pytest.fixture -def tool() -> RosStackInstances: - t = RosStackInstances() - t.poll_interval = 0 - return t +def tool(monkeypatch) -> RosStackInstances: + monkeypatch.setattr(RosStackInstances, "poll_interval", 0) + return RosStackInstances() @pytest.fixture @@ -198,6 +197,40 @@ async def test_execute_create_instances(self, tool: RosStackInstances, mock_cred assert first.instances[0]["region_id"] == "cn-hangzhou" assert first.instances[0]["status"] == "SUCCEEDED" + @pytest.mark.asyncio + async def test_execute_reacquires_clients_while_polling(self, tool: RosStackInstances) -> None: + initiate_client = MagicMock(name="initiate-client") + first_poll_client = MagicMock(name="first-poll-client") + second_poll_client = MagicMock(name="second-poll-client") + + clients = [initiate_client, first_poll_client, second_poll_client] + statuses = ["RUNNING", "SUCCEEDED"] + status_clients = [] + instance_clients = [] + + async def fake_get_operation_status(client, operation_id, region): + status_clients.append(client) + return statuses.pop(0) + + async def fake_get_instances(client, stack_group_name, region): + instance_clients.append(client) + return [] + + with ( + patch.object(tool, "_get_client", side_effect=clients), + patch.object(tool, "_initiate", return_value="op-1"), + patch.object(tool, "_get_operation_status", side_effect=fake_get_operation_status), + patch.object(tool, "_get_instances", side_effect=fake_get_instances), + ): + result = await tool.execute( + tool_input={"action": "CreateStackInstances", "params": {"StackGroupName": "demo"}}, + context=ToolContext(), + ) + + assert result.is_error is False + assert status_clients == [first_poll_client, second_poll_client] + assert instance_clients == [first_poll_client, second_poll_client] + @pytest.mark.asyncio async def test_execute_returns_initiate_error(self, tool: RosStackInstances) -> None: with ( diff --git a/tests/tools/cloud/test_registry.py b/tests/tools/cloud/test_registry.py index 7385205..8fbe826 100644 --- a/tests/tools/cloud/test_registry.py +++ b/tests/tools/cloud/test_registry.py @@ -20,5 +20,24 @@ def test_does_not_register_when_not_configured(self): credentials.has_provider.return_value = False register_cloud_tools(registry, credentials) assert registry.get("aliyun_api") is None + assert registry.get("aliyun_doc_search") is None + assert registry.get("ros_stack") is None + assert registry.get("ros_stack_instances") is None + + def test_removes_stale_aliyun_tools_when_credentials_become_unavailable(self): + registry = ToolRegistry() + credentials = MagicMock() + credentials.has_provider.side_effect = [True, False] + + register_cloud_tools(registry, credentials) + assert registry.get("aliyun_api") is not None + assert registry.get("aliyun_doc_search") is not None + assert registry.get("ros_stack") is not None + assert registry.get("ros_stack_instances") is not None + + register_cloud_tools(registry, credentials) + + assert registry.get("aliyun_api") is None + assert registry.get("aliyun_doc_search") is None assert registry.get("ros_stack") is None assert registry.get("ros_stack_instances") is None diff --git a/tests/tools/test_base.py b/tests/tools/test_base.py index df663e4..18caa76 100644 --- a/tests/tools/test_base.py +++ b/tests/tools/test_base.py @@ -105,6 +105,17 @@ def test_get_nonexistent_tool(self): registry = ToolRegistry() assert registry.get("nonexistent") is None + def test_unregister_removes_tool_if_registered(self): + """Test unregistering a tool removes it from the registry.""" + registry = ToolRegistry() + tool = DummyTool() + registry.register(tool) + + registry.unregister("dummy") + registry.unregister("missing") + + assert registry.get("dummy") is None + def test_list_tools(self): """Test listing all registered tools.""" registry = ToolRegistry() diff --git a/tests/ui/dialogs/test_resume_picker.py b/tests/ui/dialogs/test_resume_picker.py index b02f47e..c1249e0 100644 --- a/tests/ui/dialogs/test_resume_picker.py +++ b/tests/ui/dialogs/test_resume_picker.py @@ -4,13 +4,13 @@ import io import time -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from rich.console import Console as RichConsole from iac_code.agent.message import Message -from iac_code.services.session_index import SessionIndex +from iac_code.services.session_index import SessionEntry, SessionIndex from iac_code.services.session_storage import SessionStorage from iac_code.ui.core.key_event import KeyEvent from iac_code.ui.dialogs.resume_picker import ( @@ -52,6 +52,23 @@ def _make_renderer(): return Renderer(scratch, registry) +def _entry(**overrides) -> SessionEntry: + defaults = dict( + session_id="1234567890abcdef", + cwd="/proj/a", + project_name="a", + git_branch="main", + title="deploy-prod", + mtime=time.time(), + size_bytes=42, + name=None, + auto_title="create vpc resources", + is_legacy=False, + ) + defaults.update(overrides) + return SessionEntry(**defaults) + + class TestResumePickerLoad: def test_default_view_is_current_cwd(self, picker): ids = [e.session_id for e in picker._all_entries] @@ -70,6 +87,43 @@ def test_excludes_current_session(self, two_session_index): ids = [e.session_id for e in p._all_entries] assert ids == ["id-aa"] + def test_supplied_entries_are_used_without_index_reload(self): + index = MagicMock() + index.list_for_cwd = MagicMock(side_effect=AssertionError("should not reload from index")) + index.list_all_projects = MagicMock(side_effect=AssertionError("should not reload from index")) + entries = [ + _entry(session_id="candidate-a"), + _entry(session_id="current-session"), + _entry(session_id="candidate-b"), + ] + + with patch("iac_code.ui.dialogs.resume_picker.get_git_branch", return_value=None): + p = ResumePicker( + index=index, + current_cwd="/proj/a", + current_session_id="current-session", + entries=entries, + ) + + assert [entry.session_id for entry in p._all_entries] == ["candidate-a", "candidate-b"] + + def test_supplied_entries_are_not_reloaded_when_toggling_all_projects(self): + index = MagicMock() + index.list_for_cwd = MagicMock(side_effect=AssertionError("should not reload from index")) + index.list_all_projects = MagicMock(side_effect=AssertionError("should not reload from index")) + entries = [_entry(session_id="candidate-a")] + + with patch("iac_code.ui.dialogs.resume_picker.get_git_branch", return_value=None): + p = ResumePicker( + index=index, + current_cwd="/proj/a", + current_session_id=None, + entries=entries, + ) + p.handle_key(KeyEvent(key="a", char="\x01", ctrl=True)) + + assert [entry.session_id for entry in p._all_entries] == ["candidate-a"] + class TestResumePickerKeys: def test_escape_cancels(self, picker): @@ -193,6 +247,29 @@ def test_typing_filters_entries(self, picker): ids = [e.session_id for e in picker._filtered] assert ids == ["id-aa"] + def test_search_matches_name_session_id_auto_title_project_and_branch(self, two_session_index): + entries = [ + _entry( + session_id="session-by-id", + project_name="networking", + git_branch="feature-branch", + title="title text", + name="named-session", + auto_title="auto title text", + ) + ] + for query in ("named", "session-by-id", "title", "auto", "networking", "feature"): + with patch("iac_code.ui.dialogs.resume_picker.get_git_branch", return_value=None): + p = ResumePicker( + index=two_session_index, + current_cwd="/proj/a", + current_session_id=None, + entries=entries, + ) + for ch in query: + p.handle_key(KeyEvent(key=ch, char=ch)) + assert [entry.session_id for entry in p._filtered] == ["session-by-id"] + def test_down_arrow_moves_focus(self, picker): assert len(picker._filtered) >= 2 starting = picker._focused_index @@ -216,6 +293,13 @@ def test_render_when_empty(self, two_session_index): ) p.render() + def test_named_subtitle_includes_short_session_id(self): + entry = _entry(session_id="1234567890abcdef", name="deploy-prod", title="deploy-prod") + subtitle = ResumePicker._render_subtitle_line(entry).plain + + assert "12345678" in subtitle + assert "123456789" not in subtitle + class TestResumePickerPreviewDraw: def _picker_with_console(self, two_session_index, *, height=40, width=80): diff --git a/tests/ui/dialogs/test_skills_picker.py b/tests/ui/dialogs/test_skills_picker.py new file mode 100644 index 0000000..d8d095e --- /dev/null +++ b/tests/ui/dialogs/test_skills_picker.py @@ -0,0 +1,159 @@ +"""Tests for SkillsPicker dialog.""" + +from __future__ import annotations + +from rich.console import Console + +from iac_code.skills.management import SkillManagementItem +from iac_code.types.skill_source import SkillSource +from iac_code.ui.core.key_event import KeyEvent +from iac_code.ui.dialogs.skills_picker import SkillsPicker + + +def key(name: str, char: str | None = None, *, ctrl: bool = False) -> KeyEvent: + return KeyEvent(key=name, char=name if char is None else char, ctrl=ctrl) + + +def _item( + name: str, + source: SkillSource, + *, + enabled: bool = True, + locked: bool = False, + size: int = 100, + description: str | None = None, +) -> SkillManagementItem: + return SkillManagementItem( + name=name, + description=description or f"{name} description", + source=source, + content_length=size, + path=f"/repo/{name}", + enabled=enabled, + locked=locked, + ) + + +def _render_text(picker: SkillsPicker) -> str: + console = Console(record=True, width=140) + console.print(picker.render()) + return console.export_text() + + +def test_space_toggles_non_bundled_skill(): + picker = SkillsPicker([_item("team-review", SkillSource.PROJECT)]) + + picker.handle_key(key(" ", " ")) + + assert picker.disabled_skill_names == {"team-review"} + + +def test_space_does_not_toggle_locked_bundled_skill(): + picker = SkillsPicker([_item("iac-aliyun", SkillSource.BUNDLED, locked=True)]) + + picker.handle_key(key(" ", " ")) + + assert picker.disabled_skill_names == set() + assert "cannot be disabled" in picker.status_message.lower() + + +def test_enter_returns_disabled_set(): + picker = SkillsPicker([_item("team-review", SkillSource.PROJECT)]) + picker.handle_key(key(" ", " ")) + + picker.handle_key(key("enter", "")) + + assert picker.result == {"team-review"} + assert picker.done is True + + +def test_escape_cancels(): + picker = SkillsPicker([_item("team-review", SkillSource.PROJECT)]) + + picker.handle_key(key("escape", "")) + + assert picker.result is None + assert picker.done is True + + +def test_search_filters_by_name_and_description(): + picker = SkillsPicker( + [ + _item("team-review", SkillSource.PROJECT, description="review"), + _item("deploy", SkillSource.USER, description="deploy"), + ] + ) + + picker.handle_key(key("r", "r")) + picker.handle_key(key("e", "e")) + picker.handle_key(key("v", "v")) + + assert [item.name for item in picker.filtered_items] == ["team-review"] + + +def test_slash_is_search_text(): + picker = SkillsPicker( + [ + _item("team/review", SkillSource.PROJECT), + _item("deploy", SkillSource.USER), + ] + ) + + picker.handle_key(key("/", "/")) + + assert [item.name for item in picker.filtered_items] == ["team/review"] + + +def test_t_is_search_text(): + picker = SkillsPicker( + [ + _item("team-review", SkillSource.PROJECT, description="team"), + _item("deploy", SkillSource.USER, description="deploy"), + ] + ) + + picker.handle_key(key("t", "t")) + + assert [item.name for item in picker.filtered_items] == ["team-review"] + assert picker.sort_mode == "name" + + +def test_description_only_match_is_labeled(): + picker = SkillsPicker( + [ + _item("iac-aliyun", SkillSource.BUNDLED, description="Terraform template", locked=True), + ] + ) + + picker.handle_key(key("t", "t")) + + assert "matched description" in _render_text(picker) + + +def test_name_match_does_not_show_description_label(): + picker = SkillsPicker( + [ + _item("team-review", SkillSource.PROJECT, description="Terraform template"), + ] + ) + + picker.handle_key(key("t", "t")) + + assert "matched description" not in _render_text(picker) + + +def test_tab_cycles_sort_by_source_then_size(): + picker = SkillsPicker( + [ + _item("zeta", SkillSource.USER, size=400), + _item("alpha", SkillSource.PROJECT, size=800), + _item("bundled", SkillSource.BUNDLED, locked=True, size=100), + ] + ) + assert [item.name for item in picker.filtered_items] == ["alpha", "bundled", "zeta"] + + picker.handle_key(key("tab", "\t")) + assert [item.name for item in picker.filtered_items] == ["bundled", "alpha", "zeta"] + + picker.handle_key(key("tab", "\t")) + assert [item.name for item in picker.filtered_items] == ["bundled", "zeta", "alpha"] diff --git a/tests/ui/suggestions/test_aggregator.py b/tests/ui/suggestions/test_aggregator.py index cab3c4c..d3c91bc 100644 --- a/tests/ui/suggestions/test_aggregator.py +++ b/tests/ui/suggestions/test_aggregator.py @@ -19,6 +19,16 @@ def aggregator(command_provider) -> SuggestionAggregator: return SuggestionAggregator([command_provider]) +class _MemoryManager: + def list_memories(self): + return [{"name": "user-role", "description": "Role", "type": "user", "content": "Senior engineer"}] + + +@pytest.fixture +def memory_aggregator() -> SuggestionAggregator: + return SuggestionAggregator([CommandProvider(create_default_registry(), memory_manager=_MemoryManager())]) + + class TestSuggestionAggregator: def test_update_with_slash_trigger(self, aggregator): """/mod → suggestions > 0.""" @@ -156,3 +166,16 @@ def test_accept_ghost_text_does_not_include_hint(self, aggregator): assert result is not None text, _start, _end = result assert text == "/debug " + + def test_memory_argument_ghost_text(self, memory_aggregator): + """/memory d → ghost text completes the delete action.""" + memory_aggregator.update("/memory d", 9) + assert memory_aggregator.ghost_text == "elete " + + def test_accept_memory_argument_suggestion_replaces_command_span(self, memory_aggregator): + """/memory delete suggestion replaces the full slash command token.""" + text = "/memory delete " + memory_aggregator.update(text, len(text)) + result = memory_aggregator.accept_selected() + + assert result == ("/memory delete user-role", 0, len(text)) diff --git a/tests/ui/suggestions/test_command_provider.py b/tests/ui/suggestions/test_command_provider.py index c35037a..a190b98 100644 --- a/tests/ui/suggestions/test_command_provider.py +++ b/tests/ui/suggestions/test_command_provider.py @@ -20,6 +20,24 @@ def provider(registry) -> CommandProvider: return CommandProvider(registry) +class _MemoryManager: + def list_memories(self): + return [ + {"name": "user-role", "description": "Role", "type": "user", "content": "Senior engineer"}, + { + "name": "feedback-testing", + "description": "Testing", + "type": "feedback", + "content": "Prefer integration tests", + }, + ] + + +@pytest.fixture +def memory_provider(registry) -> CommandProvider: + return CommandProvider(registry, memory_manager=_MemoryManager()) + + def make_token(text: str, start: int = 0) -> CompletionToken: return CompletionToken(text=text, start=start, end=start + len(text), trigger="/") @@ -101,3 +119,36 @@ def test_arg_hint_absent_for_commands_without_one(self, provider): clear_items = [i for i in items if i.display_text == "clear"] assert len(clear_items) == 1 assert clear_items[0].arg_hint is None + + def test_memory_second_item_suggests_actions_and_memory_names(self, memory_provider): + """/memory → subcommands plus saved memory names.""" + token = make_token("/memory ") + items = memory_provider.provide(token) + + names = {item.display_text for item in items} + assert {"search", "delete", "help", "user-role", "feedback-testing"}.issubset(names) + assert [item.completion for item in items if item.display_text == "search"] == ["/memory search "] + assert [item.completion for item in items if item.display_text == "user-role"] == ["/memory user-role"] + + def test_memory_second_item_filters_action_prefix(self, memory_provider): + """/memory d → delete action suggestion.""" + token = make_token("/memory d") + items = memory_provider.provide(token) + + assert [item.display_text for item in items] == ["delete"] + assert items[0].completion == "/memory delete " + + def test_memory_delete_suggests_memory_names(self, memory_provider): + """/memory delete → saved memory name suggestions.""" + token = make_token("/memory delete ") + items = memory_provider.provide(token) + + names = [item.display_text for item in items] + assert names == ["feedback-testing", "user-role"] + assert items[0].completion == "/memory delete feedback-testing" + assert all(item.id.startswith("cmd:memory:") for item in items) + + def test_memory_search_query_has_no_argument_suggestions(self, memory_provider): + """/memory search leaves free-form search input alone.""" + token = make_token("/memory search ") + assert memory_provider.provide(token) == [] diff --git a/tests/ui/suggestions/test_skill_provider.py b/tests/ui/suggestions/test_skill_provider.py index 56e636a..674e9a5 100644 --- a/tests/ui/suggestions/test_skill_provider.py +++ b/tests/ui/suggestions/test_skill_provider.py @@ -46,6 +46,16 @@ def test_empty_query_returns_only_skills(self, provider): assert "help" not in names assert "model" not in names + def test_disabled_skills_not_suggested_when_not_registered(self): + """Disabled skills are omitted from registry, so '$' suggests enabled skills only.""" + reg = CommandRegistry() + reg.register(PromptCommand(name="enabled", description="Enabled skill")) + provider = SkillProvider(reg) + + items = provider.provide(make_token("$")) + + assert {item.display_text for item in items} == {"enabled"} + def test_partial_match_skill(self, provider): """'$dep' → results contain 'deploy'.""" items = provider.provide(make_token("$dep")) diff --git a/tests/ui/suggestions/test_token_extractor.py b/tests/ui/suggestions/test_token_extractor.py index dcaa8ba..d6cd330 100644 --- a/tests/ui/suggestions/test_token_extractor.py +++ b/tests/ui/suggestions/test_token_extractor.py @@ -102,6 +102,26 @@ def test_slash_after_tab(self, extractor): assert token is not None assert token.trigger == "/" + def test_slash_command_token_includes_trailing_space(self, extractor): + """/memory stays active for argument suggestions.""" + text = "/memory " + token = extractor.extract(text, len(text)) + assert token is not None + assert token.trigger == "/" + assert token.text == "/memory " + assert token.start == 0 + assert token.end == len(text) + + def test_slash_command_token_includes_arguments(self, extractor): + """Slash command suggestions can inspect the second input item.""" + text = "run /memory delete " + token = extractor.extract(text, len(text)) + assert token is not None + assert token.trigger == "/" + assert token.text == "/memory delete " + assert token.start == 4 + assert token.end == len(text) + def test_token_start_and_end_positions(self, extractor): """Verify start and end positions are correct.""" text = "look at @config" diff --git a/tests/ui/test_banner.py b/tests/ui/test_banner.py index d5dcb6b..cdffec5 100644 --- a/tests/ui/test_banner.py +++ b/tests/ui/test_banner.py @@ -183,10 +183,10 @@ def test_qwenpaw_source_no_config_fallback(self): class TestRenderWelcomeBanner: """Tests for render_welcome_banner(model, cwd).""" - def _call(self, model: str, cwd: str) -> Panel: + def _call(self, model: str, cwd: str, session_id: str | None = None, session_name: str | None = None) -> Panel: from iac_code.ui.banner import render_welcome_banner - return render_welcome_banner(model, cwd) + return render_welcome_banner(model, cwd, session_id=session_id, session_name=session_name) # ------------------------------------------------------------------ # Return-type tests @@ -196,6 +196,13 @@ def test_returns_panel(self, tmp_path): result = self._call("claude-3-5-sonnet", str(tmp_path)) assert isinstance(result, Panel) + def test_banner_shows_named_session(self, tmp_path): + panel = self._call("some-model", str(tmp_path), session_id="abc123", session_name="deploy-prod") + text = render_to_str(panel) + assert "Session" in text + assert "deploy-prod" in text + assert "abc123" in text + # ------------------------------------------------------------------ # cwd display: inside HOME → ~/... prefix # ------------------------------------------------------------------ diff --git a/tests/ui/test_repl_integration.py b/tests/ui/test_repl_integration.py index 50c63d3..9733cd2 100644 --- a/tests/ui/test_repl_integration.py +++ b/tests/ui/test_repl_integration.py @@ -5,6 +5,7 @@ import re import subprocess import sys +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -12,6 +13,7 @@ from iac_code.services.update_checker import PendingUpdate from iac_code.ui.components.select import SelectLayout +from iac_code.utils.project_paths import format_resume_command @pytest.fixture(autouse=True) @@ -37,6 +39,22 @@ def make_pending_update() -> PendingUpdate: ) +def make_session_entry(session_id: str, cwd: str, name: str | None = None): + from iac_code.services.session_index import SessionEntry + + return SessionEntry( + session_id=session_id, + cwd=cwd, + project_name="repo", + git_branch=None, + title=name or session_id, + mtime=123.0, + size_bytes=456, + name=name, + is_legacy=False, + ) + + class TestREPLProviderIntegration: @patch("iac_code.ui.repl.ProviderManager") @patch("iac_code.ui.repl.SessionStorage") @@ -200,17 +218,235 @@ async def test_run_once_routes_normal_chat_unchanged(): repl._handle_chat.assert_awaited_once_with("hello") +@pytest.mark.asyncio +async def test_handle_command_reports_disabled_skill(): + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.command_registry = SimpleNamespace(parse=Mock(return_value=("Disabled", [])), get=Mock(return_value=None)) + repl._disabled_skill_commands = {"disabled": object()} + repl._agent_loop = SimpleNamespace(context_manager=SimpleNamespace(get_messages=Mock(return_value=[]))) + repl._command_log = [] + repl.renderer = SimpleNamespace(print_system_message=Mock()) + + await repl._handle_command("$Disabled") + + repl.renderer.print_system_message.assert_called_once() + message = repl.renderer.print_system_message.call_args.args[0] + assert "disabled" in message.lower() + assert "/skills" in message + + +@patch("iac_code.ui.repl.ProviderManager") +@patch("iac_code.ui.repl.SessionStorage") +@patch("iac_code.ui.repl.MemoryManager") +def test_init_does_not_register_disabled_project_skill(mock_mm, mock_ss, mock_pm, monkeypatch): + from iac_code.skills.frontmatter import SkillFrontmatter + from iac_code.skills.skill_definition import SkillDefinition + from iac_code.types.skill_source import SkillSource + from iac_code.ui.repl import InlineREPL + + project_skill = SkillDefinition( + name="project-skill", + description="Project skill", + frontmatter=SkillFrontmatter(description="Project skill"), + content="Body", + source=SkillSource.PROJECT, + ) + monkeypatch.setattr("iac_code.skills.discovery.discover_all_skills", lambda cwd: [project_skill]) + monkeypatch.setattr("iac_code.skills.settings.load_disabled_skills", lambda: {"project-skill"}) + + repl = InlineREPL(model="test-model") + + assert repl.command_registry.get("project-skill") is None + assert "project-skill" in repl._disabled_skill_commands + + +@patch("iac_code.ui.repl.ProviderManager") +@patch("iac_code.ui.repl.SessionStorage") +@patch("iac_code.ui.repl.MemoryManager") +def test_refresh_skills_updates_agent_loop_auto_trigger_skills(mock_mm, mock_ss, mock_pm, monkeypatch): + from iac_code.skills.frontmatter import SkillFrontmatter + from iac_code.skills.skill_definition import SkillDefinition + from iac_code.types.skill_source import SkillSource + from iac_code.ui.repl import InlineREPL + + disabled: set[str] = set() + project_skill = SkillDefinition( + name="project-skill", + description="Project skill", + frontmatter=SkillFrontmatter(description="Project skill", auto_trigger={"script": "auto_trigger.py"}), + content="Body", + source=SkillSource.PROJECT, + ) + monkeypatch.setattr("iac_code.skills.discovery.discover_all_skills", lambda cwd: [project_skill]) + monkeypatch.setattr("iac_code.skills.settings.load_disabled_skills", lambda: disabled) + + repl = InlineREPL(model="test-model") + assert any(command.name == "project-skill" for command in repl._agent_loop._auto_trigger_skills) + + disabled.add("project-skill") + repl.refresh_skills() + + assert all(command.name != "project-skill" for command in repl._agent_loop._auto_trigger_skills) + + +def test_repl_rename_current_session_updates_storage_and_name(): + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl._session_id = "session-123" + repl._session_storage = Mock() + repl.current_git_branch = Mock(return_value="main") + repl._load_current_session_name = Mock(return_value="deploy-prod") + + result = repl.rename_current_session("deploy-prod") + + assert result == repl._session_storage.rename_session.return_value + repl._session_storage.rename_session.assert_called_once_with( + "/repo", + "session-123", + "deploy-prod", + git_branch="main", + ) + repl._load_current_session_name.assert_called_once_with() + assert repl._session_name == "deploy-prod" + + +def test_swap_session_refreshes_session_name_and_renders_banner(): + from iac_code.state.app_state import AppState + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl._session_id = "old-session" + repl._session_storage = SimpleNamespace( + load=Mock(return_value=[]), + repair_interrupted=Mock(return_value=[]), + ) + repl._agent_loop = SimpleNamespace(replace_session=Mock()) + repl._load_current_session_name = Mock(return_value="deploy-prod") + repl.store = SimpleNamespace(get_state=Mock(return_value=AppState(model="test-model", cwd="/repo"))) + repl.console = SimpleNamespace(file=SimpleNamespace(write=Mock(), flush=Mock()), print=Mock()) + repl.renderer = SimpleNamespace(replay_history=Mock()) + + with patch("iac_code.ui.repl.render_welcome_banner", return_value="banner") as render_welcome_banner: + repl.swap_session("new-session") + + assert repl._session_name == "deploy-prod" + repl._load_current_session_name.assert_called_once_with() + render_welcome_banner.assert_called_once_with( + "test-model", + "/repo", + session_id="new-session", + session_name="deploy-prod", + ) + repl.console.print.assert_called_once_with("banner") + + +def test_print_exit_text_uses_session_name_and_prints_session_id(): + from rich.text import Text + + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._session_id = "abc123" + repl._session_name = "deploy-prod" + repl.console = SimpleNamespace(print=Mock()) + + repl._print_exit_text() + + printed = [call.args[0] for call in repl.console.print.call_args_list] + assert "[dim]Goodbye![/dim]" in printed + assert any(isinstance(item, Text) and "iac-code --resume deploy-prod" in item.plain for item in printed) + assert any(isinstance(item, Text) and "Session ID: abc123" in item.plain for item in printed) + + +@pytest.mark.asyncio +async def test_prompt_for_session_name_retries_until_valid(): + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._prompt_input = SimpleNamespace(get_input=AsyncMock(side_effect=[" ", "bad name", "deploy-prod"])) + repl.renderer = SimpleNamespace(print_system_message=Mock()) + + result = await repl.prompt_for_session_name() + + assert result == "deploy-prod" + assert repl._prompt_input.get_input.await_count == 3 + assert repl.renderer.print_system_message.call_count == 2 + styles = [call.kwargs["style"] for call in repl.renderer.print_system_message.call_args_list] + assert styles == ["red", "red"] + + +def test_resolve_session_id_continue_returns_latest_current_project_session(): + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl._session_storage = SimpleNamespace(get_latest_session_anywhere=Mock(return_value=("/repo", "latest-id"))) + + assert repl._resolve_session_id(True) == "latest-id" + repl._session_storage.get_latest_session_anywhere.assert_called_once_with() + + +def test_resolve_session_id_continue_accepts_windows_equivalent_cwd(): + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = r"C:\Users\Me\Repo" + repl._session_storage = SimpleNamespace( + get_latest_session_anywhere=Mock(return_value=("c:/Users/Me/Repo", "latest-id")) + ) + + assert repl._resolve_session_id(True) == "latest-id" + + +def test_resolve_session_id_continue_cross_project_raises_with_hint(): + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl._session_storage = SimpleNamespace( + get_latest_session_anywhere=Mock(return_value=("/elsewhere/repo", "latest-id")) + ) + + with pytest.raises(ValueError) as exc_info: + repl._resolve_session_id(True) + + assert format_resume_command("/elsewhere/repo", "latest-id") in str(exc_info.value) + + +def test_cross_project_message_uses_windows_resume_command(monkeypatch): + import iac_code.utils.project_paths as project_paths + from iac_code.ui.repl import InlineREPL + + monkeypatch.setattr(project_paths.sys, "platform", "win32") + + message = InlineREPL._cross_project_message(r"C:\Users\Me\iac repo & unsafe", "abc123") + + assert r'cd /d "C:\Users\Me\iac repo & unsafe" && iac-code --resume abc123' in message + + @patch("iac_code.ui.repl.ProviderManager") @patch("iac_code.ui.repl.SessionStorage") @patch("iac_code.ui.repl.MemoryManager") def test_resume_str_accepted_when_session_exists(mock_mm, mock_ss, mock_pm): + from iac_code.services.session_resolver import ResolutionStatus, SessionResolution from iac_code.ui.repl import InlineREPL existing_id = "99646984-35a9-4850-b72a-4131a1690774" - mock_ss.return_value.exists.return_value = True mock_ss.return_value.load.return_value = [] mock_ss.return_value.repair_interrupted.return_value = [] - repl = InlineREPL(model="test-model", resume_session_id=existing_id) + with patch( + "iac_code.ui.repl.resolve_session_argument", + return_value=SessionResolution( + status=ResolutionStatus.FOUND, + entry=make_session_entry(existing_id, str(Path.cwd())), + ), + ): + repl = InlineREPL(model="test-model", resume_session_id=existing_id) assert repl.session_id == existing_id @@ -218,32 +454,208 @@ def test_resume_str_accepted_when_session_exists(mock_mm, mock_ss, mock_pm): @patch("iac_code.ui.repl.SessionStorage") @patch("iac_code.ui.repl.MemoryManager") def test_resume_str_raises_when_session_missing(mock_mm, mock_ss, mock_pm): + from iac_code.services.session_resolver import ResolutionStatus, SessionResolution from iac_code.ui.repl import InlineREPL - mock_ss.return_value.exists.return_value = False - mock_ss.return_value.find_session_anywhere.return_value = None - import pytest - - with pytest.raises(ValueError, match="Session not found"): + with ( + patch( + "iac_code.ui.repl.resolve_session_argument", + return_value=SessionResolution(status=ResolutionStatus.NOT_FOUND), + ), + pytest.raises(ValueError, match="Session not found"), + ): InlineREPL(model="test-model", resume_session_id="no-such-id") @patch("iac_code.ui.repl.ProviderManager") @patch("iac_code.ui.repl.SessionStorage") @patch("iac_code.ui.repl.MemoryManager") -def test_resume_str_cross_project_raises_with_hint(mock_mm, mock_ss, mock_pm, tmp_path): +def test_resume_str_cross_project_raises_with_hint(mock_mm, mock_ss, mock_pm): """A resume id resolved in a different project must surface the cd command.""" + from iac_code.services.session_resolver import ResolutionStatus, SessionResolution from iac_code.ui.repl import InlineREPL - mock_ss.return_value.exists.return_value = False - mock_ss.return_value.find_session_anywhere.return_value = ( - "/elsewhere/repo", - tmp_path / "fake.jsonl", + with ( + patch( + "iac_code.ui.repl.resolve_session_argument", + return_value=SessionResolution( + status=ResolutionStatus.FOUND, + entry=make_session_entry("some-id", "/elsewhere/repo"), + ), + ), + pytest.raises(ValueError) as exc_info, + ): + InlineREPL(model="test-model", resume_session_id="some-id") + + assert format_resume_command("/elsewhere/repo", "some-id") in str(exc_info.value) + + +def test_resolve_session_id_accepts_current_project_name(): + from iac_code.services.session_resolver import ResolutionStatus, SessionResolution + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl.session_index = object() + + with patch( + "iac_code.ui.repl.resolve_session_argument", + return_value=SessionResolution( + status=ResolutionStatus.FOUND, + entry=make_session_entry("abc123", repl._original_cwd, name="deploy-prod"), + ), + ) as resolve_session_argument: + result = repl._resolve_session_id("deploy-prod") + + assert result == "abc123" + resolve_session_argument.assert_called_once_with(repl.session_index, repl._original_cwd, "deploy-prod") + + +def test_resolve_session_id_ambiguous_name_raises_candidates(): + from iac_code.services.session_resolver import ResolutionStatus, SessionResolution + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl.session_index = object() + candidates = [ + make_session_entry("abc123", "/repo", name="deploy-prod"), + make_session_entry("def456", "/elsewhere/repo", name="deploy-prod"), + ] + + with ( + patch( + "iac_code.ui.repl.resolve_session_argument", + return_value=SessionResolution(status=ResolutionStatus.AMBIGUOUS_NAME, candidates=candidates), + ), + pytest.raises(ValueError) as exc_info, + ): + repl._resolve_session_id("deploy-prod") + + message = str(exc_info.value) + assert "Multiple sessions match" in message + assert "abc123" in message + assert "def456" in message + assert format_resume_command("/repo", "abc123") in message + assert format_resume_command("/elsewhere/repo", "def456") in message + + +def test_printed_session_name_resume_command_resolves_to_session_id(): + from rich.text import Text + + from iac_code.services.session_resolver import ResolutionStatus, SessionResolution + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl._session_id = "abc123" + repl._session_name = "deploy-prod" + repl.session_index = object() + repl.console = SimpleNamespace(print=Mock()) + + repl._print_exit_text() + command = next( + item.plain + for call in repl.console.print.call_args_list + for item in call.args + if isinstance(item, Text) and item.plain.startswith("iac-code --resume ") ) - import pytest + resume_arg = command.rsplit(" ", 1)[-1] + + with patch( + "iac_code.ui.repl.resolve_session_argument", + return_value=SessionResolution( + status=ResolutionStatus.FOUND, + entry=make_session_entry("abc123", repl._original_cwd, name="deploy-prod"), + ), + ): + assert repl._resolve_session_id(resume_arg) == "abc123" - with pytest.raises(ValueError, match=r"cd /elsewhere/repo && iac-code --resume"): - InlineREPL(model="test-model", resume_session_id="some-id") + +@pytest.mark.asyncio +async def test_rename_error_result_prints_red_and_records_error(): + from iac_code.commands.registry import LocalCommand + from iac_code.commands.rename import rename_command + from iac_code.state.app_state import AppState + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.command_registry = SimpleNamespace( + parse=Mock(return_value=("rename", ["-bad"])), + get=Mock(return_value=LocalCommand(name="rename", description="Rename", handler=rename_command)), + ) + repl.renderer = SimpleNamespace(print_system_message=Mock(), print_command_result=Mock()) + repl.console = SimpleNamespace() + repl._agent_loop = SimpleNamespace(context_manager=SimpleNamespace(get_messages=Mock(return_value=[]))) + repl._command_log = [] + repl.store = SimpleNamespace(get_state=Mock(return_value=AppState(model="test-model", cwd="/repo"))) + repl._refresh_banner = Mock() + repl.rename_current_session = Mock() + + await repl._handle_command("/rename -bad") + + repl.renderer.print_system_message.assert_called_once() + assert repl.renderer.print_system_message.call_args.kwargs["style"] == "red" + repl.renderer.print_command_result.assert_not_called() + assert repl._command_log[-1][0] == "/rename -bad" + assert repl._command_log[-1][3] is True + repl._refresh_banner.assert_not_called() + + +@pytest.mark.asyncio +async def test_rename_success_refreshes_banner(): + from iac_code.commands.registry import LocalCommand + from iac_code.commands.rename import rename_command + from iac_code.state.app_state import AppState + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.command_registry = SimpleNamespace( + parse=Mock(return_value=("rename", ["deploy-prod"])), + get=Mock(return_value=LocalCommand(name="rename", description="Rename", handler=rename_command)), + ) + repl.renderer = SimpleNamespace(print_system_message=Mock(), print_command_result=Mock()) + repl.console = SimpleNamespace() + repl._agent_loop = SimpleNamespace(context_manager=SimpleNamespace(get_messages=Mock(return_value=[]))) + repl._command_log = [] + repl.store = SimpleNamespace(get_state=Mock(return_value=AppState(model="test-model", cwd="/repo"))) + repl._refresh_banner = Mock() + repl.rename_current_session = Mock(return_value="renamed") + + await repl._handle_command("/rename deploy-prod") + + repl._refresh_banner.assert_called_once_with() + repl.renderer.print_command_result.assert_not_called() + assert repl._command_log[-1][0] == "/rename deploy-prod" + assert repl._command_log[-1][3] is False + + +@pytest.mark.asyncio +async def test_rename_unchanged_does_not_refresh_banner(): + from iac_code.commands.registry import LocalCommand + from iac_code.commands.rename import rename_command + from iac_code.state.app_state import AppState + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.command_registry = SimpleNamespace( + parse=Mock(return_value=("rename", ["deploy-prod"])), + get=Mock(return_value=LocalCommand(name="rename", description="Rename", handler=rename_command)), + ) + repl.renderer = SimpleNamespace(print_system_message=Mock(), print_command_result=Mock()) + repl.console = SimpleNamespace() + repl._agent_loop = SimpleNamespace(context_manager=SimpleNamespace(get_messages=Mock(return_value=[]))) + repl._command_log = [] + repl.store = SimpleNamespace(get_state=Mock(return_value=AppState(model="test-model", cwd="/repo"))) + repl._refresh_banner = Mock() + repl.rename_current_session = Mock(return_value="unchanged") + + await repl._handle_command("/rename deploy-prod") + + repl._refresh_banner.assert_not_called() + repl.renderer.print_command_result.assert_called_once() + assert repl._command_log[-1][0] == "/rename deploy-prod" + assert repl._command_log[-1][3] is False @patch("iac_code.ui.repl.ProviderManager") diff --git a/tests/ui/test_repl_shell_escape.py b/tests/ui/test_repl_shell_escape.py index cc1cec4..150f0e7 100644 --- a/tests/ui/test_repl_shell_escape.py +++ b/tests/ui/test_repl_shell_escape.py @@ -16,6 +16,8 @@ class FakeRenderer: def __init__(self, permission_allowed: bool = True) -> None: self.messages: list[tuple[str, str]] = [] self.recorded_turns: list[str] = [] + self.user_messages: list[str] = [] + self.command_results: list[tuple[str, str]] = [] self.permission_allowed = permission_allowed self.permission_events = [] @@ -25,6 +27,12 @@ def print_system_message(self, text: str, style: str = "yellow") -> None: def record_user_turn(self, text: str) -> None: self.recorded_turns.append(text) + def print_user_message(self, text: str) -> None: + self.user_messages.append(text) + + def print_command_result(self, command: str, result: str) -> None: + self.command_results.append((command, result)) + async def prompt_permission(self, event) -> bool: self.permission_events.append(event) return self.permission_allowed @@ -46,6 +54,9 @@ def __init__(self) -> None: self.user_messages: list[str] = [] self.assistant_messages: list[str] = [] + def get_messages(self) -> list: + return [] + def add_user_message(self, message: str) -> None: self.user_messages.append(message) @@ -94,6 +105,8 @@ def make_repl( repl._original_cwd = cwd repl.renderer = FakeRenderer(permission_allowed=permission_allowed) repl._history = RecordingHistory() + repl._command_log = [] + repl._streaming_error_log = [] repl._agent_loop = SimpleNamespace(context_manager=RecordingContextManager()) repl.store = SimpleNamespace(get_state=lambda: SimpleNamespace(permission_context=permission_context)) repl.tool_registry = SimpleNamespace(get=lambda name: tool if name == "bash" else None) @@ -112,6 +125,7 @@ async def test_shell_escape_executes_registered_bash_tool(tmp_path): assert ("STDOUT:\nhello\nExit code: 0", "white") in repl.renderer.messages assert repl.renderer.recorded_turns == [] assert repl._history.appended == [] + assert repl._command_log == [("!echo hello", "$ echo hello\nSTDOUT:\nhello\nExit code: 0", 0, False)] assert repl._agent_loop.context_manager.user_messages == [] assert repl._agent_loop.context_manager.assistant_messages == [] @@ -160,6 +174,7 @@ async def test_shell_escape_error_result_prints_red_output(tmp_path): assert tool.calls == [({"command": "missing-command"}, str(tmp_path))] assert ("STDERR:\nnot found\nExit code: 127", "red") in repl.renderer.messages + assert repl._command_log == [("!missing-command", "$ missing-command\nSTDERR:\nnot found\nExit code: 127", 0, True)] @pytest.mark.asyncio @@ -215,3 +230,25 @@ async def handle_shell_escape(user_input: str) -> None: assert handled == ["!echo hello"] assert history.is_navigating is False assert history.search("") == ["previous prompt"] + + +def test_refresh_banner_replays_shell_escape_command(tmp_path): + from iac_code.state.app_state import AppState + + repl = InlineREPL.__new__(InlineREPL) + repl._session_id = "session-1" + repl._session_name = "deploy-prod" + repl.store = SimpleNamespace(get_state=lambda: AppState(model="test-model", cwd=str(tmp_path))) + repl.console = SimpleNamespace( + file=SimpleNamespace(write=lambda _text: None, flush=lambda: None), + print=lambda *_: None, + ) + repl.renderer = FakeRenderer() + repl._agent_loop = SimpleNamespace(context_manager=SimpleNamespace(get_messages=lambda: [])) + repl._streaming_error_log = [] + repl._command_log = [("!echo hello", "$ echo hello\nhello", 0, False)] + + repl._refresh_banner() + + assert repl.renderer.user_messages == ["!echo hello"] + assert repl.renderer.command_results == [("!echo hello", "$ echo hello\nhello")] diff --git a/tests/ui/test_repl_status.py b/tests/ui/test_repl_status.py new file mode 100644 index 0000000..5b3db90 --- /dev/null +++ b/tests/ui/test_repl_status.py @@ -0,0 +1,98 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from iac_code.agent.message import Message, ToolResultBlock +from iac_code.state.app_state import AppState, AppStateStore +from iac_code.ui.repl import InlineREPL + + +def test_count_user_turns_ignores_tool_result_messages() -> None: + messages = [ + Message(role="user", content="first"), + Message(role="assistant", content="answer"), + Message(role="user", content=[ToolResultBlock(tool_use_id="t1", content="tool", is_error=False)]), + Message(role="user", content="second"), + ] + + assert InlineREPL._count_user_turns(messages) == 2 + + +def test_status_snapshot_uses_agent_loop_and_original_cwd(monkeypatch) -> None: + repl = object.__new__(InlineREPL) + repl._session_id = "abc123" + repl._was_resumed = True + repl._original_cwd = "/tmp/status-project" + repl.store = AppStateStore(AppState(model="qwen3.7-max", cwd="/other/cwd")) + repl._provider_manager = MagicMock() + repl._provider_manager.get_provider_display.return_value = "Alibaba Cloud Bailian" + repl._provider_manager.get_model_name.return_value = "qwen3.7-max" + repl._agent_loop = MagicMock() + repl._agent_loop.max_turns = 100 + repl._agent_loop.get_session_usage.return_value = SimpleNamespace( + input_tokens=10, + output_tokens=5, + cache_read_input_tokens=2, + cache_creation_input_tokens=1, + total_tokens=18, + recorded_events=1, + has_recorded_usage=True, + ) + repl._agent_loop.get_context_usage.return_value = { + "total_tokens": 58000, + "context_window": 128000, + "usage_percent": 45.3125, + } + repl._agent_loop.context_manager.get_messages.return_value = [ + Message(role="user", content="first"), + Message(role="assistant", content="answer"), + ] + + monkeypatch.setattr("iac_code.ui.repl.get_active_provider_key", lambda: "dashscope") + monkeypatch.setattr( + "iac_code.services.cloud_credentials.CloudCredentials", + lambda: SimpleNamespace(get_provider=lambda name: SimpleNamespace(region_id="cn-beijing")), + ) + + snapshot = repl.get_status_snapshot() + + assert snapshot["session_id"] == "abc123" + assert snapshot["resumed"] is True + assert snapshot["cwd"] == "/tmp/status-project" + assert snapshot["provider"] == "Alibaba Cloud Bailian" + assert snapshot["model"] == "qwen3.7-max" + assert snapshot["region"] == "cn-beijing" + assert snapshot["turn_count"] == 1 + assert snapshot["max_turns"] == 100 + assert snapshot["api_usage"].total_tokens == 18 + assert snapshot["context_usage"]["usage_percent"] == 45.3125 + + +def test_status_snapshot_uses_runtime_provider_manager(monkeypatch) -> None: + repl = object.__new__(InlineREPL) + repl._session_id = "runtime" + repl._was_resumed = False + repl._original_cwd = "/tmp/status-project" + repl.store = AppStateStore(AppState(model="stale-model", cwd="/tmp/status-project")) + repl._provider_manager = MagicMock() + repl._provider_manager.get_provider_display.return_value = "Runtime Provider" + repl._provider_manager.get_model_name.return_value = "runtime-model" + repl._agent_loop = MagicMock() + repl._agent_loop.max_turns = 100 + repl._agent_loop.get_session_usage.return_value = SimpleNamespace( + total_tokens=0, + recorded_events=0, + has_recorded_usage=False, + ) + repl._agent_loop.get_context_usage.return_value = {} + repl._agent_loop.context_manager.get_messages.return_value = [] + + monkeypatch.setattr("iac_code.ui.repl.get_active_provider_key", lambda: "openai") + monkeypatch.setattr( + "iac_code.services.cloud_credentials.CloudCredentials", + lambda: SimpleNamespace(get_provider=lambda name: None), + ) + + snapshot = repl.get_status_snapshot() + + assert snapshot["provider"] == "Runtime Provider" + assert snapshot["model"] == "runtime-model" diff --git a/tests/utils/test_project_paths.py b/tests/utils/test_project_paths.py index ebb2345..b6a1169 100644 --- a/tests/utils/test_project_paths.py +++ b/tests/utils/test_project_paths.py @@ -8,7 +8,9 @@ from iac_code.utils.project_paths import ( MAX_SANITIZED_LENGTH, find_git_worktree_root, + format_resume_command, get_git_branch, + same_project_path, sanitize_path, ) @@ -42,6 +44,23 @@ def test_empty_string(self): assert sanitize_path("") == "" +class TestProjectPathComparison: + def test_windows_drive_case_and_separators_match(self): + assert same_project_path(r"C:\Users\Me\Repo", "c:/Users/Me/Repo") + + +class TestFormatResumeCommand: + def test_windows_command_quotes_for_cmd_exe(self): + command = format_resume_command(r"C:\Users\Me\iac repo & unsafe", "abc123", platform="win32") + + assert command == r'cd /d "C:\Users\Me\iac repo & unsafe" && iac-code --resume abc123' + + def test_posix_command_keeps_shell_quoting(self): + command = format_resume_command("/project a;unsafe", "abc123", platform="linux") + + assert command == "cd '/project a;unsafe' && iac-code --resume abc123" + + class TestGetGitBranch: """Regression: ``get_git_branch`` must not spawn ``git``. diff --git a/website/docs/cli/command-line-options.md b/website/docs/cli/command-line-options.md index 7252abf..f5bf626 100644 --- a/website/docs/cli/command-line-options.md +++ b/website/docs/cli/command-line-options.md @@ -16,7 +16,7 @@ Command line options change how IaC Code starts. Use them before entering the in | `--output-format ` | Set output format for non-interactive mode. Supported values are `text`, `json`, and `stream-json`. The default is `text`. | | `--max-turns ` | Limit the maximum number of agent turns in non-interactive mode. The default is `100`. | | `-d`, `--debug` | Enable debug logging for the current run. In interactive mode, use `/debug` to inspect or change debug logging after startup. | -| `-r `, `--resume ` | Resume a previous session by ID. This is for returning to a known conversation. | +| `-r `, `--resume ` | Resume a previous session by exact session ID, unique ID prefix, or unique session name. Cross-project resolved sessions print a `cd ... && iac-code --resume ` command instead of hot-swapping the current project. | | `-c`, `--continue` | Resume the most recent session. This cannot be used together with `--resume`. | | `--allowed-tools ` | Comma-separated tool permission patterns to allow, e.g. `'bash(git *),write_file'`. | | `--disallowed-tools ` | Comma-separated tool permission patterns to deny, e.g. `'bash(rm *)'`. | diff --git a/website/docs/cli/commands.md b/website/docs/cli/commands.md index a77dd7b..8257c29 100644 --- a/website/docs/cli/commands.md +++ b/website/docs/cli/commands.md @@ -20,7 +20,11 @@ Text after the command name is passed as arguments. In the table below, `` | `/effort [level]` | Show or change thinking effort for the active model when the selected model supports effort control. With a level, it applies the requested value if valid for the model. Without a level, it opens an interactive picker in the REPL, or prints the current effort in non-interactive contexts. | | `/exit` | Exit the interactive REPL. Aliases: `/quit`, `/q`. | | `/help` | Show available commands and common keyboard shortcuts inside the REPL. Alias: `/?`. | +| `/memory [\|search \|delete \|help]` | List, view, search, or delete saved memories. Natural-language memory creation is still handled by the assistant through the memory tool when you ask it to remember something. | | `/model [model_name]` | Show or switch the active model. With `model_name`, it switches directly to that model for the active provider. Without an argument, it opens an interactive model picker when a provider is configured, or prints the current model when no console UI is available. | -| `/resume [conversation id or search term]` | Resume a previous session. With an argument, IaC Code resolves it as a session ID or unique ID prefix. Without an argument, it opens the interactive session picker. Cross-project sessions print a `cd ... && iac-code --resume ` command instead of hot-swapping the current project. | +| `/rename ` | Name the current session. Names appear in the welcome banner, exit hint, and `/resume` picker, and can be used with `/resume` or `--resume` when they uniquely identify a session. | +| `/resume [session id\|unique id prefix\|unique session name]` | Resume a previous session. With an argument, IaC Code resolves it as an exact session ID, unique ID prefix, or unique session name. Without an argument, it opens the interactive session picker. Cross-project sessions print a `cd ... && iac-code --resume ` command instead of hot-swapping the current project. | +| `/skills` | Open the skill management picker. Search skills, sort by name/source/size, and enable or disable user and project skills. Bundled skills remain locked on. | +| `/status` | Show current session ID, provider, model, Alibaba Cloud region, working directory, recorded API token usage, turn count, and context utilization. | The exact command list can change between releases. Use `/help` or type `/` in the REPL to inspect the commands available in your installed version. diff --git a/website/docs/cli/interactive-mode.md b/website/docs/cli/interactive-mode.md index c1e536b..2e33bd3 100644 --- a/website/docs/cli/interactive-mode.md +++ b/website/docs/cli/interactive-mode.md @@ -25,6 +25,12 @@ Then describe what you want to build: Create a VPC, two ECS instances, and a security group that allows SSH from my office IP. ``` +## Commands + +Type `/` to discover available slash commands. Common operational commands include `/status` for the current session state, `/skills` for skill management, `/memory` for saved memories, `/rename` for naming the active session, and `/resume` for switching sessions. + +Type `$` to discover and invoke skills only. + ## Editing input Use `Shift+Enter` to insert a newline without sending the prompt. Press `Enter` diff --git a/website/docs/cli/sessions.md b/website/docs/cli/sessions.md index 2952b56..e10b12a 100644 --- a/website/docs/cli/sessions.md +++ b/website/docs/cli/sessions.md @@ -17,20 +17,37 @@ In the REPL, use the `/resume` command: /resume ``` -This opens an interactive picker showing recent sessions for the current project, with their last prompt as the title. +This opens an interactive picker showing recent sessions for the current project, with the session name as the title when set, otherwise the last prompt or first prompt fallback. -To resume a specific session by ID or ID prefix: +To resume a specific session by exact session ID, unique ID prefix, or unique session name: ```text /resume abc123 ``` +### Naming Sessions + +Use `/rename` to give the active session a stable, human-readable name: + +```text +/rename deploy-prod +``` + +The name is stored in the session metadata. It appears in the welcome banner when you resume, in the exit hint, and in the `/resume` picker. + +You can resume by name when it uniquely identifies a session: + +```text +/resume deploy-prod +iac-code --resume deploy-prod +``` + ### CLI: `--resume` and `--continue` -Resume a specific session from the command line: +Resume a specific session from the command line by exact session ID, unique ID prefix, or unique session name: ```bash -iac-code --resume +iac-code --resume ``` Resume the most recent session: @@ -42,7 +59,7 @@ iac-code --continue The short flags `-r` and `-c` are also available: ```bash -iac-code -r +iac-code -r iac-code -c ``` @@ -66,7 +83,7 @@ The `/resume` picker displays: | Column | Description | |--------|-------------| -| Title | Last user prompt (or first prompt if no metadata) | +| Title | Session name when set, otherwise last user prompt or first prompt | | Branch | Git branch at the time of the session | | Time | Last modification time | diff --git a/website/docs/cli/skills.md b/website/docs/cli/skills.md index bafcfd8..87a93cd 100644 --- a/website/docs/cli/skills.md +++ b/website/docs/cli/skills.md @@ -89,6 +89,14 @@ paths: | `agent` | No | `"general-purpose"` | Agent type for fork mode | | `paths` | No | `[]` | Glob patterns for path-based auto-activation | +## Managing Skills + +Run `/skills` in the interactive REPL to open the skill management picker. The picker lists discovered bundled, user, and project skills with their source, size, and enabled state. You can search by name or description, sort by name/source/size, and toggle user or project skills on and off. + +Disabled skills are saved in `settings.yml` under `disabled_skills`. Bundled skills are locked enabled and are not written to the disabled list. + +Use `$` when you want autocomplete and invocation to target skills only. This is useful when a skill name overlaps with ordinary text or when you want to avoid built-in slash commands. + ## Execution Modes ### Inline (default) @@ -188,3 +196,4 @@ Save this as `~/.iac-code/skills/checklist.md` or `.iac-code/skills/checklist.md - **Bundled skills** are always allowed automatically. - **User/project skills** with no shell commands and no `allowed_tools` are auto-allowed. - **Other skills** prompt for user confirmation on first use. +- **Disabled user/project skills** are hidden from model-visible skill listings and automatic triggers, and direct `skill` tool calls return a disabled-skill error. diff --git a/website/docs/configuration/alibaba-cloud-credentials.md b/website/docs/configuration/alibaba-cloud-credentials.md index d620dfa..abed55b 100644 --- a/website/docs/configuration/alibaba-cloud-credentials.md +++ b/website/docs/configuration/alibaba-cloud-credentials.md @@ -7,7 +7,23 @@ description: Configure Alibaba Cloud AccessKey or STS credentials. Alibaba Cloud credentials are required for operations that inspect or manage cloud resources. -Supported environment variables: +## OAuth Browser Login + +The recommended interactive setup path is `/auth`: + +```text +/auth +``` + +Choose **Configure IaC Cloud Service**, then **Alibaba Cloud**, then **OAuth Login (Browser)**. IaC Code opens a browser authorization flow, listens for the local callback, exchanges the authorization code with PKCE, and saves OAuth-backed temporary credentials to `.cloud-credentials.yml` under the IaC Code config directory. + +During setup you can choose the China or International OAuth site. IaC Code stores the selected site with the refresh token so future refreshes use the same endpoint. + +OAuth credentials are refreshed automatically when the access token or STS credentials are near expiration. If the refresh token expires or is revoked, run `/auth` again and choose OAuth Login (Browser). + +## Environment Variables + +Environment variables are still supported for AccessKey and STS workflows: | Variable | Description | |---|---| diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md index 00a3084..2b523ab 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -16,7 +16,7 @@ Befehlszeilenoptionen steuern, wie IaC Code gestartet wird. Sie können vor dem | `--output-format ` | Ausgabeformat für den nicht-interaktiven Modus festlegen. Unterstützte Werte sind `text`, `json` und `stream-json`. Standard ist `text`. | | `--max-turns ` | Maximale Anzahl der Agenten-Runden im nicht-interaktiven Modus begrenzen. Standard ist `100`. | | `-d`, `--debug` | Debug-Protokollierung für den aktuellen Lauf aktivieren. Im interaktiven Modus verwenden Sie `/debug`, um die Debug-Protokollierung nach dem Start zu prüfen oder zu ändern. | -| `-r `, `--resume ` | Eine vorherige Sitzung anhand der ID fortsetzen. Dies dient zum Zurückkehren zu einer bekannten Konversation. | +| `-r `, `--resume ` | Eine vorherige Sitzung über die exakte Sitzungs-ID, ein eindeutiges ID-Präfix oder einen eindeutigen Sitzungsnamen fortsetzen. Projektübergreifend aufgelöste Sitzungen geben einen `cd ... && iac-code --resume `-Befehl aus, statt das aktuelle Projekt direkt zu wechseln. | | `-c`, `--continue` | Die letzte Sitzung fortsetzen. Kann nicht zusammen mit `--resume` verwendet werden. | | `--allowed-tools ` | Kommagetrennte Werkzeug-Berechtigungsmuster zum Erlauben, z.B. `'bash(git *),write_file'`. | | `--disallowed-tools ` | Kommagetrennte Werkzeug-Berechtigungsmuster zum Verweigern, z.B. `'bash(rm *)'`. | diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md index 73c326e..814a395 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,11 @@ Text nach dem Befehlsnamen wird als Argumente uebergeben. In der folgenden Tabel | `/effort [level]` | Zeigen oder aendern Sie den Denkaufwand fuer das aktive Modell, wenn das ausgewaehlte Modell Aufwandsteuerung unterstuetzt. Mit einem Level wird der angeforderte Wert angewendet, wenn er fuer das Modell gueltig ist. Ohne Level wird im REPL eine interaktive Auswahl geoeffnet, oder der aktuelle Aufwand wird in nicht-interaktiven Kontexten ausgegeben. | | `/exit` | Beenden Sie das interaktive REPL. Aliase: `/quit`, `/q`. | | `/help` | Zeigen Sie verfuegbare Befehle und gaengige Tastenkuerzel im REPL an. Alias: `/?`. | +| `/memory [\|search \|delete \|help]` | Gespeicherte Erinnerungen auflisten, anzeigen, durchsuchen oder löschen. Das Erstellen von Erinnerungen in natürlicher Sprache erledigt weiterhin der Assistent über das Memory-Werkzeug, wenn Sie ihn bitten, sich etwas zu merken. | | `/model [model_name]` | Zeigen oder wechseln Sie das aktive Modell. Mit `model_name` wird direkt zu diesem Modell fuer den aktiven Anbieter gewechselt. Ohne Argument wird eine interaktive Modellauswahl geoeffnet, wenn ein Anbieter konfiguriert ist, oder das aktuelle Modell wird ausgegeben, wenn keine Konsolen-UI verfuegbar ist. | -| `/resume [conversation id or search term]` | Setzen Sie eine fruehere Sitzung fort. Mit einem Argument loest IaC Code es als Sitzungs-ID oder eindeutigen ID-Praefix auf. Ohne Argument wird die interaktive Sitzungsauswahl geoeffnet. Projektuebergreifende Sitzungen geben einen `cd ... && iac-code --resume ` Befehl aus, anstatt das aktuelle Projekt direkt zu wechseln. | +| `/rename ` | Die aktuelle Sitzung benennen. Namen erscheinen im Willkommensbanner, im Exit-Hinweis und in der `/resume`-Auswahl und können mit `/resume` oder `--resume` verwendet werden, wenn sie eine Sitzung eindeutig identifizieren. | +| `/resume [sitzungs-id\|eindeutiges-id-präfix\|eindeutiger-sitzungsname]` | Eine frühere Sitzung fortsetzen. Mit einem Argument löst IaC Code es als exakte Sitzungs-ID, eindeutiges ID-Präfix oder eindeutigen Sitzungsnamen auf. Ohne Argument wird die interaktive Sitzungsauswahl geöffnet. Projektübergreifende Sitzungen geben einen `cd ... && iac-code --resume `-Befehl aus, anstatt das aktuelle Projekt direkt zu wechseln. | +| `/skills` | Die Skill-Verwaltungsauswahl öffnen. Skills nach Name oder Beschreibung suchen, nach Name/Quelle/Größe sortieren und Benutzer- oder Projekt-Skills aktivieren oder deaktivieren. Gebündelte Skills bleiben gesperrt aktiviert. | +| `/status` | Aktuelle Sitzungs-ID, Anbieter, Modell, Alibaba Cloud-Region, Arbeitsverzeichnis, aufgezeichnete API-Token-Nutzung, Rundenzahl und Kontextauslastung anzeigen. | Die genaue Befehlsliste kann sich zwischen Versionen aendern. Verwenden Sie `/help` oder tippen Sie `/` im REPL, um die in Ihrer installierten Version verfuegbaren Befehle anzuzeigen. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index 53255d1..aaabb00 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -25,6 +25,12 @@ Beschreiben Sie dann, was Sie erstellen moechten: Create a VPC, two ECS instances, and a security group that allows SSH from my office IP. ``` +## Befehle + +Tippen Sie `/`, um verfügbare Slash-Befehle zu entdecken. Zu den häufigen Betriebsbefehlen gehören `/status` für den aktuellen Sitzungszustand, `/skills` für die Skill-Verwaltung, `/memory` für gespeicherte Erinnerungen, `/rename` zum Benennen der aktiven Sitzung und `/resume` zum Wechseln zwischen Sitzungen. + +Tippen Sie `$`, um ausschließlich Skills zu entdecken und aufzurufen. + ## Eingabe bearbeiten Verwenden Sie `Shift+Enter`, um eine neue Zeile einzufuegen, ohne den Prompt zu senden. Druecken Sie normales `Enter`, um den vollstaendigen Prompt zu senden. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/sessions.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/sessions.md index 2952b56..4d2f7bd 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/sessions.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/sessions.md @@ -1,73 +1,90 @@ --- -title: Sessions -description: Persist and resume conversations across runs. +title: Sitzungen +description: Konversationen über Läufe hinweg speichern und fortsetzen. --- -# Sessions +# Sitzungen -IaC Code automatically persists every conversation to disk. You can resume any previous session to continue where you left off. +IaC Code speichert jede Konversation automatisch auf der Festplatte. Sie können frühere Sitzungen fortsetzen und dort weiterarbeiten, wo Sie aufgehört haben. -## Resuming Sessions +## Sitzungen fortsetzen -### Interactive: `/resume` +### Interaktiv: `/resume` -In the REPL, use the `/resume` command: +Verwenden Sie im REPL den Befehl `/resume`: ```text /resume ``` -This opens an interactive picker showing recent sessions for the current project, with their last prompt as the title. +Dies öffnet eine interaktive Auswahl mit den letzten Sitzungen des aktuellen Projekts. Wenn ein Sitzungsname gesetzt ist, wird er als Titel angezeigt; sonst wird die letzte Eingabe oder ersatzweise die erste Eingabe verwendet. -To resume a specific session by ID or ID prefix: +Eine bestimmte Sitzung können Sie über die exakte Sitzungs-ID, ein eindeutiges ID-Präfix oder einen eindeutigen Sitzungsnamen fortsetzen: ```text /resume abc123 ``` -### CLI: `--resume` and `--continue` +### Sitzungen benennen -Resume a specific session from the command line: +Verwenden Sie `/rename`, um der aktiven Sitzung einen stabilen, gut lesbaren Namen zu geben: + +```text +/rename deploy-prod +``` + +Der Name wird in den Sitzungsmetadaten gespeichert. Er erscheint beim Fortsetzen im Willkommensbanner, im Exit-Hinweis und in der `/resume`-Auswahl. + +Wenn der Name eine Sitzung eindeutig identifiziert, können Sie damit fortsetzen: + +```text +/resume deploy-prod +iac-code --resume deploy-prod +``` + +### CLI: `--resume` und `--continue` + +Eine bestimmte Sitzung können Sie über die Befehlszeile per exakter Sitzungs-ID, eindeutigem ID-Präfix oder eindeutigem Sitzungsnamen fortsetzen: ```bash -iac-code --resume +iac-code --resume ``` -Resume the most recent session: +Die zuletzt verwendete Sitzung fortsetzen: ```bash iac-code --continue ``` -The short flags `-r` and `-c` are also available: +Die Kurzoptionen `-r` und `-c` sind ebenfalls verfügbar: ```bash -iac-code -r +iac-code -r iac-code -c ``` -### Cross-project Sessions +### Projektübergreifende Sitzungen -When a session belongs to a different project directory, IaC Code does not hot-swap the working directory. Instead, it prints the command to resume in the correct context: +Wenn eine Sitzung zu einem anderen Projektverzeichnis gehört, wechselt IaC Code das Arbeitsverzeichnis nicht direkt. Stattdessen wird ein Befehl ausgegeben, der die Sitzung im richtigen Kontext fortsetzt: ```text cd /path/to/other/project && iac-code --resume ``` -This command is also copied to the clipboard when possible. +Dieser Befehl wird nach Möglichkeit auch in die Zwischenablage kopiert. -## Interruption Recovery +## Wiederherstellung nach Unterbrechungen -If a session was interrupted mid-execution (e.g., the process was killed while a tool was running), IaC Code detects the orphaned tool calls on resume and appends synthetic error results. This allows the model to recover gracefully without getting stuck waiting for tool output that will never arrive. +Wenn eine Sitzung mitten in der Ausführung unterbrochen wurde, etwa weil der Prozess während eines Tool-Laufs beendet wurde, erkennt IaC Code beim Fortsetzen verwaiste Tool-Aufrufe und ergänzt synthetische Fehlerergebnisse. So kann das Modell sauber weiterarbeiten, statt dauerhaft auf Tool-Ausgabe zu warten, die nie eintreffen wird. -## Session Picker +## Sitzungsauswahl -The `/resume` picker displays: +Die `/resume`-Auswahl zeigt: -| Column | Description | -|--------|-------------| -| Title | Last user prompt (or first prompt if no metadata) | -| Branch | Git branch at the time of the session | -| Time | Last modification time | +| Spalte | Beschreibung | +|--------|--------------| +| Titel | Sitzungsname, falls gesetzt; sonst letzte oder erste Benutzereingabe | +| Branch | Git-Branch zum Zeitpunkt der Sitzung | +| Zeit | Letzte Änderungszeit | -Sessions are sorted by most recent first. You can type to filter by title content. +Sitzungen werden absteigend nach Aktualität sortiert. Sie können Text eingeben, um nach dem Titelinhalt zu filtern. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/skills.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/skills.md index bafcfd8..7733d0e 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/skills.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/skills.md @@ -89,6 +89,14 @@ paths: | `agent` | No | `"general-purpose"` | Agent type for fork mode | | `paths` | No | `[]` | Glob patterns for path-based auto-activation | +## Skills verwalten + +Führen Sie `/skills` im interaktiven REPL aus, um die Skill-Verwaltungsauswahl zu öffnen. Die Auswahl zeigt gefundene gebündelte, Benutzer- und Projekt-Skills mit Quelle, Größe und Aktivierungsstatus. Sie können nach Name oder Beschreibung suchen, nach Name/Quelle/Größe sortieren und Benutzer- oder Projekt-Skills ein- und ausschalten. + +Deaktivierte Skills werden in `settings.yml` unter `disabled_skills` gespeichert. Gebündelte Skills sind fest aktiviert und werden nicht in die Deaktivierungsliste geschrieben. + +Verwenden Sie `$`, wenn Autovervollständigung und Aufruf nur auf Skills zielen sollen. Das ist nützlich, wenn ein Skill-Name sich mit normalem Text überschneidet oder wenn Sie eingebaute Slash-Befehle vermeiden möchten. + ## Execution Modes ### Inline (default) @@ -188,3 +196,4 @@ Save this as `~/.iac-code/skills/checklist.md` or `.iac-code/skills/checklist.md - **Bundled skills** are always allowed automatically. - **User/project skills** with no shell commands and no `allowed_tools` are auto-allowed. - **Other skills** prompt for user confirmation on first use. +- **Deaktivierte Benutzer-/Projekt-Skills** werden aus modell-sichtbaren Skill-Listen und automatischen Triggern ausgeblendet; direkte `skill`-Tool-Aufrufe geben einen Fehler für deaktivierte Skills zurück. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md index 98c728f..c8b46c7 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md @@ -7,6 +7,22 @@ description: Konfigurieren Sie Alibaba Cloud AccessKey- oder STS-Anmeldedaten. Alibaba Cloud-Anmeldedaten werden fuer Operationen benoetigt, die Cloud-Ressourcen ueberpruefen oder verwalten. +## OAuth-Browser-Anmeldung + +Der empfohlene interaktive Einrichtungsweg ist `/auth`: + +```text +/auth +``` + +Wählen Sie **IaC-Cloud-Service konfigurieren**, dann **Alibaba Cloud** und anschließend **OAuth Login (Browser)**. IaC Code öffnet einen Browser-Autorisierungsablauf, wartet auf den lokalen Callback, tauscht den Autorisierungscode mit PKCE aus und speichert OAuth-gestützte temporäre Anmeldedaten in `.cloud-credentials.yml` im IaC-Code-Konfigurationsverzeichnis. + +Während der Einrichtung können Sie die China- oder International-OAuth-Site wählen. IaC Code speichert die ausgewählte Site zusammen mit dem Refresh Token, damit spätere Aktualisierungen denselben Endpunkt verwenden. + +OAuth-Anmeldedaten werden automatisch aktualisiert, wenn Access Token oder STS-Anmeldedaten bald ablaufen. Wenn der Refresh Token abläuft oder widerrufen wird, führen Sie erneut `/auth` aus und wählen Sie OAuth Login (Browser). + +## Umgebungsvariablen + Unterstuetzte Umgebungsvariablen: | Variable | Beschreibung | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md index 0f364e0..50594eb 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -16,7 +16,7 @@ Las opciones de línea de comandos cambian cómo se inicia IaC Code. Úselas ant | `--output-format ` | Establecer el formato de salida para el modo no interactivo. Los valores soportados son `text`, `json` y `stream-json`. El valor predeterminado es `text`. | | `--max-turns ` | Limitar el número máximo de turnos del agente en modo no interactivo. El valor predeterminado es `100`. | | `-d`, `--debug` | Habilitar el registro de depuración para la ejecución actual. En modo interactivo, use `/debug` para inspeccionar o cambiar el registro de depuración después del inicio. | -| `-r `, `--resume ` | Reanudar una sesión anterior por ID. Esto es para volver a una conversación conocida. | +| `-r `, `--resume ` | Reanudar una sesión anterior por ID exacto, prefijo único de ID o nombre único de sesión. Las sesiones resueltas en otro proyecto imprimen un comando `cd ... && iac-code --resume ` en lugar de cambiar en caliente el proyecto actual. | | `-c`, `--continue` | Reanudar la sesión más reciente. No se puede usar junto con `--resume`. | | `--allowed-tools ` | Patrones de permisos de herramientas separados por comas para permitir, ej. `'bash(git *),write_file'`. | | `--disallowed-tools ` | Patrones de permisos de herramientas separados por comas para denegar, ej. `'bash(rm *)'`. | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md index 0eb28f0..964e210 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,11 @@ El texto despues del nombre del comando se pasa como argumentos. En la tabla sig | `/effort [level]` | Muestra o cambia el esfuerzo de pensamiento para el modelo activo cuando el modelo seleccionado admite control de esfuerzo. Con un nivel, aplica el valor solicitado si es valido para el modelo. Sin un nivel, abre un selector interactivo en el REPL, o imprime el esfuerzo actual en contextos no interactivos. | | `/exit` | Sale del REPL interactivo. Alias: `/quit`, `/q`. | | `/help` | Muestra los comandos disponibles y los atajos de teclado comunes dentro del REPL. Alias: `/?`. | +| `/memory [\|search \|delete \|help]` | Listar, ver, buscar o eliminar memorias guardadas. La creación de memorias en lenguaje natural sigue a cargo del asistente mediante la herramienta de memoria cuando le pides que recuerde algo. | | `/model [model_name]` | Muestra o cambia el modelo activo. Con `model_name`, cambia directamente a ese modelo para el proveedor activo. Sin argumento, abre un selector interactivo de modelos cuando hay un proveedor configurado, o imprime el modelo actual cuando no hay interfaz de consola disponible. | -| `/resume [conversation id or search term]` | Reanuda una sesion anterior. Con un argumento, IaC Code lo resuelve como un ID de sesion o un prefijo de ID unico. Sin argumento, abre el selector interactivo de sesiones. Las sesiones de otros proyectos imprimen un comando `cd ... && iac-code --resume ` en lugar de intercambiar el proyecto actual. | +| `/rename ` | Nombrar la sesión actual. Los nombres aparecen en el banner de bienvenida, en la sugerencia de salida y en el selector de `/resume`, y pueden usarse con `/resume` o `--resume` cuando identifican una sesión de forma única. | +| `/resume [id-de-sesion\|prefijo-unico-de-id\|nombre-unico-de-sesion]` | Reanudar una sesión anterior. Con un argumento, IaC Code lo resuelve como ID exacto, prefijo único de ID o nombre único de sesión. Sin argumento, abre el selector interactivo de sesiones. Las sesiones de otros proyectos imprimen un comando `cd ... && iac-code --resume ` en lugar de cambiar en caliente el proyecto actual. | +| `/skills` | Abrir el selector de gestión de habilidades. Busca habilidades por nombre o descripción, ordena por nombre/origen/tamaño y activa o desactiva habilidades de usuario o de proyecto. Las habilidades incluidas permanecen bloqueadas y activadas. | +| `/status` | Mostrar el ID de sesión actual, proveedor, modelo, región de Alibaba Cloud, directorio de trabajo, uso registrado de tokens de API, número de turnos y utilización del contexto. | La lista exacta de comandos puede cambiar entre versiones. Usa `/help` o escribe `/` en el REPL para inspeccionar los comandos disponibles en tu version instalada. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index 02c4f1b..80811f1 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -25,6 +25,12 @@ Luego describe lo que quieres construir: Create a VPC, two ECS instances, and a security group that allows SSH from my office IP. ``` +## Comandos + +Escribe `/` para descubrir los comandos slash disponibles. Entre los comandos operativos habituales están `/status` para el estado de la sesión actual, `/skills` para gestionar habilidades, `/memory` para las memorias guardadas, `/rename` para nombrar la sesión activa y `/resume` para cambiar de sesión. + +Escribe `$` para descubrir e invocar solo habilidades. + ## Editar la entrada Usa `Shift+Enter` para insertar una nueva linea sin enviar el prompt. Pulsa `Enter` solo para enviar el prompt completo. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/sessions.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/sessions.md index 2952b56..65769d5 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/sessions.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/sessions.md @@ -1,73 +1,90 @@ --- -title: Sessions -description: Persist and resume conversations across runs. +title: Sesiones +description: Persistir y reanudar conversaciones entre ejecuciones. --- -# Sessions +# Sesiones -IaC Code automatically persists every conversation to disk. You can resume any previous session to continue where you left off. +IaC Code guarda automáticamente cada conversación en disco. Puedes reanudar cualquier sesión anterior para continuar donde la dejaste. -## Resuming Sessions +## Reanudar sesiones -### Interactive: `/resume` +### Interactivo: `/resume` -In the REPL, use the `/resume` command: +En el REPL, usa el comando `/resume`: ```text /resume ``` -This opens an interactive picker showing recent sessions for the current project, with their last prompt as the title. +Esto abre un selector interactivo con las sesiones recientes del proyecto actual. Si la sesión tiene nombre, se muestra como título; de lo contrario se usa el último prompt o, como alternativa, el primero. -To resume a specific session by ID or ID prefix: +Para reanudar una sesión concreta por ID exacto, prefijo único de ID o nombre único de sesión: ```text /resume abc123 ``` -### CLI: `--resume` and `--continue` +### Nombrar sesiones -Resume a specific session from the command line: +Usa `/rename` para dar a la sesión activa un nombre estable y legible: + +```text +/rename deploy-prod +``` + +El nombre se guarda en los metadatos de la sesión. Aparece en el banner de bienvenida al reanudar, en la sugerencia de salida y en el selector de `/resume`. + +Puedes reanudar por nombre cuando identifica una sesión de forma única: + +```text +/resume deploy-prod +iac-code --resume deploy-prod +``` + +### CLI: `--resume` y `--continue` + +Reanuda una sesión concreta desde la línea de comandos por ID exacto, prefijo único de ID o nombre único de sesión: ```bash -iac-code --resume +iac-code --resume ``` -Resume the most recent session: +Reanuda la sesión más reciente: ```bash iac-code --continue ``` -The short flags `-r` and `-c` are also available: +También están disponibles las opciones cortas `-r` y `-c`: ```bash -iac-code -r +iac-code -r iac-code -c ``` -### Cross-project Sessions +### Sesiones de otros proyectos -When a session belongs to a different project directory, IaC Code does not hot-swap the working directory. Instead, it prints the command to resume in the correct context: +Cuando una sesión pertenece a otro directorio de proyecto, IaC Code no cambia el directorio de trabajo en caliente. En su lugar, imprime el comando para reanudarla en el contexto correcto: ```text cd /path/to/other/project && iac-code --resume ``` -This command is also copied to the clipboard when possible. +El comando también se copia al portapapeles cuando es posible. -## Interruption Recovery +## Recuperación ante interrupciones -If a session was interrupted mid-execution (e.g., the process was killed while a tool was running), IaC Code detects the orphaned tool calls on resume and appends synthetic error results. This allows the model to recover gracefully without getting stuck waiting for tool output that will never arrive. +Si una sesión se interrumpió durante la ejecución, por ejemplo porque el proceso se terminó mientras una herramienta estaba en curso, IaC Code detecta las llamadas de herramienta huérfanas al reanudar y agrega resultados de error sintéticos. Esto permite que el modelo se recupere sin quedarse esperando una salida de herramienta que nunca llegará. -## Session Picker +## Selector de sesiones -The `/resume` picker displays: +El selector de `/resume` muestra: -| Column | Description | -|--------|-------------| -| Title | Last user prompt (or first prompt if no metadata) | -| Branch | Git branch at the time of the session | -| Time | Last modification time | +| Columna | Descripción | +|---------|-------------| +| Título | Nombre de sesión si existe; de lo contrario, último o primer prompt del usuario | +| Rama | Rama de Git en el momento de la sesión | +| Hora | Última hora de modificación | -Sessions are sorted by most recent first. You can type to filter by title content. +Las sesiones se ordenan de más reciente a más antigua. Puedes escribir para filtrar por contenido del título. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/skills.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/skills.md index bafcfd8..0cd3e1d 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/skills.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/skills.md @@ -89,6 +89,14 @@ paths: | `agent` | No | `"general-purpose"` | Agent type for fork mode | | `paths` | No | `[]` | Glob patterns for path-based auto-activation | +## Gestionar habilidades + +Ejecuta `/skills` en el REPL interactivo para abrir el selector de gestión de habilidades. El selector muestra las habilidades integradas, de usuario y de proyecto descubiertas, junto con su origen, tamaño y estado de habilitación. Puedes buscar por nombre o descripción, ordenar por nombre/origen/tamaño y activar o desactivar habilidades de usuario o de proyecto. + +Las habilidades deshabilitadas se guardan en `settings.yml` bajo `disabled_skills`. Las habilidades integradas permanecen siempre habilitadas y no se escriben en la lista de deshabilitadas. + +Usa `$` cuando quieras que el autocompletado y la invocación apunten solo a habilidades. Es útil cuando el nombre de una habilidad se solapa con texto normal o cuando quieres evitar los comandos slash integrados. + ## Execution Modes ### Inline (default) @@ -188,3 +196,4 @@ Save this as `~/.iac-code/skills/checklist.md` or `.iac-code/skills/checklist.md - **Bundled skills** are always allowed automatically. - **User/project skills** with no shell commands and no `allowed_tools` are auto-allowed. - **Other skills** prompt for user confirmation on first use. +- **Las habilidades de usuario/proyecto deshabilitadas** se ocultan de los listados visibles para el modelo y de los disparadores automáticos; las llamadas directas a la herramienta `skill` devuelven un error de habilidad deshabilitada. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md index 81a64de..8e90cce 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md @@ -7,6 +7,22 @@ description: Configurar credenciales de AccessKey o STS de Alibaba Cloud. Las credenciales de Alibaba Cloud son necesarias para las operaciones que inspeccionan o gestionan recursos en la nube. +## Inicio de sesión OAuth en el navegador + +La ruta de configuración interactiva recomendada es `/auth`: + +```text +/auth +``` + +Elige **Configurar servicio cloud de IaC**, luego **Alibaba Cloud** y después **OAuth Login (Browser)**. IaC Code abre un flujo de autorización en el navegador, espera la devolución de llamada local, intercambia el código de autorización con PKCE y guarda credenciales temporales respaldadas por OAuth en `.cloud-credentials.yml`, dentro del directorio de configuración de IaC Code. + +Durante la configuración puedes elegir el sitio OAuth de China o el internacional. IaC Code guarda el sitio elegido junto con el refresh token para que las actualizaciones posteriores usen el mismo endpoint. + +Las credenciales OAuth se actualizan automáticamente cuando el access token o las credenciales STS están por caducar. Si el refresh token caduca o se revoca, ejecuta `/auth` de nuevo y elige OAuth Login (Browser). + +## Variables de entorno + Variables de entorno soportadas: | Variable | Descripcion | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md index 9b6534a..bbea499 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -16,7 +16,7 @@ Les options de ligne de commande modifient le démarrage d'IaC Code. Utilisez-le | `--output-format ` | Définir le format de sortie pour le mode non interactif. Les valeurs prises en charge sont `text`, `json` et `stream-json`. La valeur par défaut est `text`. | | `--max-turns ` | Limiter le nombre maximum de tours de l'agent en mode non interactif. La valeur par défaut est `100`. | | `-d`, `--debug` | Activer la journalisation de débogage pour l'exécution en cours. En mode interactif, utilisez `/debug` pour inspecter ou modifier la journalisation de débogage après le démarrage. | -| `-r `, `--resume ` | Reprendre une session précédente par ID. Ceci permet de revenir à une conversation connue. | +| `-r `, `--resume ` | Reprendre une session précédente par identifiant exact, préfixe d'identifiant unique ou nom de session unique. Les sessions résolues dans un autre projet affichent une commande `cd ... && iac-code --resume ` au lieu de basculer le projet courant à chaud. | | `-c`, `--continue` | Reprendre la session la plus récente. Ne peut pas être utilisé avec `--resume`. | | `--allowed-tools ` | Modèles de permissions d'outils séparés par des virgules à autoriser, ex. `'bash(git *),write_file'`. | | `--disallowed-tools ` | Modèles de permissions d'outils séparés par des virgules à refuser, ex. `'bash(rm *)'`. | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md index e7edfdb..0a26d40 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,11 @@ Le texte après le nom de la commande est transmis comme arguments. Dans le tabl | `/effort [level]` | Afficher ou modifier l'effort de réflexion pour le modèle actif lorsque le modèle sélectionné prend en charge le contrôle d'effort. Avec un niveau, il applique la valeur demandée si elle est valide pour le modèle. Sans niveau, il ouvre un sélecteur interactif dans le REPL, ou affiche l'effort actuel dans les contextes non interactifs. | | `/exit` | Quitter le REPL interactif. Alias : `/quit`, `/q`. | | `/help` | Afficher les commandes disponibles et les raccourcis clavier courants dans le REPL. Alias : `/?`. | +| `/memory [\|search \|delete \|help]` | Lister, afficher, rechercher ou supprimer les mémoires enregistrées. La création de mémoires en langage naturel reste gérée par l'assistant via l'outil de mémoire lorsque vous lui demandez de se souvenir de quelque chose. | | `/model [model_name]` | Afficher ou changer le modèle actif. Avec `model_name`, il bascule directement vers ce modèle pour le fournisseur actif. Sans argument, il ouvre un sélecteur de modèle interactif lorsqu'un fournisseur est configuré, ou affiche le modèle actuel lorsqu'aucune interface console n'est disponible. | -| `/resume [conversation id or search term]` | Reprendre une session précédente. Avec un argument, IaC Code le résout comme un identifiant de session ou un préfixe d'identifiant unique. Sans argument, il ouvre le sélecteur de session interactif. Les sessions inter-projets affichent une commande `cd ... && iac-code --resume ` au lieu de basculer le projet actuel à chaud. | +| `/rename ` | Nommer la session actuelle. Les noms apparaissent dans la bannière d'accueil, l'indication de sortie et le sélecteur `/resume`, et peuvent être utilisés avec `/resume` ou `--resume` lorsqu'ils identifient une session de façon unique. | +| `/resume [id-de-session\|préfixe-id-unique\|nom-de-session-unique]` | Reprendre une session précédente. Avec un argument, IaC Code le résout comme identifiant exact, préfixe d'identifiant unique ou nom de session unique. Sans argument, il ouvre le sélecteur de session interactif. Les sessions inter-projets affichent une commande `cd ... && iac-code --resume ` au lieu de basculer le projet courant à chaud. | +| `/skills` | Ouvrir le sélecteur de gestion des compétences. Recherchez par nom ou description, triez par nom/source/taille et activez ou désactivez les compétences utilisateur ou projet. Les compétences intégrées restent verrouillées et activées. | +| `/status` | Afficher l'ID de session actuel, le fournisseur, le modèle, la région Alibaba Cloud, le répertoire de travail, l'utilisation enregistrée des tokens d'API, le nombre de tours et l'utilisation du contexte. | La liste exacte des commandes peut varier entre les versions. Utilisez `/help` ou tapez `/` dans le REPL pour inspecter les commandes disponibles dans votre version installée. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index c8f39a2..139a0c7 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -25,6 +25,12 @@ Puis décrivez ce que vous souhaitez construire : Create a VPC, two ECS instances, and a security group that allows SSH from my office IP. ``` +## Commandes + +Tapez `/` pour découvrir les commandes slash disponibles. Les commandes opérationnelles courantes incluent `/status` pour l'état de la session actuelle, `/skills` pour la gestion des compétences, `/memory` pour les mémoires enregistrées, `/rename` pour nommer la session active et `/resume` pour changer de session. + +Tapez `$` pour découvrir et invoquer uniquement des compétences. + ## Modifier la saisie Utilisez `Shift+Enter` pour insérer une nouvelle ligne sans envoyer le prompt. Appuyez sur `Enter` seul pour envoyer le prompt complet. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/sessions.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/sessions.md index 2952b56..e587eb1 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/sessions.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/sessions.md @@ -1,73 +1,90 @@ --- title: Sessions -description: Persist and resume conversations across runs. +description: Conserver et reprendre les conversations entre les exécutions. --- # Sessions -IaC Code automatically persists every conversation to disk. You can resume any previous session to continue where you left off. +IaC Code enregistre automatiquement chaque conversation sur disque. Vous pouvez reprendre n'importe quelle session précédente pour continuer là où vous vous étiez arrêté. -## Resuming Sessions +## Reprendre des sessions -### Interactive: `/resume` +### Interactif : `/resume` -In the REPL, use the `/resume` command: +Dans le REPL, utilisez la commande `/resume` : ```text /resume ``` -This opens an interactive picker showing recent sessions for the current project, with their last prompt as the title. +Cela ouvre un sélecteur interactif qui affiche les sessions récentes du projet courant. Si un nom de session est défini, il sert de titre ; sinon le dernier prompt, ou à défaut le premier prompt, est utilisé. -To resume a specific session by ID or ID prefix: +Pour reprendre une session précise par identifiant exact, préfixe d'identifiant unique ou nom de session unique : ```text /resume abc123 ``` -### CLI: `--resume` and `--continue` +### Nommer les sessions -Resume a specific session from the command line: +Utilisez `/rename` pour donner à la session active un nom stable et lisible : + +```text +/rename deploy-prod +``` + +Le nom est stocké dans les métadonnées de session. Il apparaît dans la bannière d'accueil lors de la reprise, dans l'indication de sortie et dans le sélecteur `/resume`. + +Vous pouvez reprendre par nom lorsqu'il identifie une session de façon unique : + +```text +/resume deploy-prod +iac-code --resume deploy-prod +``` + +### CLI : `--resume` et `--continue` + +Reprendre une session précise depuis la ligne de commande par identifiant exact, préfixe d'identifiant unique ou nom de session unique : ```bash -iac-code --resume +iac-code --resume ``` -Resume the most recent session: +Reprendre la session la plus récente : ```bash iac-code --continue ``` -The short flags `-r` and `-c` are also available: +Les options courtes `-r` et `-c` sont également disponibles : ```bash -iac-code -r +iac-code -r iac-code -c ``` -### Cross-project Sessions +### Sessions inter-projets -When a session belongs to a different project directory, IaC Code does not hot-swap the working directory. Instead, it prints the command to resume in the correct context: +Lorsqu'une session appartient à un autre répertoire de projet, IaC Code ne change pas le répertoire de travail à chaud. Il affiche plutôt la commande permettant de reprendre dans le bon contexte : ```text cd /path/to/other/project && iac-code --resume ``` -This command is also copied to the clipboard when possible. +Cette commande est aussi copiée dans le presse-papiers lorsque c'est possible. -## Interruption Recovery +## Récupération après interruption -If a session was interrupted mid-execution (e.g., the process was killed while a tool was running), IaC Code detects the orphaned tool calls on resume and appends synthetic error results. This allows the model to recover gracefully without getting stuck waiting for tool output that will never arrive. +Si une session a été interrompue pendant l'exécution, par exemple parce que le processus a été tué pendant qu'un outil tournait, IaC Code détecte les appels d'outil orphelins à la reprise et ajoute des résultats d'erreur synthétiques. Le modèle peut ainsi se rétablir proprement sans rester bloqué en attendant une sortie d'outil qui n'arrivera jamais. -## Session Picker +## Sélecteur de sessions -The `/resume` picker displays: +Le sélecteur `/resume` affiche : -| Column | Description | -|--------|-------------| -| Title | Last user prompt (or first prompt if no metadata) | -| Branch | Git branch at the time of the session | -| Time | Last modification time | +| Colonne | Description | +|---------|-------------| +| Titre | Nom de session s'il existe ; sinon dernier ou premier prompt utilisateur | +| Branche | Branche Git au moment de la session | +| Heure | Dernière modification | -Sessions are sorted by most recent first. You can type to filter by title content. +Les sessions sont triées de la plus récente à la plus ancienne. Vous pouvez taper du texte pour filtrer par contenu du titre. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/skills.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/skills.md index bafcfd8..7a07e16 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/skills.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/skills.md @@ -89,6 +89,14 @@ paths: | `agent` | No | `"general-purpose"` | Agent type for fork mode | | `paths` | No | `[]` | Glob patterns for path-based auto-activation | +## Gérer les compétences + +Exécutez `/skills` dans le REPL interactif pour ouvrir le sélecteur de gestion des compétences. Le sélecteur affiche les compétences intégrées, utilisateur et projet détectées, avec leur source, leur taille et leur état d'activation. Vous pouvez rechercher par nom ou description, trier par nom/source/taille, et activer ou désactiver les compétences utilisateur ou projet. + +Les compétences désactivées sont enregistrées dans `settings.yml` sous `disabled_skills`. Les compétences intégrées restent verrouillées comme activées et ne sont pas écrites dans la liste des désactivations. + +Utilisez `$` lorsque vous voulez limiter l'autocomplétion et l'appel aux compétences uniquement. C'est utile lorsqu'un nom de compétence recoupe du texte ordinaire ou lorsque vous voulez éviter les commandes slash intégrées. + ## Execution Modes ### Inline (default) @@ -188,3 +196,4 @@ Save this as `~/.iac-code/skills/checklist.md` or `.iac-code/skills/checklist.md - **Bundled skills** are always allowed automatically. - **User/project skills** with no shell commands and no `allowed_tools` are auto-allowed. - **Other skills** prompt for user confirmation on first use. +- **Les compétences utilisateur/projet désactivées** sont masquées des listes visibles par le modèle et des déclencheurs automatiques ; les appels directs à l'outil `skill` renvoient une erreur de compétence désactivée. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md index 926332a..18a573d 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md @@ -7,6 +7,22 @@ description: Configurer les identifiants AccessKey ou STS d'Alibaba Cloud. Les identifiants Alibaba Cloud sont requis pour les opérations qui inspectent ou gèrent des ressources cloud. +## Connexion OAuth dans le navigateur + +Le chemin de configuration interactive recommandé est `/auth` : + +```text +/auth +``` + +Choisissez **Configurer le service cloud IaC**, puis **Alibaba Cloud**, puis **OAuth Login (Browser)**. IaC Code ouvre un flux d'autorisation dans le navigateur, attend le callback local, échange le code d'autorisation avec PKCE et enregistre des identifiants temporaires adossés à OAuth dans `.cloud-credentials.yml`, dans le répertoire de configuration d'IaC Code. + +Pendant la configuration, vous pouvez choisir le site OAuth Chine ou international. IaC Code enregistre le site choisi avec le refresh token afin que les actualisations ultérieures utilisent le même endpoint. + +Les identifiants OAuth sont actualisés automatiquement lorsque l'access token ou les identifiants STS arrivent bientôt à expiration. Si le refresh token expire ou est révoqué, exécutez de nouveau `/auth` et choisissez OAuth Login (Browser). + +## Variables d'environnement + Variables d'environnement prises en charge : | Variable | Description | diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md index e07fb2a..a41b377 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -16,7 +16,7 @@ description: IaC Code の起動オプションとワンショット実行パラ | `--output-format ` | 非対話モードの出力形式を設定します。サポートされる値は `text`、`json`、`stream-json` です。デフォルトは `text` です。 | | `--max-turns ` | 非対話モードでのエージェントの最大ターン数を制限します。デフォルトは `100` です。 | | `-d`, `--debug` | 今回の実行でデバッグログを有効にします。対話モードでは、起動後に `/debug` を使用してデバッグログを確認または変更できます。 | -| `-r `, `--resume ` | ID でセッションを再開します。既知の会話に戻るために使用します。 | +| `-r <セッションIDまたは名前>`, `--resume <セッションIDまたは名前>` | 正確なセッション ID、一意な ID プレフィックス、または一意なセッション名で以前のセッションを再開します。別プロジェクトとして解決されたセッションは、現在のプロジェクトをその場で切り替えず、`cd ... && iac-code --resume ` コマンドを表示します。 | | `-c`, `--continue` | 最新のセッションを再開します。`--resume` と同時に使用できません。 | | `--allowed-tools ` | 許可するツール権限パターンをカンマ区切りで指定します。例:`'bash(git *),write_file'`。 | | `--disallowed-tools ` | 拒否するツール権限パターンをカンマ区切りで指定します。例:`'bash(rm *)'`。 | diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md index 6b3bcf0..03c7e68 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,11 @@ description: 組み込み対話コマンドの完全リファレンス。 | `/effort [level]` | 選択したモデルがエフォート制御をサポートしている場合、アクティブモデルの思考エフォートを表示または変更します。レベルを指定すると、モデルに対して有効な値であれば適用します。レベルなしでは REPL で対話ピッカーを開くか、非対話コンテキストでは現在のエフォートを表示します。 | | `/exit` | 対話 REPL を終了します。エイリアス:`/quit`、`/q`。 | | `/help` | REPL 内で利用可能なコマンドと一般的なキーボードショートカットを表示します。エイリアス:`/?`。 | +| `/memory [<名前>\|search <クエリ>\|delete <名前>\|help]` | 保存済みメモリの一覧表示、表示、検索、削除を行います。自然言語でのメモリ作成は、何かを覚えるよう依頼したときに、引き続きアシスタントがメモリツールを通じて処理します。 | | `/model [model_name]` | アクティブなモデルを表示または切り替えます。`model_name` を指定すると、アクティブプロバイダーのそのモデルに直接切り替えます。引数なしでは、プロバイダーが設定されている場合は対話モデルピッカーを開き、コンソール UI が利用できない場合は現在のモデルを表示します。 | -| `/resume [conversation id or search term]` | 前のセッションを再開します。引数を指定すると、セッション ID またはユニーク ID プレフィックスとして解決します。引数なしでは対話セッションピッカーを開きます。プロジェクト間のセッションでは、現在のプロジェクトをホットスワップする代わりに `cd ... && iac-code --resume ` コマンドを表示します。 | +| `/rename <名前>` | 現在のセッションに名前を付けます。名前はウェルカムバナー、終了時のヒント、`/resume` ピッカーに表示され、一意にセッションを識別できる場合は `/resume` または `--resume` で使用できます。 | +| `/resume [セッションID\|一意なIDプレフィックス\|一意なセッション名]` | 以前のセッションを再開します。引数を指定すると、IaC Code は正確なセッション ID、一意な ID プレフィックス、または一意なセッション名として解決します。引数なしでは対話セッションピッカーを開きます。プロジェクト間のセッションでは、現在のプロジェクトをその場で切り替えず、`cd ... && iac-code --resume ` コマンドを表示します。 | +| `/skills` | スキル管理ピッカーを開きます。名前や説明でスキルを検索し、名前/ソース/サイズで並べ替え、ユーザーまたはプロジェクトのスキルを有効化/無効化できます。バンドル済みスキルは有効なままロックされます。 | +| `/status` | 現在のセッション ID、プロバイダー、モデル、Alibaba Cloud リージョン、作業ディレクトリ、記録された API トークン使用量、ターン数、コンテキスト使用率を表示します。 | 正確なコマンドリストはリリースによって変わる可能性があります。インストールされたバージョンで利用可能なコマンドを確認するには `/help` を使用するか、REPL で `/` を入力してください。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index 81031eb..c3a7837 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -25,6 +25,12 @@ iac-code Create a VPC, two ECS instances, and a security group that allows SSH from my office IP. ``` +## コマンド + +`/` を入力すると、利用可能なスラッシュコマンドを確認できます。よく使う運用コマンドには、現在のセッション状態を表示する `/status`、スキル管理の `/skills`、保存済みメモリの `/memory`、アクティブなセッションに名前を付ける `/rename`、セッションを切り替える `/resume` があります。 + +`$` を入力すると、スキルだけを検索して呼び出せます。 + ## 入力の編集 `Shift+Enter` を使うと、プロンプトを送信せずに改行を挿入できます。完全なプロンプトを送信するには、通常の `Enter` を押します。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/sessions.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/sessions.md index 2952b56..ae8e0c3 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/sessions.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/sessions.md @@ -1,73 +1,90 @@ --- -title: Sessions -description: Persist and resume conversations across runs. +title: セッション +description: 実行をまたいで会話を保存し、再開します。 --- -# Sessions +# セッション -IaC Code automatically persists every conversation to disk. You can resume any previous session to continue where you left off. +IaC Code はすべての会話を自動的にディスクへ保存します。以前のセッションを再開して、中断したところから作業を続けられます。 -## Resuming Sessions +## セッションの再開 -### Interactive: `/resume` +### 対話式:`/resume` -In the REPL, use the `/resume` command: +REPL では `/resume` コマンドを使用します: ```text /resume ``` -This opens an interactive picker showing recent sessions for the current project, with their last prompt as the title. +これにより、現在のプロジェクトの最近のセッションを表示する対話ピッカーが開きます。セッション名が設定されている場合はそれをタイトルとして表示し、未設定の場合は最後のプロンプト、または最初のプロンプトを代替として表示します。 -To resume a specific session by ID or ID prefix: +正確なセッション ID、一意な ID プレフィックス、または一意なセッション名で特定のセッションを再開できます: ```text /resume abc123 ``` -### CLI: `--resume` and `--continue` +### セッションに名前を付ける -Resume a specific session from the command line: +`/rename` を使うと、アクティブなセッションに安定した読みやすい名前を付けられます: + +```text +/rename deploy-prod +``` + +名前はセッションメタデータに保存されます。再開時のウェルカムバナー、終了時のヒント、`/resume` ピッカーに表示されます。 + +名前がセッションを一意に識別する場合は、その名前で再開できます: + +```text +/resume deploy-prod +iac-code --resume deploy-prod +``` + +### CLI:`--resume` と `--continue` + +コマンドラインから、正確なセッション ID、一意な ID プレフィックス、または一意なセッション名で特定のセッションを再開できます: ```bash -iac-code --resume +iac-code --resume <セッションIDまたは名前> ``` -Resume the most recent session: +最新のセッションを再開します: ```bash iac-code --continue ``` -The short flags `-r` and `-c` are also available: +短いオプション `-r` と `-c` も利用できます: ```bash -iac-code -r +iac-code -r <セッションIDまたは名前> iac-code -c ``` -### Cross-project Sessions +### プロジェクト間セッション -When a session belongs to a different project directory, IaC Code does not hot-swap the working directory. Instead, it prints the command to resume in the correct context: +セッションが別のプロジェクトディレクトリに属している場合、IaC Code は作業ディレクトリをその場で切り替えません。代わりに、正しいコンテキストで再開するためのコマンドを表示します: ```text cd /path/to/other/project && iac-code --resume ``` -This command is also copied to the clipboard when possible. +可能な場合、このコマンドはクリップボードにもコピーされます。 -## Interruption Recovery +## 中断からの復旧 -If a session was interrupted mid-execution (e.g., the process was killed while a tool was running), IaC Code detects the orphaned tool calls on resume and appends synthetic error results. This allows the model to recover gracefully without getting stuck waiting for tool output that will never arrive. +ツール実行中にプロセスが終了した場合など、セッションが実行途中で中断された場合、IaC Code は再開時に孤立したツール呼び出しを検出し、合成されたエラー結果を追加します。これにより、モデルは届くことのないツール出力を待ち続けずに復旧できます。 -## Session Picker +## セッションピッカー -The `/resume` picker displays: +`/resume` ピッカーには次の情報が表示されます: -| Column | Description | -|--------|-------------| -| Title | Last user prompt (or first prompt if no metadata) | -| Branch | Git branch at the time of the session | -| Time | Last modification time | +| 列 | 説明 | +|----|------| +| タイトル | 設定済みのセッション名、または最後/最初のユーザープロンプト | +| ブランチ | セッション時点の Git ブランチ | +| 時刻 | 最終更新時刻 | -Sessions are sorted by most recent first. You can type to filter by title content. +セッションは新しい順に並びます。タイトル内容で絞り込むために文字を入力できます。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/skills.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/skills.md index bafcfd8..23dcbb1 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/skills.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/skills.md @@ -89,6 +89,14 @@ paths: | `agent` | No | `"general-purpose"` | Agent type for fork mode | | `paths` | No | `[]` | Glob patterns for path-based auto-activation | +## スキルの管理 + +インタラクティブ REPL で `/skills` を実行すると、スキル管理ピッカーを開けます。ピッカーには検出された組み込みスキル、ユーザースキル、プロジェクトスキルが表示され、ソース、サイズ、有効状態を確認できます。名前または説明で検索し、名前/ソース/サイズで並べ替え、ユーザースキルまたはプロジェクトスキルを有効化・無効化できます。 + +無効化されたスキルは `settings.yml` の `disabled_skills` に保存されます。組み込みスキルは常に有効に固定され、無効化リストには書き込まれません。 + +オートコンプリートと呼び出し対象をスキルだけに絞りたい場合は `$` を使用します。スキル名が通常のテキストと重なる場合や、組み込み Slash コマンドを避けたい場合に便利です。 + ## Execution Modes ### Inline (default) @@ -188,3 +196,4 @@ Save this as `~/.iac-code/skills/checklist.md` or `.iac-code/skills/checklist.md - **Bundled skills** are always allowed automatically. - **User/project skills** with no shell commands and no `allowed_tools` are auto-allowed. - **Other skills** prompt for user confirmation on first use. +- **無効化されたユーザー/プロジェクトスキル**は、モデルから見えるスキル一覧と自動トリガーから非表示になり、直接の `skill` ツール呼び出しは無効化スキルのエラーを返します。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md index 3c92909..703a06d 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md @@ -7,6 +7,22 @@ description: Alibaba Cloud の AccessKey または STS 認証情報の設定。 Alibaba Cloud の認証情報は、クラウドリソースの検査や管理を行う操作に必要です。 +## OAuth ブラウザログイン + +推奨される対話型セットアップ手順は `/auth` です。 + +```text +/auth +``` + +**IaC クラウドサービスを設定**、**Alibaba Cloud**、**OAuth Login (Browser)** の順に選択します。IaC Code はブラウザの認可フローを開き、ローカル callback を待ち受け、PKCE で認可コードを交換して、OAuth に基づく一時認証情報を IaC Code 設定ディレクトリ内の `.cloud-credentials.yml` に保存します。 + +セットアップ中に、中国または国際版の OAuth サイトを選択できます。IaC Code は選択したサイトを refresh token と一緒に保存し、以降の更新で同じ endpoint を使用します。 + +access token または STS 認証情報の有効期限が近づくと、OAuth 認証情報は自動的に更新されます。refresh token の有効期限が切れた場合、または取り消された場合は、もう一度 `/auth` を実行して OAuth Login (Browser) を選択してください。 + +## 環境変数 + サポートされる環境変数: | 変数 | 説明 | diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md index c2f5a53..745850f 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -16,7 +16,7 @@ As opções de linha de comando alteram como o IaC Code é iniciado. Use-as ante | `--output-format ` | Definir o formato de saída para o modo não interativo. Os valores suportados são `text`, `json` e `stream-json`. O padrão é `text`. | | `--max-turns ` | Limitar o número máximo de turnos do agente no modo não interativo. O padrão é `100`. | | `-d`, `--debug` | Ativar o registro de depuração para a execução atual. No modo interativo, use `/debug` para inspecionar ou alterar o registro de depuração após a inicialização. | -| `-r `, `--resume ` | Retomar uma sessão anterior por ID. Para retornar a uma conversa conhecida. | +| `-r `, `--resume ` | Retomar uma sessão anterior por ID exato, prefixo único de ID ou nome único de sessão. Sessões resolvidas em outro projeto imprimem um comando `cd ... && iac-code --resume ` em vez de trocar o projeto atual em tempo real. | | `-c`, `--continue` | Retomar a sessão mais recente. Não pode ser usado junto com `--resume`. | | `--allowed-tools ` | Padrões de permissão de ferramentas separados por vírgulas para permitir, ex. `'bash(git *),write_file'`. | | `--disallowed-tools ` | Padrões de permissão de ferramentas separados por vírgulas para negar, ex. `'bash(rm *)'`. | diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md index c30feba..33d9541 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,11 @@ O texto apos o nome do comando e passado como argumentos. Na tabela abaixo, `\|search \|delete \|help]` | Listar, ver, pesquisar ou excluir memórias salvas. A criação de memórias em linguagem natural continua sendo feita pelo assistente por meio da ferramenta de memória quando você pede que ele se lembre de algo. | | `/model [model_name]` | Mostra ou troca o modelo ativo. Com `model_name`, troca diretamente para esse modelo no provedor ativo. Sem argumento, abre um seletor interativo de modelos quando um provedor esta configurado, ou imprime o modelo atual quando nao ha UI de console disponivel. | -| `/resume [conversation id or search term]` | Retoma uma sessao anterior. Com um argumento, o IaC Code resolve-o como um ID de sessao ou prefixo de ID unico. Sem argumento, abre o seletor interativo de sessoes. Sessoes de outros projetos imprimem um comando `cd ... && iac-code --resume ` em vez de trocar o projeto atual. | +| `/rename ` | Nomear a sessão atual. Os nomes aparecem no banner de boas-vindas, na dica de saída e no seletor de `/resume`, e podem ser usados com `/resume` ou `--resume` quando identificam uma sessão de forma única. | +| `/resume [id-da-sessao\|prefixo-unico-de-id\|nome-unico-da-sessao]` | Retomar uma sessão anterior. Com um argumento, o IaC Code resolve-o como ID exato, prefixo único de ID ou nome único de sessão. Sem argumento, abre o seletor interativo de sessões. Sessões de outros projetos imprimem um comando `cd ... && iac-code --resume ` em vez de trocar o projeto atual em tempo real. | +| `/skills` | Abrir o seletor de gerenciamento de habilidades. Pesquise por nome ou descrição, ordene por nome/origem/tamanho e ative ou desative habilidades de usuário ou de projeto. Habilidades integradas permanecem bloqueadas e ativadas. | +| `/status` | Mostrar o ID da sessão atual, provedor, modelo, região da Alibaba Cloud, diretório de trabalho, uso registrado de tokens de API, contagem de turnos e utilização do contexto. | A lista exata de comandos pode mudar entre versoes. Use `/help` ou digite `/` no REPL para inspecionar os comandos disponiveis na sua versao instalada. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index a62a058..3d6a544 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -25,6 +25,12 @@ Em seguida, descreva o que deseja construir: Create a VPC, two ECS instances, and a security group that allows SSH from my office IP. ``` +## Comandos + +Digite `/` para descobrir os comandos slash disponíveis. Comandos operacionais comuns incluem `/status` para o estado da sessão atual, `/skills` para gerenciamento de habilidades, `/memory` para memórias salvas, `/rename` para nomear a sessão ativa e `/resume` para alternar sessões. + +Digite `$` para descobrir e invocar apenas habilidades. + ## Editar entrada Use `Shift+Enter` para inserir uma nova linha sem enviar o prompt. Pressione `Enter` sozinho para enviar o prompt completo. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/sessions.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/sessions.md index 2952b56..3be4923 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/sessions.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/sessions.md @@ -1,73 +1,90 @@ --- -title: Sessions -description: Persist and resume conversations across runs. +title: Sessões +description: Persistir e retomar conversas entre execuções. --- -# Sessions +# Sessões -IaC Code automatically persists every conversation to disk. You can resume any previous session to continue where you left off. +O IaC Code persiste automaticamente cada conversa em disco. Você pode retomar qualquer sessão anterior para continuar de onde parou. -## Resuming Sessions +## Retomar sessões -### Interactive: `/resume` +### Interativo: `/resume` -In the REPL, use the `/resume` command: +No REPL, use o comando `/resume`: ```text /resume ``` -This opens an interactive picker showing recent sessions for the current project, with their last prompt as the title. +Isso abre um seletor interativo com as sessões recentes do projeto atual. Quando um nome de sessão está definido, ele aparece como título; caso contrário, o último prompt ou, como fallback, o primeiro prompt é usado. -To resume a specific session by ID or ID prefix: +Para retomar uma sessão específica por ID exato, prefixo único de ID ou nome único de sessão: ```text /resume abc123 ``` -### CLI: `--resume` and `--continue` +### Nomear sessões -Resume a specific session from the command line: +Use `/rename` para dar à sessão ativa um nome estável e legível: + +```text +/rename deploy-prod +``` + +O nome é armazenado nos metadados da sessão. Ele aparece no banner de boas-vindas ao retomar, na dica de saída e no seletor de `/resume`. + +Você pode retomar pelo nome quando ele identifica uma sessão de forma única: + +```text +/resume deploy-prod +iac-code --resume deploy-prod +``` + +### CLI: `--resume` e `--continue` + +Retome uma sessão específica pela linha de comando por ID exato, prefixo único de ID ou nome único de sessão: ```bash -iac-code --resume +iac-code --resume ``` -Resume the most recent session: +Retome a sessão mais recente: ```bash iac-code --continue ``` -The short flags `-r` and `-c` are also available: +As opções curtas `-r` e `-c` também estão disponíveis: ```bash -iac-code -r +iac-code -r iac-code -c ``` -### Cross-project Sessions +### Sessões entre projetos -When a session belongs to a different project directory, IaC Code does not hot-swap the working directory. Instead, it prints the command to resume in the correct context: +Quando uma sessão pertence a outro diretório de projeto, o IaC Code não troca o diretório de trabalho em tempo real. Em vez disso, imprime o comando para retomar no contexto correto: ```text cd /path/to/other/project && iac-code --resume ``` -This command is also copied to the clipboard when possible. +Esse comando também é copiado para a área de transferência quando possível. -## Interruption Recovery +## Recuperação de interrupções -If a session was interrupted mid-execution (e.g., the process was killed while a tool was running), IaC Code detects the orphaned tool calls on resume and appends synthetic error results. This allows the model to recover gracefully without getting stuck waiting for tool output that will never arrive. +Se uma sessão foi interrompida durante a execução, por exemplo porque o processo foi encerrado enquanto uma ferramenta estava rodando, o IaC Code detecta as chamadas de ferramenta órfãs ao retomar e adiciona resultados de erro sintéticos. Isso permite que o modelo se recupere sem ficar preso aguardando uma saída de ferramenta que nunca chegará. -## Session Picker +## Seletor de sessões -The `/resume` picker displays: +O seletor de `/resume` mostra: -| Column | Description | -|--------|-------------| -| Title | Last user prompt (or first prompt if no metadata) | -| Branch | Git branch at the time of the session | -| Time | Last modification time | +| Coluna | Descrição | +|--------|-----------| +| Título | Nome da sessão quando definido; caso contrário, último ou primeiro prompt do usuário | +| Branch | Branch Git no momento da sessão | +| Hora | Última modificação | -Sessions are sorted by most recent first. You can type to filter by title content. +As sessões são ordenadas da mais recente para a mais antiga. Você pode digitar para filtrar pelo conteúdo do título. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/skills.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/skills.md index bafcfd8..6c920d8 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/skills.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/skills.md @@ -89,6 +89,14 @@ paths: | `agent` | No | `"general-purpose"` | Agent type for fork mode | | `paths` | No | `[]` | Glob patterns for path-based auto-activation | +## Gerenciar habilidades + +Execute `/skills` no REPL interativo para abrir o seletor de gerenciamento de habilidades. O seletor lista as habilidades integradas, de usuário e de projeto descobertas, com origem, tamanho e estado de habilitação. Você pode pesquisar por nome ou descrição, ordenar por nome/origem/tamanho e ativar ou desativar habilidades de usuário ou de projeto. + +As habilidades desabilitadas são salvas em `settings.yml` em `disabled_skills`. As habilidades integradas ficam sempre habilitadas e não são gravadas na lista de desabilitadas. + +Use `$` quando quiser que o autocompletar e a invocação apontem apenas para habilidades. Isso é útil quando o nome de uma habilidade se sobrepõe a texto comum ou quando você quer evitar comandos slash integrados. + ## Execution Modes ### Inline (default) @@ -188,3 +196,4 @@ Save this as `~/.iac-code/skills/checklist.md` or `.iac-code/skills/checklist.md - **Bundled skills** are always allowed automatically. - **User/project skills** with no shell commands and no `allowed_tools` are auto-allowed. - **Other skills** prompt for user confirmation on first use. +- **Habilidades de usuário/projeto desabilitadas** ficam ocultas das listas visíveis ao modelo e dos gatilhos automáticos; chamadas diretas à ferramenta `skill` retornam um erro de habilidade desabilitada. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md index 0c4e671..a2f6b58 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md @@ -7,6 +7,22 @@ description: Configure credenciais AccessKey ou STS da Alibaba Cloud. As credenciais da Alibaba Cloud sao necessarias para operacoes que inspecionam ou gerenciam recursos na nuvem. +## Login OAuth no navegador + +O caminho de configuração interativa recomendado é `/auth`: + +```text +/auth +``` + +Escolha **Configurar serviço de nuvem IaC**, depois **Alibaba Cloud** e então **OAuth Login (Browser)**. O IaC Code abre um fluxo de autorização no navegador, aguarda o callback local, troca o código de autorização com PKCE e salva credenciais temporárias baseadas em OAuth em `.cloud-credentials.yml`, no diretório de configuração do IaC Code. + +Durante a configuração, você pode escolher o site OAuth da China ou o internacional. O IaC Code salva o site escolhido junto com o refresh token para que atualizações futuras usem o mesmo endpoint. + +As credenciais OAuth são atualizadas automaticamente quando o access token ou as credenciais STS estão perto de expirar. Se o refresh token expirar ou for revogado, execute `/auth` novamente e escolha OAuth Login (Browser). + +## Variáveis de ambiente + Variaveis de ambiente suportadas: | Variavel | Descricao | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md index c1470b6..38575fd 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -16,7 +16,7 @@ description: IaC Code 启动选项和一次性执行参数参考。 | `--output-format ` | 设置非交互模式的输出格式。支持 `text`、`json` 和 `stream-json`,默认值为 `text`。 | | `--max-turns ` | 限制非交互模式中的最大代理轮次,默认值为 `100`。 | | `-d`, `--debug` | 为本次运行启用调试日志。交互模式启动后,可以使用 `/debug` 查看或调整调试日志。 | -| `-r `, `--resume ` | 按会话 ID 恢复历史会话。适合回到已知的对话。 | +| `-r `, `--resume ` | 按精确会话 ID、唯一 ID 前缀或唯一会话名称恢复历史会话。解析到跨项目会话时,会打印 `cd ... && iac-code --resume ` 命令,而不是直接热切换当前项目。 | | `-c`, `--continue` | 恢复最近一次会话。不能与 `--resume` 同时使用。 | | `--allowed-tools ` | 逗号分隔的工具权限允许模式,例如 `'bash(git *),write_file'`。 | | `--disallowed-tools ` | 逗号分隔的工具权限拒绝模式,例如 `'bash(rm *)'`。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md index 48e49ce..8816306 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,11 @@ Slash 命令用于在交互式会话中控制 IaC Code。输入 `/` 可以查看 | `/effort [level]` | 在当前模型支持 effort 控制时,查看或切换 thinking effort。带 `level` 时,如果该值对当前模型有效就会直接应用;不带参数时,在 REPL 中打开交互式选择器,在无控制台 UI 的场景中显示当前 effort。 | | `/exit` | 退出交互式 REPL。别名:`/quit`、`/q`。 | | `/help` | 在 REPL 中显示可用命令和常用快捷键。别名:`/?`。 | +| `/memory [\|search \|delete \|help]` | 列出、查看、搜索或删除已保存的记忆。当你让助手记住某件事时,自然语言创建记忆仍由助手通过 memory 工具完成。 | | `/model [model_name]` | 查看或切换当前模型。带 `model_name` 时,会直接为当前提供商切换到该模型;不带参数时,如果已配置提供商,会打开交互式模型选择器;在无控制台 UI 的场景中会显示当前模型。 | -| `/resume [conversation id or search term]` | 恢复历史会话。带参数时,IaC Code 会把它解析为会话 ID 或唯一 ID 前缀;不带参数时打开交互式会话选择器。跨项目会话不会直接热切换,而是打印 `cd ... && iac-code --resume ` 命令。 | +| `/rename ` | 为当前会话命名。名称会显示在欢迎横幅、退出提示和 `/resume` 选择器中;当它能唯一标识一个会话时,也可以用于 `/resume` 或 `--resume`。 | +| `/resume [session id\|unique id prefix\|unique session name]` | 恢复历史会话。带参数时,IaC Code 会把它解析为精确会话 ID、唯一 ID 前缀或唯一会话名称;不带参数时打开交互式会话选择器。跨项目会话不会直接热切换,而是打印 `cd ... && iac-code --resume ` 命令。 | +| `/skills` | 打开技能管理选择器。可以按名称或描述搜索技能,按名称/来源/大小排序,并启用或禁用用户技能和项目技能。内置技能始终锁定为启用。 | +| `/status` | 显示当前会话 ID、提供商、模型、阿里云地域、工作目录、已记录的 API token 用量、轮次数和上下文利用率。 | 准确命令列表可能随版本变化。请在 REPL 中使用 `/help` 或输入 `/` 查看当前安装版本支持的命令。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index e6b565a..3b001f4 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -25,6 +25,12 @@ iac-code 创建一个 VPC、两台 ECS 实例,以及一个允许办公 IP 通过 SSH 访问的安全组。 ``` +## 命令 + +输入 `/` 可以发现可用的 Slash 命令。常用运维命令包括:用 `/status` 查看当前会话状态,用 `/skills` 管理技能,用 `/memory` 查看已保存记忆,用 `/rename` 命名当前会话,以及用 `/resume` 切换会话。 + +输入 `$` 只会发现并调用技能。 + ## 编辑输入 使用 `Shift+Enter` 可以插入换行而不发送 prompt。单独按 `Enter` 会提交完整 prompt。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/sessions.md index ca59635..0fb8e4c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/sessions.md @@ -17,20 +17,37 @@ IaC Code 会自动将每次对话持久化到磁盘。你可以恢复任何历 /resume ``` -这将打开交互式选择器,显示当前项目的最近会话及其最后一条提示词作为标题。 +这会打开交互式选择器,显示当前项目的最近会话。设置了会话名称时会优先用名称作为标题,否则会使用最后一条提示词,或回退到第一条提示词。 -通过 ID 或 ID 前缀恢复特定会话: +通过精确会话 ID、唯一 ID 前缀或唯一会话名称恢复特定会话: ```text /resume abc123 ``` +### 命名会话 + +使用 `/rename` 为当前会话设置一个稳定、易读的名称: + +```text +/rename deploy-prod +``` + +名称会保存在会话元数据中。恢复会话时,它会显示在欢迎横幅、退出提示和 `/resume` 选择器中。 + +当名称能唯一标识一个会话时,可以按名称恢复: + +```text +/resume deploy-prod +iac-code --resume deploy-prod +``` + ### 命令行:`--resume` 和 `--continue` -从命令行恢复特定会话: +从命令行按精确会话 ID、唯一 ID 前缀或唯一会话名称恢复特定会话: ```bash -iac-code --resume +iac-code --resume ``` 恢复最近的会话: @@ -42,7 +59,7 @@ iac-code --continue 也可使用短标志 `-r` 和 `-c`: ```bash -iac-code -r +iac-code -r iac-code -c ``` @@ -66,7 +83,7 @@ cd /path/to/other/project && iac-code --resume | 列 | 说明 | |----|------| -| 标题 | 最后一条用户提示词(如无元数据则为第一条提示词) | +| 标题 | 设置了会话名称时显示名称,否则显示最后一条或第一条用户提示词 | | 分支 | 会话时的 Git 分支 | | 时间 | 最后修改时间 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/skills.md index c248449..e7e7701 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/skills.md @@ -89,6 +89,14 @@ paths: | `agent` | 否 | `"general-purpose"` | fork 模式使用的 agent 类型 | | `paths` | 否 | `[]` | 用于路径自动激活的 glob 模式 | +## 管理技能 + +在交互式 REPL 中运行 `/skills` 可以打开技能管理选择器。选择器会列出已发现的内置技能、用户技能和项目技能,并展示来源、大小和启用状态。你可以按名称或描述搜索,按名称/来源/大小排序,也可以启用或禁用用户技能与项目技能。 + +禁用的技能会保存在 `settings.yml` 的 `disabled_skills` 下。内置技能固定为启用状态,不会写入禁用列表。 + +当你希望自动补全和调用目标只限于技能时,可以使用 `$`。如果某个技能名称容易和普通文本混淆,或者你想避开内置 Slash 命令,这会很有用。 + ## 执行模式 ### Inline(默认) @@ -188,3 +196,4 @@ user_invocable: true - **内置技能**始终自动允许。 - 无 Shell 命令且无 `allowed_tools` 的**用户/项目技能**自动允许。 - **其他技能**首次使用时需要用户确认。 +- **已禁用的用户/项目技能**不会出现在模型可见的技能列表和自动触发中,直接调用 `skill` 工具会返回技能已禁用错误。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md index d14bbaf..8e84826 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/alibaba-cloud-credentials.md @@ -7,6 +7,22 @@ description: 配置阿里云 AccessKey 或 STS 凭证。 需要检查或管理云资源时,必须配置阿里云凭证。 +## OAuth 浏览器登录 + +推荐的交互式配置入口是 `/auth`: + +```text +/auth +``` + +选择 **配置 IaC 云服务**,然后选择 **Alibaba Cloud**,再选择 **OAuth Login (Browser)**。IaC Code 会打开浏览器授权流程,等待本地回调,使用 PKCE 交换授权码,并将基于 OAuth 的临时凭证保存到 IaC Code 配置目录下的 `.cloud-credentials.yml`。 + +配置过程中可以选择中国站或国际站 OAuth。IaC Code 会把所选站点与 refresh token 一起保存,后续刷新会继续使用同一 endpoint。 + +当 access token 或 STS 凭证即将过期时,OAuth 凭证会自动刷新。如果 refresh token 过期或被撤销,请重新运行 `/auth` 并选择 OAuth Login (Browser)。 + +## 环境变量 + 支持的环境变量: | 变量 | 说明 |