Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 89 additions & 10 deletions src/claude_task_master/cli_commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
- path: Show path to config file
"""

from __future__ import annotations

import json
from typing import Any

import typer
from rich.console import Console
from rich.markdown import Markdown
Expand Down Expand Up @@ -68,22 +73,30 @@ def config_init(
def config_show(
raw: bool = typer.Option(False, "--raw", "-r", help="Show raw JSON without formatting"),
env: bool = typer.Option(False, "--env", "-e", help="Show environment variable overrides"),
show_secrets: bool = typer.Option(
False,
"--show-secrets",
help="Reveal API keys and other secrets (default: masked)",
),
) -> None:
"""📖 Display current configuration.

Shows the active configuration including any environment variable overrides.
Secrets (API keys) are masked unless --show-secrets is passed — this applies
to --raw output too, so piped JSON never leaks credentials by default.
Use --raw for machine-readable JSON output.

Examples:
claudetm config show
claudetm config show --raw
claudetm config show --env
claudetm config show --show-secrets
"""
if env:
_display_env_overrides()
_display_env_overrides(show_secrets=show_secrets)
return

_display_config(raw=raw)
_display_config(raw=raw, show_secrets=show_secrets)


@config_app.command(name="path")
Expand Down Expand Up @@ -114,15 +127,73 @@ def config_path(
console.print(str(path))


def _display_config(raw: bool = False) -> None:
# Field/env-var names whose values are treated as secrets and masked in output.
_SECRET_NAME_HINTS = ("key", "secret", "token", "password")


def _looks_secret(name: str) -> bool:
"""Return True if a field/env-var name looks like it holds a secret.

Args:
name: The config field name or environment variable name.

Returns:
True if the name matches a known secret hint (key/secret/token/password).
"""
lowered = name.lower()
return any(hint in lowered for hint in _SECRET_NAME_HINTS)


def _mask_secret(value: str) -> str:
"""Mask a secret value, keeping a short prefix as an identification hint.

Args:
value: The secret value to mask.

Returns:
A masked placeholder; short values collapse to ``***``.
"""
return f"{value[:8]}..." if len(value) > 8 else "***"


def _redact_secrets(data: Any) -> Any:
"""Recursively copy config data, masking any leaf whose key looks secret.

Only non-empty string values are masked; ``None``/unset fields are left as-is
so the output still shows which secrets are configured versus absent.

Args:
data: A config value (dict, list, or scalar) from ``model_dump()``.

Returns:
A new structure with secret leaves replaced by masked placeholders.
"""
if isinstance(data, dict):
redacted: dict[str, Any] = {}
for key, value in data.items():
if _looks_secret(key) and isinstance(value, str) and value:
redacted[key] = _mask_secret(value)
else:
redacted[key] = _redact_secrets(value)
return redacted
if isinstance(data, list):
return [_redact_secrets(item) for item in data]
return data


def _display_config(raw: bool = False, show_secrets: bool = False) -> None:
"""Display the current configuration.

Args:
raw: If True, output raw JSON without formatting.
show_secrets: If True, reveal secret values instead of masking them.
"""
try:
config = get_config()
config_json = config.model_dump_json(indent=2)
config_dict = config.model_dump()
display_dict = config_dict if show_secrets else _redact_secrets(config_dict)
secrets_masked = display_dict != config_dict
config_json = json.dumps(display_dict, indent=2)

if raw:
print(config_json)
Expand All @@ -141,6 +212,11 @@ def _display_config(raw: bool = False) -> None:
syntax = Syntax(config_json, "json", theme="monokai", line_numbers=False)
console.print(syntax)

if secrets_masked:
console.print(
"\n[dim]🔒 Secrets masked. Use '--show-secrets' to reveal them.[/dim]"
)

# Show env var overrides hint
overrides = get_env_overrides()
if overrides:
Expand All @@ -154,8 +230,12 @@ def _display_config(raw: bool = False) -> None:
raise typer.Exit(1) from None


def _display_env_overrides() -> None:
"""Display active environment variable overrides."""
def _display_env_overrides(show_secrets: bool = False) -> None:
"""Display active environment variable overrides.

