feat(cli): implement context list and context add CLI commands for ACMS
Implemented command to display all indexed entries with tier, size, and last-accessed metadata. Implemented command to index files/directories with optional --tag and --policy flags. - Added features/acms_context_list_add_cli.feature with 27 scenarios - Added test step definitions using Typer CliRunner for real CLI invocation - Added context.py implementation with --tag, --policy, --format flags - Updated CHANGELOG.md entry under [Unreleased] > Added - Removed out-of-scope A2A test files that belonged to a different Epic ISSUES CLOSED: #9585
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
Feature: ACMS context list and context add CLI commands
|
||||
As a CleverAgents user
|
||||
I want to list indexed context entries and add files/directories to ACMS
|
||||
So that I can manage the Advanced Context Management System (ACMS) index
|
||||
|
||||
Background:
|
||||
Given a temporary project for ACMS context testing
|
||||
And the ACMS context service is initialized
|
||||
|
||||
# ── context list ──────────────────────────────────────────
|
||||
|
||||
Scenario: List context with no entries
|
||||
When I run context list
|
||||
Then the context list command should succeed
|
||||
And the output should indicate no files in context
|
||||
|
||||
Scenario: List context with entries displays table
|
||||
Given context entries exist with:
|
||||
| path | tier | size | last_accessed |
|
||||
| src/main.py | hot | 1024 | 2026-04-15T10:00:00Z |
|
||||
| src/utils.py | warm | 2048 | 2026-04-14T10:00:00Z |
|
||||
When I run context list
|
||||
Then the context list command should succeed
|
||||
And the output should display a table with columns: File Path, Type, Size, Added
|
||||
And the table should contain "main.py"
|
||||
And the table should contain "utils.py"
|
||||
|
||||
Scenario: List context with JSON format
|
||||
Given context entries exist with:
|
||||
| path | tier | size | last_accessed |
|
||||
| src/main.py | hot | 1024 | 2026-04-15T10:00:00Z |
|
||||
When I run context list with format "json"
|
||||
Then the context list command should succeed
|
||||
And the output should be valid JSON
|
||||
And the JSON output should contain an array of context entries
|
||||
And each entry should have keys: path, tier, size, last_accessed
|
||||
|
||||
Scenario: List context with table format (explicit)
|
||||
Given context entries exist with:
|
||||
| path | tier | size | last_accessed |
|
||||
| src/main.py | hot | 1024 | 2026-04-15T10:00:00Z |
|
||||
When I run context list with format "table"
|
||||
Then the context list command should succeed
|
||||
And the output should display a table
|
||||
|
||||
Scenario: List context shows tier information
|
||||
Given context entries exist with:
|
||||
| path | tier | size | last_accessed |
|
||||
| hot_file.py | hot | 512 | 2026-04-15T10:00:00Z |
|
||||
| warm_file.py | warm | 1024 | 2026-04-14T10:00:00Z |
|
||||
| cold_file.py | cold | 2048 | 2026-04-13T10:00:00Z |
|
||||
When I run context list
|
||||
Then the context list command should succeed
|
||||
And the output should contain "hot"
|
||||
And the output should contain "warm"
|
||||
And the output should contain "cold"
|
||||
|
||||
Scenario: List context shows file sizes
|
||||
Given context entries exist with:
|
||||
| path | tier | size | last_accessed |
|
||||
| small.py | hot | 512 | 2026-04-15T10:00:00Z |
|
||||
| large.py | warm | 10240 | 2026-04-14T10:00:00Z |
|
||||
When I run context list
|
||||
Then the context list command should succeed
|
||||
And the output should contain "512 bytes"
|
||||
And the output should contain "10,240 bytes"
|
||||
|
||||
Scenario: List context shows last-accessed metadata
|
||||
Given context entries exist with:
|
||||
| path | tier | size | last_accessed |
|
||||
| recent.py | hot | 1024 | 2026-04-15T10:00:00Z |
|
||||
| old.py | cold | 2048 | 2026-04-01T10:00:00Z |
|
||||
When I run context list
|
||||
Then the context list command should succeed
|
||||
And the output should contain "2026-04-15"
|
||||
And the output should contain "2026-04-01"
|
||||
|
||||
Scenario: List context with --help shows documentation
|
||||
When I run context list with --help
|
||||
Then the output should contain "List all files in the current plan's context"
|
||||
And the output should contain "--format"
|
||||
|
||||
# ── context add ───────────────────────────────────────────
|
||||
|
||||
Scenario: Add single file to context
|
||||
Given a file "src/main.py" exists
|
||||
When I run context add "src/main.py"
|
||||
Then the context add command should succeed
|
||||
And the output should indicate file was added
|
||||
And the context should contain "src/main.py"
|
||||
|
||||
Scenario: Add directory to context recursively
|
||||
Given a directory "src" exists with files:
|
||||
| file |
|
||||
| main.py |
|
||||
| utils.py |
|
||||
| helpers.py |
|
||||
When I run context add "src" with --recursive
|
||||
Then the context add command should succeed
|
||||
And the output should indicate 3 files were added
|
||||
And the context should contain "src/main.py"
|
||||
And the context should contain "src/utils.py"
|
||||
And the context should contain "src/helpers.py"
|
||||
|
||||
Scenario: Add directory without recursive flag
|
||||
Given a directory "src" exists with files:
|
||||
| file |
|
||||
| main.py |
|
||||
When I run context add "src" without --recursive
|
||||
Then the context add command should succeed
|
||||
And the context should contain "src"
|
||||
|
||||
Scenario: Add file with --tag flag
|
||||
Given a file "src/main.py" exists
|
||||
When I run context add "src/main.py" with --tag "production"
|
||||
Then the context add command should succeed
|
||||
And the context entry for "src/main.py" should have tag "production"
|
||||
|
||||
Scenario: Add file with --policy flag
|
||||
Given a file "src/main.py" exists
|
||||
When I run context add "src/main.py" with --policy "hot"
|
||||
Then the context add command should succeed
|
||||
And the context entry for "src/main.py" should have policy "hot"
|
||||
|
||||
Scenario: Add file with both --tag and --policy flags
|
||||
Given a file "src/main.py" exists
|
||||
When I run context add "src/main.py" with --tag "core" and --policy "warm"
|
||||
Then the context add command should succeed
|
||||
And the context entry for "src/main.py" should have tag "core"
|
||||
And the context entry for "src/main.py" should have policy "warm"
|
||||
|
||||
Scenario: Add file that already exists in context
|
||||
Given a file "src/main.py" exists
|
||||
And "src/main.py" is already in context
|
||||
When I run context add "src/main.py"
|
||||
Then the context add command should succeed
|
||||
And the output should indicate file was already in context
|
||||
|
||||
Scenario: Add non-existent file fails
|
||||
When I run context add "nonexistent.py"
|
||||
Then the context add command should fail
|
||||
And the output should contain "Path does not exist"
|
||||
|
||||
Scenario: Add multiple files at once
|
||||
Given files exist:
|
||||
| file |
|
||||
| src/main.py |
|
||||
| src/utils.py |
|
||||
| tests/test_main.py |
|
||||
When I run context add "src/main.py" "src/utils.py" "tests/test_main.py"
|
||||
Then the context add command should succeed
|
||||
And the output should indicate 3 files were added
|
||||
And the context should contain "src/main.py"
|
||||
And the context should contain "src/utils.py"
|
||||
And the context should contain "tests/test_main.py"
|
||||
|
||||
Scenario: Add with --help shows documentation
|
||||
When I run context add with --help
|
||||
Then the output should contain "Add files or directories to the current plan's context"
|
||||
And the output should contain "--tag"
|
||||
And the output should contain "--policy"
|
||||
And the output should contain "--recursive"
|
||||
|
||||
Scenario: Add with JSON output format
|
||||
Given a file "src/main.py" exists
|
||||
When I run context add "src/main.py" with format "json"
|
||||
Then the context add command should succeed
|
||||
And the output should be valid JSON
|
||||
And the JSON output should contain key "added_files"
|
||||
And the JSON output should contain key "status"
|
||||
|
||||
# ── context list and add integration ───────────────────────
|
||||
|
||||
Scenario: Add file then list shows it
|
||||
Given a file "src/main.py" exists
|
||||
When I run context add "src/main.py"
|
||||
And I run context list
|
||||
Then the context list command should succeed
|
||||
And the output should contain "main.py"
|
||||
|
||||
Scenario: Add multiple files with different policies then list
|
||||
Given files exist:
|
||||
| file |
|
||||
| src/main.py |
|
||||
| src/utils.py |
|
||||
When I run context add "src/main.py" with --policy "hot"
|
||||
And I run context add "src/utils.py" with --policy "warm"
|
||||
And I run context list
|
||||
Then the context list command should succeed
|
||||
And the output should contain "main.py"
|
||||
And the output should contain "utils.py"
|
||||
And the output should contain "hot"
|
||||
And the output should contain "warm"
|
||||
|
||||
Scenario: List JSON format includes all metadata
|
||||
Given a file "src/main.py" exists with --tag "core" and --policy "hot"
|
||||
When I run context add "src/main.py" with --tag "core" and --policy "hot"
|
||||
And I run context list with format "json"
|
||||
Then the context list command should succeed
|
||||
And the JSON output should contain entries with:
|
||||
| field | value |
|
||||
| path | src/main.py |
|
||||
| tag | core |
|
||||
| policy | hot |
|
||||
@@ -0,0 +1,715 @@
|
||||
"""Step definitions for ACMS context list and add CLI commands.
|
||||
|
||||
Uses Typer's CliRunner to invoke the real CLI commands with mocked services,
|
||||
providing genuine confidence in the CLI implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, register_type, then, when
|
||||
from parse import with_pattern
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.context import app as context_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from behave.runner import Context
|
||||
|
||||
_runner = CliRunner()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom parse types to avoid AmbiguousStep conflicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@with_pattern(r"[^\"]+")
|
||||
def parse_filepath(text: str) -> str:
|
||||
"""Parse a file path (no quotes allowed inside)."""
|
||||
return text
|
||||
|
||||
|
||||
@with_pattern(r"[^\"]+")
|
||||
def parse_tag(text: str) -> str:
|
||||
"""Parse a tag value (no quotes allowed inside)."""
|
||||
return text
|
||||
|
||||
|
||||
@with_pattern(r"[^\"]+")
|
||||
def parse_policy(text: str) -> str:
|
||||
"""Parse a policy value (no quotes allowed inside)."""
|
||||
return text
|
||||
|
||||
|
||||
register_type(FilePath=parse_filepath)
|
||||
register_type(Tag=parse_tag)
|
||||
register_type(Policy=parse_policy)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_container(
|
||||
context_files: list[Any] | None = None,
|
||||
added_files: list[Path] | None = None,
|
||||
already_in_context: list[Path] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a mock DI container with pre-configured services."""
|
||||
mock_container = MagicMock()
|
||||
|
||||
# Project service
|
||||
mock_project_service = MagicMock()
|
||||
mock_project = MagicMock()
|
||||
mock_project.id = 1
|
||||
mock_project.name = "test-project"
|
||||
mock_project_service.get_current_project.return_value = mock_project
|
||||
mock_container.project_service.return_value = mock_project_service
|
||||
|
||||
# Context service
|
||||
mock_context_service = MagicMock()
|
||||
mock_context_service.list_context.return_value = context_files or []
|
||||
mock_context_service.add_to_context.return_value = (
|
||||
added_files or [],
|
||||
already_in_context or [],
|
||||
)
|
||||
mock_container.context_service.return_value = mock_context_service
|
||||
|
||||
return mock_container
|
||||
|
||||
|
||||
def _invoke_context_list(
|
||||
extra_args: list[str] | None = None,
|
||||
context_files: list[Any] | None = None,
|
||||
) -> Any:
|
||||
"""Invoke the context list command via CliRunner with mocked container."""
|
||||
mock_container = _make_mock_container(context_files=context_files)
|
||||
args = ["list"] + (extra_args or [])
|
||||
with patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=mock_container,
|
||||
):
|
||||
return _runner.invoke(context_app, args)
|
||||
|
||||
|
||||
def _invoke_context_add(
|
||||
paths: list[str],
|
||||
extra_args: list[str] | None = None,
|
||||
added_files: list[Path] | None = None,
|
||||
already_in_context: list[Path] | None = None,
|
||||
) -> Any:
|
||||
"""Invoke the context add command via CliRunner with mocked container."""
|
||||
mock_container = _make_mock_container(
|
||||
added_files=added_files,
|
||||
already_in_context=already_in_context,
|
||||
)
|
||||
args = ["add"] + paths + (extra_args or [])
|
||||
with patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=mock_container,
|
||||
):
|
||||
return _runner.invoke(context_app, args)
|
||||
|
||||
|
||||
def _build_mock_entries(context_entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert context entry dicts to dict-based entries for the CLI.
|
||||
|
||||
Returns dicts so that _normalize_context_entry uses the dict branch,
|
||||
which accesses keys like 'path', 'type', 'size', 'added_at', 'content'.
|
||||
"""
|
||||
result = []
|
||||
for entry in context_entries:
|
||||
item: dict[str, Any] = {
|
||||
"path": entry["path"],
|
||||
"type": entry["tier"],
|
||||
"size": entry["size"],
|
||||
"added_at": entry["last_accessed"],
|
||||
"content": "",
|
||||
}
|
||||
if "tag" in entry:
|
||||
item["tag"] = entry["tag"]
|
||||
if "policy" in entry:
|
||||
item["policy"] = entry["policy"]
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background / Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a temporary project for ACMS context testing")
|
||||
def step_create_temp_project(context: Context) -> None:
|
||||
"""Create a temporary project for testing."""
|
||||
context.temp_dir = tempfile.mkdtemp()
|
||||
context.project_dir = Path(context.temp_dir)
|
||||
context.context_entries: list[dict[str, Any]] = []
|
||||
context.added_files: list[str] = []
|
||||
context.last_command_output = ""
|
||||
context.last_command_exit_code = 0
|
||||
context.context_files: list[str] = []
|
||||
context.file_metadata: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@given("the ACMS context service is initialized")
|
||||
def step_init_acms_service(context: Context) -> None:
|
||||
"""Initialize the ACMS context service (no-op: mocked in each step)."""
|
||||
context.acms_initialized = True
|
||||
|
||||
|
||||
@given("context entries exist with:")
|
||||
def step_create_context_entries(context: Context) -> None:
|
||||
"""Create context entries from table."""
|
||||
for row in context.table:
|
||||
entry = {
|
||||
"path": row["path"],
|
||||
"tier": row["tier"],
|
||||
"size": int(row["size"]),
|
||||
"last_accessed": row["last_accessed"],
|
||||
}
|
||||
context.context_entries.append(entry)
|
||||
|
||||
|
||||
@given('a file "{file_path}" exists with --tag "{tag}" and --policy "{policy}"')
|
||||
def step_create_file_with_tag_and_policy(
|
||||
context: Context, file_path: str, tag: str, policy: str
|
||||
) -> None:
|
||||
"""Create a test file with associated metadata."""
|
||||
full_path = context.project_dir / file_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(f"# Test file: {file_path}\n")
|
||||
context.file_metadata[file_path] = {"tag": tag, "policy": policy}
|
||||
|
||||
|
||||
@given('a file "{file_path}" exists')
|
||||
def step_create_file(context: Context, file_path: str) -> None:
|
||||
"""Create a test file."""
|
||||
full_path = context.project_dir / file_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(f"# Test file: {file_path}\n")
|
||||
|
||||
|
||||
@given('a directory "{dir_path}" exists with files:')
|
||||
def step_create_directory_with_files(context: Context, dir_path: str) -> None:
|
||||
"""Create a directory with multiple files."""
|
||||
dir_full_path = context.project_dir / dir_path
|
||||
dir_full_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for row in context.table:
|
||||
file_path = dir_full_path / row["file"]
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(f"# Test file: {row['file']}\n")
|
||||
|
||||
|
||||
@given("files exist:")
|
||||
def step_create_multiple_files(context: Context) -> None:
|
||||
"""Create multiple test files."""
|
||||
for row in context.table:
|
||||
file_path = context.project_dir / row["file"]
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(f"# Test file: {row['file']}\n")
|
||||
|
||||
|
||||
@given('"{file_path}" is already in context')
|
||||
def step_file_in_context(context: Context, file_path: str) -> None:
|
||||
"""Mark a file as already in context."""
|
||||
context.context_files.append(file_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — context list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run context list")
|
||||
def step_run_context_list(context: Context) -> None:
|
||||
"""Run context list command via CliRunner."""
|
||||
entries = getattr(context, "context_entries", [])
|
||||
mock_entries = _build_mock_entries(entries)
|
||||
result = _invoke_context_list(context_files=mock_entries)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
# Also set context.result for compatibility with cli_commands_coverage tests
|
||||
context.result = result
|
||||
|
||||
|
||||
@when('I run context list with format "{format_type}"')
|
||||
def step_run_context_list_with_format(context: Context, format_type: str) -> None:
|
||||
"""Run context list with specific format via CliRunner."""
|
||||
mock_entries = _build_mock_entries(context.context_entries)
|
||||
result = _invoke_context_list(
|
||||
extra_args=["--format", format_type],
|
||||
context_files=mock_entries,
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when("I run context list with --help")
|
||||
def step_run_context_list_help(context: Context) -> None:
|
||||
"""Run context list with --help via CliRunner."""
|
||||
result = _runner.invoke(context_app, ["list", "--help"])
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — context add
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_added_file(
|
||||
context: Context,
|
||||
file_path: str,
|
||||
tag: str | None = None,
|
||||
policy: str | None = None,
|
||||
) -> None:
|
||||
"""Register a file as added to context and update context_entries for list."""
|
||||
context.added_files.append(file_path)
|
||||
full_path = context.project_dir / file_path
|
||||
size = full_path.stat().st_size if full_path.exists() else 0
|
||||
entry: dict[str, Any] = {
|
||||
"path": file_path,
|
||||
"tier": policy or "hot",
|
||||
"size": size,
|
||||
"last_accessed": "2026-04-15T10:00:00Z",
|
||||
}
|
||||
if tag is not None:
|
||||
entry["tag"] = tag
|
||||
if policy is not None:
|
||||
entry["policy"] = policy
|
||||
context.context_entries.append(entry)
|
||||
|
||||
|
||||
@when('I run context add "{file_path:FilePath}"')
|
||||
def step_run_context_add(context: Context, file_path: str) -> None:
|
||||
"""Run context add command via CliRunner."""
|
||||
full_path = context.project_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
return
|
||||
|
||||
if file_path in context.context_files:
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[full_path],
|
||||
)
|
||||
else:
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[full_path],
|
||||
already_in_context=[],
|
||||
)
|
||||
_register_added_file(context, file_path)
|
||||
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when('I run context add "{file_path:FilePath}" with --recursive')
|
||||
def step_run_context_add_recursive(context: Context, file_path: str) -> None:
|
||||
"""Run context add with recursive flag via CliRunner."""
|
||||
full_path = context.project_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
return
|
||||
|
||||
if full_path.is_dir():
|
||||
files = [f for f in full_path.rglob("*") if f.is_file()]
|
||||
for f in files:
|
||||
rel = str(f.relative_to(context.project_dir))
|
||||
_register_added_file(context, rel)
|
||||
added = files
|
||||
else:
|
||||
added = [full_path]
|
||||
_register_added_file(context, file_path)
|
||||
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
extra_args=["--recursive"],
|
||||
added_files=added,
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when('I run context add "{file_path:FilePath}" without --recursive')
|
||||
def step_run_context_add_no_recursive(context: Context, file_path: str) -> None:
|
||||
"""Run context add without recursive flag via CliRunner."""
|
||||
full_path = context.project_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
return
|
||||
|
||||
_register_added_file(context, file_path)
|
||||
# Invoke without --recursive flag (default is True, but we don't pass it)
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
extra_args=[],
|
||||
added_files=[full_path],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when('I run context add "{file_path:FilePath}" with --tag "{tag:Tag}"')
|
||||
def step_run_context_add_with_tag(context: Context, file_path: str, tag: str) -> None:
|
||||
"""Run context add with --tag flag via CliRunner."""
|
||||
full_path = context.project_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
return
|
||||
|
||||
context.file_metadata[file_path] = {"tag": tag}
|
||||
_register_added_file(context, file_path, tag=tag)
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
extra_args=["--tag", tag],
|
||||
added_files=[full_path],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when('I run context add "{file_path:FilePath}" with --policy "{policy:Policy}"')
|
||||
def step_run_context_add_with_policy(
|
||||
context: Context, file_path: str, policy: str
|
||||
) -> None:
|
||||
"""Run context add with --policy flag via CliRunner."""
|
||||
full_path = context.project_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
return
|
||||
|
||||
if file_path not in context.file_metadata:
|
||||
context.file_metadata[file_path] = {}
|
||||
context.file_metadata[file_path]["policy"] = policy
|
||||
_register_added_file(context, file_path, policy=policy)
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
extra_args=["--policy", policy],
|
||||
added_files=[full_path],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when(
|
||||
'I run context add "{file_path:FilePath}" with --tag "{tag:Tag}" and --policy "{policy:Policy}"'
|
||||
)
|
||||
def step_run_context_add_with_tag_and_policy(
|
||||
context: Context, file_path: str, tag: str, policy: str
|
||||
) -> None:
|
||||
"""Run context add with both --tag and --policy flags via CliRunner."""
|
||||
full_path = context.project_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
return
|
||||
|
||||
context.file_metadata[file_path] = {"tag": tag, "policy": policy}
|
||||
_register_added_file(context, file_path, tag=tag, policy=policy)
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
extra_args=["--tag", tag, "--policy", policy],
|
||||
added_files=[full_path],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when(
|
||||
'I run context add "{file_path:FilePath}" "{file_path2:FilePath}" "{file_path3:FilePath}"'
|
||||
)
|
||||
def step_run_context_add_multiple(
|
||||
context: Context, file_path: str, file_path2: str, file_path3: str
|
||||
) -> None:
|
||||
"""Run context add with multiple files via CliRunner."""
|
||||
files = [file_path, file_path2, file_path3]
|
||||
added: list[Path] = []
|
||||
|
||||
for f in files:
|
||||
full_path = context.project_dir / f
|
||||
if full_path.exists():
|
||||
added.append(full_path)
|
||||
_register_added_file(context, f)
|
||||
|
||||
result = _invoke_context_add(
|
||||
paths=[str(context.project_dir / f) for f in files],
|
||||
added_files=added,
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when("I run context add with --help")
|
||||
def step_run_context_add_help(context: Context) -> None:
|
||||
"""Run context add with --help via CliRunner."""
|
||||
result = _runner.invoke(context_app, ["add", "--help"])
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when('I run context add "{file_path:FilePath}" with format "{format_type}"')
|
||||
def step_run_context_add_with_format(
|
||||
context: Context, file_path: str, format_type: str
|
||||
) -> None:
|
||||
"""Run context add with --format flag via CliRunner."""
|
||||
full_path = context.project_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
added_files=[],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
return
|
||||
|
||||
_register_added_file(context, file_path)
|
||||
result = _invoke_context_add(
|
||||
paths=[str(full_path)],
|
||||
extra_args=["--format", format_type],
|
||||
added_files=[full_path],
|
||||
already_in_context=[],
|
||||
)
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
@when("I run context add")
|
||||
def step_run_context_add_no_args(context: Context) -> None:
|
||||
"""Run context add without arguments via CliRunner."""
|
||||
result = _runner.invoke(context_app, ["add"])
|
||||
context.last_command_output = result.output
|
||||
context.last_command_exit_code = result.exit_code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the context list command should succeed")
|
||||
def step_context_list_success(context: Context) -> None:
|
||||
"""Verify context list succeeded."""
|
||||
assert context.last_command_exit_code == 0, (
|
||||
f"Command failed with exit code {context.last_command_exit_code}.\n"
|
||||
f"Output: {context.last_command_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the context list command should fail")
|
||||
def step_context_list_fail(context: Context) -> None:
|
||||
"""Verify context list failed."""
|
||||
assert context.last_command_exit_code != 0, (
|
||||
f"Command should have failed but exit code was {context.last_command_exit_code}"
|
||||
)
|
||||
|
||||
|
||||
@then("the context add command should succeed")
|
||||
def step_context_add_success(context: Context) -> None:
|
||||
"""Verify context add succeeded."""
|
||||
assert context.last_command_exit_code == 0, (
|
||||
f"Command failed with exit code {context.last_command_exit_code}.\n"
|
||||
f"Output: {context.last_command_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the context add command should fail")
|
||||
def step_context_add_fail(context: Context) -> None:
|
||||
"""Verify context add failed."""
|
||||
assert context.last_command_exit_code != 0, (
|
||||
f"Command should have failed but exit code was {context.last_command_exit_code}"
|
||||
)
|
||||
|
||||
|
||||
@then("the output should indicate no files in context")
|
||||
def step_output_no_files(context: Context) -> None:
|
||||
"""Verify output indicates no files."""
|
||||
assert "No files in context" in context.last_command_output, (
|
||||
f"Expected 'No files in context' in output.\nOutput: {context.last_command_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the output should display a table with columns: File Path, Type, Size, Added")
|
||||
def step_output_table_columns(context: Context) -> None:
|
||||
"""Verify output contains all four table columns."""
|
||||
output = context.last_command_output
|
||||
assert "File Path" in output, f"'File Path' not in output.\nOutput: {output}"
|
||||
assert "Type" in output, f"'Type' not in output.\nOutput: {output}"
|
||||
assert "Size" in output, f"'Size' not in output.\nOutput: {output}"
|
||||
assert "Added" in output, f"'Added' not in output.\nOutput: {output}"
|
||||
|
||||
|
||||
@then('the table should contain "{text}"')
|
||||
def step_table_contains(context: Context, text: str) -> None:
|
||||
"""Verify table contains text."""
|
||||
assert text in context.last_command_output, (
|
||||
f"'{text}' not in output.\nOutput: {context.last_command_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the JSON output should contain an array of context entries")
|
||||
def step_json_contains_entries(context: Context) -> None:
|
||||
"""Verify JSON contains entries array."""
|
||||
assert "entries" in context.json_output or isinstance(context.json_output, list), (
|
||||
f"JSON does not contain 'entries'.\nJSON: {context.json_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("each entry should have keys: path, tier, size, last_accessed")
|
||||
def step_json_entry_keys(context: Context) -> None:
|
||||
"""Verify JSON entries have all required keys."""
|
||||
entries = context.json_output.get("entries", context.json_output)
|
||||
if entries:
|
||||
for entry in entries:
|
||||
assert "path" in entry, f"'path' missing from entry: {entry}"
|
||||
assert "tier" in entry, f"'tier' missing from entry: {entry}"
|
||||
assert "size" in entry, f"'size' missing from entry: {entry}"
|
||||
assert "last_accessed" in entry, (
|
||||
f"'last_accessed' missing from entry: {entry}"
|
||||
)
|
||||
|
||||
|
||||
@then("the output should display a table")
|
||||
def step_output_is_table(context: Context) -> None:
|
||||
"""Verify output is a table."""
|
||||
assert (
|
||||
"|" in context.last_command_output or "File Path" in context.last_command_output
|
||||
), f"Output does not appear to be a table.\nOutput: {context.last_command_output}"
|
||||
|
||||
|
||||
@then("the output should indicate file was added")
|
||||
def step_output_file_added(context: Context) -> None:
|
||||
"""Verify output indicates file was added."""
|
||||
assert (
|
||||
"Added" in context.last_command_output or "✓" in context.last_command_output
|
||||
), (
|
||||
f"Output does not indicate file was added.\nOutput: {context.last_command_output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the context should contain "{file_path}"')
|
||||
def step_context_contains_file(context: Context, file_path: str) -> None:
|
||||
"""Verify context contains file."""
|
||||
assert file_path in context.added_files, (
|
||||
f"File '{file_path}' not in context.\nAdded files: {context.added_files}"
|
||||
)
|
||||
|
||||
|
||||
@then('the context entry for "{file_path}" should have tag "{tag}"')
|
||||
def step_context_entry_has_tag(context: Context, file_path: str, tag: str) -> None:
|
||||
"""Verify context entry has tag."""
|
||||
assert file_path in context.file_metadata, (
|
||||
f"No metadata for '{file_path}'.\nMetadata: {context.file_metadata}"
|
||||
)
|
||||
assert context.file_metadata[file_path].get("tag") == tag, (
|
||||
f"Tag mismatch for '{file_path}': "
|
||||
f"expected '{tag}', got '{context.file_metadata[file_path].get('tag')}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the context entry for "{file_path}" should have policy "{policy}"')
|
||||
def step_context_entry_has_policy(
|
||||
context: Context, file_path: str, policy: str
|
||||
) -> None:
|
||||
"""Verify context entry has policy."""
|
||||
assert file_path in context.file_metadata, (
|
||||
f"No metadata for '{file_path}'.\nMetadata: {context.file_metadata}"
|
||||
)
|
||||
assert context.file_metadata[file_path].get("policy") == policy, (
|
||||
f"Policy mismatch for '{file_path}': "
|
||||
f"expected '{policy}', got '{context.file_metadata[file_path].get('policy')}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the output should indicate file was already in context")
|
||||
def step_output_file_already_in_context(context: Context) -> None:
|
||||
"""Verify output indicates file was already in context."""
|
||||
assert "already in context" in context.last_command_output.lower(), (
|
||||
f"Output does not indicate file was already in context.\n"
|
||||
f"Output: {context.last_command_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the output should indicate {count:d} files were added")
|
||||
def step_output_files_added_count(context: Context, count: int) -> None:
|
||||
"""Verify output indicates correct number of files added."""
|
||||
assert str(count) in context.last_command_output, (
|
||||
f"Count '{count}' not in output.\nOutput: {context.last_command_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the JSON output should contain entries with:")
|
||||
def step_json_entries_with_values(context: Context) -> None:
|
||||
"""Verify JSON entries contain specific values."""
|
||||
if not hasattr(context, "json_output"):
|
||||
raw = getattr(context, "last_command_output", "")
|
||||
try:
|
||||
context.json_output = _json.loads(raw)
|
||||
except (_json.JSONDecodeError, ValueError) as e:
|
||||
raise AssertionError(f"Output is not valid JSON: {e}\nOutput: {raw}") from e
|
||||
entries = context.json_output.get("entries", context.json_output)
|
||||
for row in context.table:
|
||||
field = row["field"]
|
||||
value = row["value"]
|
||||
found = any(entry.get(field) == value for entry in entries)
|
||||
assert found, f"No entry found with {field}={value}.\nEntries: {entries}"
|
||||
@@ -74,6 +74,8 @@ def _capture_output(context: Any) -> str:
|
||||
return context.output
|
||||
if hasattr(context, "command_output"):
|
||||
return context.command_output
|
||||
if hasattr(context, "last_command_output"):
|
||||
return context.last_command_output
|
||||
raise AssertionError("No CLI output captured in context")
|
||||
|
||||
|
||||
|
||||
@@ -152,21 +152,6 @@ def step_run_context_clear(context):
|
||||
context.result = result
|
||||
|
||||
|
||||
@when("I run context list")
|
||||
def step_run_context_list(context):
|
||||
"""Run context list command."""
|
||||
runner = CliRunner()
|
||||
with patch("cleveragents.application.container.get_container") as mock_container:
|
||||
mock_context_service = MagicMock(spec=ContextService)
|
||||
mock_context_service.list_context = MagicMock(
|
||||
return_value=["file1.py", "file2.py"]
|
||||
)
|
||||
mock_container.return_value.context_service = mock_context_service
|
||||
|
||||
result = runner.invoke(context_app, ["list"])
|
||||
context.result = result
|
||||
|
||||
|
||||
@when('I run plan tell with instruction "{instruction}"')
|
||||
def step_run_plan_tell(context, instruction):
|
||||
"""Run plan tell command."""
|
||||
|
||||
@@ -237,6 +237,21 @@ def step_save_yaml_keys(context: Context) -> None:
|
||||
|
||||
@then("the output should be valid JSON")
|
||||
def step_output_valid_json(context: Context) -> None:
|
||||
# Support both context.result (CLI output format tests) and
|
||||
# context.last_command_output (ACMS context CLI tests)
|
||||
last_cmd_output = getattr(context, "last_command_output", "")
|
||||
if last_cmd_output:
|
||||
# ACMS context CLI test path: last_command_output takes priority
|
||||
try:
|
||||
context.json_output = json.loads(last_cmd_output)
|
||||
except json.JSONDecodeError as e:
|
||||
raise AssertionError(
|
||||
f"Output is not valid JSON: {e}\nOutput: {last_cmd_output}"
|
||||
) from e
|
||||
return
|
||||
if not hasattr(context, "result"):
|
||||
# No output captured at all
|
||||
raise AssertionError("No CLI output captured in context")
|
||||
assert context.result.exit_code == 0, (
|
||||
f"CLI exited with {context.result.exit_code}: {context.result.output}"
|
||||
)
|
||||
|
||||
@@ -407,7 +407,12 @@ def step_json_valid(context: Context) -> None:
|
||||
|
||||
@then('the JSON output should contain key "{key}"')
|
||||
def step_json_has_key(context: Context, key: str) -> None:
|
||||
data = json.loads(context.json_text)
|
||||
# Support both context.json_text (diff review tests) and
|
||||
# context.json_output (ACMS context CLI tests)
|
||||
if hasattr(context, "json_output"):
|
||||
data = context.json_output
|
||||
else:
|
||||
data = json.loads(context.json_text)
|
||||
assert key in data, f"Expected key '{key}' in JSON output"
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Deprecated alias: ``agents context <subcommand>`` (emits deprecation warning)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
@@ -16,6 +17,7 @@ import typer
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.cli.formatting import OutputFormat
|
||||
from cleveragents.cli.renderers import _get_console
|
||||
from cleveragents.core.exceptions import (
|
||||
CleverAgentsError,
|
||||
@@ -222,6 +224,21 @@ def context_add(
|
||||
recursive: Annotated[
|
||||
bool, typer.Option("-r", "--recursive", help="Add directories recursively")
|
||||
] = True,
|
||||
tag: Annotated[
|
||||
str | None,
|
||||
typer.Option("--tag", help="Tag for the context entry (e.g. 'production')"),
|
||||
] = None,
|
||||
policy: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--policy",
|
||||
help="Storage tier policy for the context entry (hot|warm|cold)",
|
||||
),
|
||||
] = None,
|
||||
output_format: Annotated[
|
||||
str,
|
||||
typer.Option("--format", help="Output format: table or json"),
|
||||
] = OutputFormat.TABLE.value,
|
||||
) -> None:
|
||||
"""Add files or directories to the current plan's context.
|
||||
|
||||
@@ -248,11 +265,13 @@ def context_add(
|
||||
# Add each path to context
|
||||
added_files: list[Path] = []
|
||||
already_in_context: list[Path] = []
|
||||
has_errors = False
|
||||
for path_str in paths:
|
||||
path = Path(path_str).resolve()
|
||||
|
||||
if not path.exists():
|
||||
console.print(f"[red]Error:[/red] Path does not exist: {path}")
|
||||
has_errors = True
|
||||
continue
|
||||
|
||||
files, already = context_service.add_to_context(
|
||||
@@ -261,6 +280,23 @@ def context_add(
|
||||
added_files.extend(files)
|
||||
already_in_context.extend(already)
|
||||
|
||||
if has_errors and not added_files:
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if output_format == "json":
|
||||
result: dict[str, Any] = {
|
||||
"status": "success" if added_files else "no_change",
|
||||
"added_files": [str(f) for f in added_files],
|
||||
"already_in_context": [str(f) for f in already_in_context],
|
||||
"count": len(added_files),
|
||||
}
|
||||
if tag is not None:
|
||||
result["tag"] = tag
|
||||
if policy is not None:
|
||||
result["policy"] = policy
|
||||
typer.echo(_json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
if added_files:
|
||||
console.print(
|
||||
f"[green]✓[/green] Added {len(added_files)} file(s) to context:"
|
||||
@@ -404,6 +440,10 @@ def context_list(
|
||||
resolve_path=True,
|
||||
),
|
||||
] = None,
|
||||
output_format: Annotated[
|
||||
str,
|
||||
typer.Option("--format", help="Output format: table or json"),
|
||||
] = OutputFormat.TABLE.value,
|
||||
) -> None:
|
||||
"""List all files in the current plan's context.
|
||||
|
||||
@@ -451,7 +491,38 @@ def context_list(
|
||||
console.print("Use 'agents actor context add <path>' to add files.")
|
||||
return
|
||||
|
||||
# Display context files
|
||||
if output_format == "json":
|
||||
entries: list[dict[str, Any]] = []
|
||||
for file_info in context_files:
|
||||
path_text, type_label, size, added_at, _ = _normalize_context_entry(
|
||||
file_info
|
||||
)
|
||||
entry: dict[str, Any] = {
|
||||
"path": path_text,
|
||||
"tier": type_label,
|
||||
"size": size if isinstance(size, int) else 0,
|
||||
"last_accessed": added_at,
|
||||
}
|
||||
# Include optional tag/policy fields if present on the entry
|
||||
if isinstance(file_info, dict):
|
||||
if "tag" in file_info:
|
||||
entry["tag"] = str(file_info["tag"])
|
||||
if "policy" in file_info:
|
||||
entry["policy"] = str(file_info["policy"])
|
||||
else:
|
||||
tag_val = getattr(file_info, "tag", None)
|
||||
if tag_val is not None:
|
||||
entry["tag"] = str(tag_val)
|
||||
policy_val = getattr(file_info, "policy", None)
|
||||
if policy_val is not None:
|
||||
entry["policy"] = str(policy_val)
|
||||
entries.append(entry)
|
||||
typer.echo(
|
||||
_json.dumps({"entries": entries, "total": len(entries)}, indent=2)
|
||||
)
|
||||
return
|
||||
|
||||
# Display context files as table
|
||||
table = Table(title=f"Context Files ({len(context_files)} total)")
|
||||
table.add_column("File Path", style="cyan")
|
||||
table.add_column("Type", style="green")
|
||||
|
||||
Reference in New Issue
Block a user