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
135 changes: 129 additions & 6 deletions dotscope/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .ingest import _cmd_ingest, _cmd_impact, _cmd_backtest, _cmd_conventions, _cmd_diff, _cmd_bootstrap
from .hooks import _cmd_observe, _cmd_incremental, _cmd_hook, _cmd_refresh, _cmd_check, _cmd_check_backtest, _cmd_voice
from .serve import _cmd_serve
from .ops import _cmd_ops
from .trial import _cmd_trial
from .cut_score import _cmd_cut_score
from .orchestrator import _cmd_orchestrator
Expand All @@ -16,6 +17,44 @@

import sys

_REFRESH_ACTIONS = {"scope", "repo", "enqueue", "run", "status", "recover"}


def _normalize_legacy_refresh_args(args_list):
"""Rewrite legacy `dotscope refresh <scopes...>` into the v1 grammar."""
args_list = list(args_list)
try:
idx = args_list.index("refresh")
except ValueError:
return args_list

tail = args_list[idx + 1:]
if not tail:
args_list[idx + 1:idx + 1] = ["--legacy-refresh-syntax", "repo"]
return args_list

first = tail[0]
if first in _REFRESH_ACTIONS:
return args_list

if first == "--repo":
del args_list[idx + 1]
args_list[idx + 1:idx + 1] = ["--legacy-refresh-syntax", "repo"]
return args_list

if first.startswith("-"):
if "--repo" in tail:
repo_idx = idx + 1 + tail.index("--repo")
del args_list[repo_idx]
args_list[idx + 1:idx + 1] = ["--legacy-refresh-syntax", "repo"]
elif "--async" in tail:
args_list[idx + 1:idx + 1] = ["--legacy-refresh-syntax", "repo"]
return args_list

args_list[idx + 1:idx + 1] = ["--legacy-refresh-syntax", "scope"]
return args_list


def _safe_print(text, **kwargs):
"""Print with ASCII fallback for Windows cp1252 terminals."""
try:
Expand All @@ -29,7 +68,7 @@ def main(argv=None):
consume_decode_warnings()

# Intercept help before argparse touches it
args_list = argv if argv is not None else sys.argv[1:]
args_list = _normalize_legacy_refresh_args(argv if argv is not None else sys.argv[1:])
if not args_list or args_list == ["help"] or args_list == ["--help"] or args_list == ["-h"]:
from ..ux.help import print_help
print_help()
Expand All @@ -49,6 +88,15 @@ def main(argv=None):
)
parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
parser.add_argument("-h", "--help", action="store_true", dest="show_help")
parser.add_argument("--root", default=None, help="Repository root path")
parser.add_argument(
"--format",
dest="output_format",
choices=["human", "json", "legacy-json"],
default="human",
help="Output format for control-plane commands",
)
parser.add_argument("--json", dest="output_format", action="store_const", const="json")

sub = parser.add_subparsers(dest="command")

Expand Down Expand Up @@ -140,19 +188,74 @@ def main(argv=None):
hook_sub.add_parser("claude", help="Install Claude Code pre-commit enforcement")

# --- refresh ---
p_refresh = sub.add_parser("refresh", help="Refresh scopes (synchronous by default)")
p_refresh.add_argument("scopes", nargs="*", help="Scope names to refresh (omit for full repo)")
p_refresh.add_argument("--repo", action="store_true", help="Force full repo refresh")
p_refresh.add_argument("--async", dest="run_async", action="store_true", help="Queue and return (legacy async mode)")
p_refresh = sub.add_parser("refresh", help="Refresh scopes and runtime state")
p_refresh.add_argument(
"--legacy-refresh-syntax",
action="store_true",
help=argparse.SUPPRESS,
)
refresh_sub = p_refresh.add_subparsers(dest="refresh_action")
p_refresh_scope = refresh_sub.add_parser("scope", help="Refresh specific scopes")
p_refresh_scope.add_argument("scopes", nargs="+", help="Scope names to refresh")
p_refresh_scope.add_argument("--async", dest="run_async", action="store_true", help="Queue and return")
p_refresh_repo = refresh_sub.add_parser("repo", help="Refresh the entire repo")
p_refresh_repo.add_argument("--async", dest="run_async", action="store_true", help="Queue and return")
p_refresh_enqueue = refresh_sub.add_parser("enqueue", help="Queue runtime refresh work")
p_refresh_enqueue.add_argument("scopes", nargs="*", help="Scope names to refresh")
p_refresh_enqueue.add_argument("--commit", default=None, help="Classify a commit into refresh work")
p_refresh_enqueue.add_argument("--repo", action="store_true", help="Enqueue a full repo runtime refresh")
p_refresh_enqueue.add_argument("--reason", default="", help="Reason stored with the queued job")
p_refresh_run = refresh_sub.add_parser("run", help="Run queued refresh work")
p_refresh_run.add_argument("--drain", action="store_true", help="Drain the entire queue")
refresh_sub.add_parser("status", help="Show refresh worker and queue status")
p_refresh_status = refresh_sub.add_parser("status", help="Show refresh worker and queue status")
p_refresh_status.add_argument(
"--json",
dest="output_format",
action="store_const",
const="json",
default=argparse.SUPPRESS,
)
p_refresh_status.add_argument(
"--format",
dest="output_format",
choices=["human", "json", "legacy-json"],
default=argparse.SUPPRESS,
)
p_refresh_recover = refresh_sub.add_parser("recover", help="Recover stale refresh locks and non-terminal jobs")
p_refresh_recover.add_argument("--dry-run", action="store_true", help="Plan recovery without mutating state")
p_refresh_recover.add_argument(
"--json",
dest="output_format",
action="store_const",
const="json",
default=argparse.SUPPRESS,
)
p_refresh_recover.add_argument(
"--format",
dest="output_format",
choices=["human", "json", "legacy-json"],
default=argparse.SUPPRESS,
)