Args:
show_secrets: If True, reveal secret values instead of masking them.
"""
overrides = get_env_overrides()

console.print("[bold blue]🔧 Environment Variable Overrides[/bold blue]\n")
Expand All @@ -180,10 +260,9 @@ def _display_env_overrides() -> None:

console.print("[bold]Active overrides:[/bold]\n")
for env_var, value in overrides.items():
# Mask sensitive values
if "key" in env_var.lower() or "secret" in env_var.lower():
masked = value[:8] + "..." if len(value) > 8 else "***"
console.print(f" [cyan]{env_var}[/cyan] = [dim]{masked}[/dim]")
# Mask sensitive values unless explicitly revealed
if not show_secrets and _looks_secret(env_var):
console.print(f" [cyan]{env_var}[/cyan] = [dim]{_mask_secret(value)}[/dim]")
else:
console.print(f" [cyan]{env_var}[/cyan] = {value}")

Expand Down
10 changes: 7 additions & 3 deletions src/claude_task_master/cli_commands/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rich.console import Console
from rich.markdown import Markdown

from ..core.agent_models import get_tools_for_phase
from ..core.state import StateManager

console = Console()
Expand Down Expand Up @@ -35,13 +36,16 @@ def status() -> None:
console.print(f"[cyan]Sessions:[/cyan] {state.session_count}")
console.print(f"[cyan]Run ID:[/cyan] {state.run_id}")

# Show tools based on current phase/status
# Show tools based on current phase/status. Derive from config so the
# display stays in sync with the actual restricted phase defaults.
if state.status == "planning":
console.print("[cyan]Tools:[/cyan] Read, Glob, Grep, Bash (read-only mode)")
planning_tools = get_tools_for_phase("planning")
tools_display = ", ".join(planning_tools) if planning_tools else "All"
console.print(f"[cyan]Tools:[/cyan] {tools_display} (read-only mode)")
elif state.status == "working":
console.print("[cyan]Tools:[/cyan] All (bypassPermissions mode)")
else:
# For blocked, paused, success, failed - show what was last used
# For blocked, paused, stopped, success, failed - show what was last used
console.print("[cyan]Tools:[/cyan] All (bypassPermissions mode)")

if state.current_pr:
Expand Down
46 changes: 3 additions & 43 deletions src/claude_task_master/cli_commands/mailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,49 +241,9 @@ def mailbox_callback(ctx: typer.Context) -> None:
mailbox_status()


@mailbox_app.command("send")
def mailbox_send_command(
message: Annotated[str, typer.Argument(help="Message content to send")],
sender: Annotated[
str,
typer.Option("--sender", "-s", help="Sender identifier"),
] = "cli",
priority: Annotated[
int,
typer.Option("--priority", "-p", help="Priority level (0=low, 1=normal, 2=high, 3=urgent)"),
] = 1,
) -> None:
"""Send a message to the mailbox.

Adds a new message that will be processed after the current task completes.
The orchestrator checks the mailbox after each task and updates the plan
if messages are present.

Examples:
claudetm mailbox send "Please also update the README"
claudetm mailbox send "Fix the auth bug first" --priority 3
claudetm mailbox send "Low priority cleanup" -p 0 -s "supervisor"
"""
mailbox_send(message, sender, priority)


@mailbox_app.command("clear")
def mailbox_clear_command(
force: Annotated[
bool,
typer.Option("--force", "-f", help="Skip confirmation"),
] = False,
) -> None:
"""Clear all messages from the mailbox.

Removes all pending messages. This is useful to cancel pending plan updates
or start fresh.

Examples:
claudetm mailbox clear
claudetm mailbox clear -f
"""
mailbox_clear(force)
# Register the original command functions with the mailbox_app
mailbox_app.command("send")(mailbox_send)
mailbox_app.command("clear")(mailbox_clear)


def register_mailbox_commands(app: typer.Typer) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/claude_task_master/cli_commands/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,15 @@ def _print_profile(profile: Profile, active: bool) -> None:
@profile_app.command(name="remove")
def profile_remove(
name: str = typer.Argument(..., help="Profile name to remove"),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
force: bool = typer.Option(False, "--force", "-f", help="Allow removing active profile"),
) -> None:
"""🗑️ Remove a profile (its config dir is left on disk)."""
manager = ProfileManager()
if not force and not typer.confirm(f"Remove profile '{name}'?"):
console.print("[yellow]Cancelled[/yellow]")
raise typer.Exit(0)
try:
manager.remove(name)
manager.remove(name, force=force)
except ProfileError as e:
console.print(f"[red]{e}[/red]")
raise typer.Exit(1) from None
Expand Down
Loading
Loading