# --- ops ---
p_ops = sub.add_parser("ops", help="Operator status and recovery checks")
ops_sub = p_ops.add_subparsers(dest="ops_action")
p_ops_status = ops_sub.add_parser("status", help="Show production health status")
p_ops_status.add_argument(
"--json",
dest="output_format",
action="store_const",
const="json",
default=argparse.SUPPRESS,
help="Output as JSON",
)
p_ops_status.add_argument(
"--format",
dest="output_format",
choices=["human", "json", "legacy-json"],
default=argparse.SUPPRESS,
help="Output format",
)

# --- utility ---
p_utility = sub.add_parser("utility", help="Show utility scores for a scope")
Expand Down Expand Up @@ -401,7 +504,21 @@ def main(argv=None):
print_help()
return

previous_dotscope_root = os.environ.get("DOTSCOPE_ROOT")
root_env_overridden = False

try:
if getattr(args, "root", None):
from ..models.control_plane import canonical_root
from ..paths.repo import find_repo_root

resolved_root = find_repo_root(args.root)
if resolved_root is None:
raise ValueError("Could not find repository root")
args.root = canonical_root(resolved_root)
os.environ["DOTSCOPE_ROOT"] = args.root
root_env_overridden = True

handler = {
"resolve": _cmd_resolve,
"context": _cmd_context,
Expand All @@ -420,6 +537,7 @@ def main(argv=None):
"incremental": _cmd_incremental,
"hook": _cmd_hook,
"refresh": _cmd_refresh,
"ops": _cmd_ops,
"utility": _cmd_utility,
"virtual": _cmd_virtual,
"lessons": _cmd_lessons,
Expand All @@ -444,6 +562,11 @@ def main(argv=None):
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
if root_env_overridden:
if previous_dotscope_root is None:
os.environ.pop("DOTSCOPE_ROOT", None)
else:
os.environ["DOTSCOPE_ROOT"] = previous_dotscope_root
warnings = consume_decode_warnings()
if warnings:
count = len(warnings)
Expand Down
7 changes: 7 additions & 0 deletions dotscope/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Allow python -m dotscope.cli."""

from . import main


if __name__ == "__main__":
main()
49 changes: 49 additions & 0 deletions dotscope/cli/control_plane.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""CLI helpers for rendering control-plane operation results."""

from __future__ import annotations

import json
import sys
from typing import Callable, Optional

from ..models.control_plane import OperationResult
from ..paths.repo import find_repo_root


def cli_output_format(args) -> str:
fmt = getattr(args, "output_format", None)
if fmt:
return fmt
if getattr(args, "json_output", False):
return "json"
return "human"


def cli_root(args) -> str:
root_hint = getattr(args, "root", None)
root = find_repo_root(root_hint) if root_hint else find_repo_root()
if root is None:
raise ValueError("Could not find repository root")
from ..models.control_plane import canonical_root

return canonical_root(root) or root


def emit_operation_result(
args,
result: OperationResult,
*,
render_human: Callable[[OperationResult], None],
legacy_payload: Optional[Callable[[OperationResult], dict]] = None,
) -> None:
fmt = cli_output_format(args)
if fmt == "json":
print(result.to_json(include_root=True))
sys.exit(result.exit_code)
if fmt == "legacy-json":
payload = legacy_payload(result) if legacy_payload else result.data
print(json.dumps(payload, indent=2))
sys.exit(result.exit_code)

render_human(result)
sys.exit(result.exit_code)
Loading
Loading