File size: 6,326 Bytes
5ef3875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"""Command-line entry point: ``ycode`` / ``python -m ycode``."""

from __future__ import annotations

import argparse
import contextlib
import os
import sys
import time
import traceback
from pathlib import Path

from ycode import __version__
from ycode.agent.context import detect_project
from ycode.agent.loop import TaskStatus
from ycode.config import APPROVAL_MODES, Config, load_config, load_env_files, ycode_home
from ycode.errors import YCodeError
from ycode.llm import create_provider
from ycode.ui.console import ConsoleUI, make_console


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="ycode",
        description="YCode - an AI coding agent for your terminal.",
    )
    parser.add_argument("task", nargs="*", help="Run a single task non-interactively, then exit.")
    parser.add_argument("-p", "--prompt", help="Same as passing a task: run it and exit.")
    parser.add_argument("-m", "--model", help="Model ID (overrides YCODE_MODEL and config files).")
    parser.add_argument("--provider", choices=("local", "anthropic"),
                        help="LLM provider: YCode's own model (default, no API key) or Claude "
                             "(anthropic, needs ANTHROPIC_API_KEY).")
    parser.add_argument("--local", nargs="?", const="", default=None, metavar="MODEL",
                        help="Use YCode's own model (the default). Optionally pick one: v2, v3, v4, v5, "
                             "or a model directory. Default: your trained model if you have one, else v5 "
                             "(v4 with less than 4 GB of free memory).")
    parser.add_argument("--max-steps", type=int, help="Maximum agent steps per task.")
    parser.add_argument("--approval-mode", choices=APPROVAL_MODES, help="How risky actions are approved.")
    parser.add_argument("--yes", action="store_true",
                        help="Shortcut for --approval-mode auto (blocked commands stay blocked).")
    parser.add_argument("-C", "--directory", help="Project directory (default: current directory).")
    parser.add_argument("--no-banner", action="store_true", help="Skip the start-up banner.")
    parser.add_argument("--version", action="version", version=f"YCode {__version__}")
    return parser


def _debug() -> bool:
    return os.environ.get("YCODE_DEBUG", "").lower() in ("1", "true", "yes")


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    console = make_console()
    ui = ConsoleUI(console)

    workspace = Path(args.directory or os.getcwd()).expanduser()
    if not workspace.is_dir():
        ui.print_error(YCodeError(f"Directory not found: {workspace}"))
        return 2
    workspace = workspace.resolve()

    load_env_files(workspace)
    try:
        config = load_config(
            workspace,
            cli_overrides={
                "model": args.model,
                "provider": "local" if args.local is not None else args.provider,
                "local_model": args.local or None,
                "max_steps": args.max_steps,
                "approval_mode": "auto" if args.yes else args.approval_mode,
            },
        )
    except YCodeError as exc:
        ui.print_error(exc)
        return 2

    if config.theme != "default":
        console = make_console(config.theme)
        ui = ConsoleUI(console)

    task = args.prompt or " ".join(args.task).strip() or None
    try:
        return run(config, workspace, ui, task=task, show_banner=not args.no_banner)
    except KeyboardInterrupt:
        ui.stop()
        console.print()
        return 130


def run(config: Config, workspace: Path, ui: ConsoleUI, *, task: str | None, show_banner: bool) -> int:
    from ycode.app import YCodeApp

    with ui.console.status("Inspecting project...", spinner="dots") if ui.console.is_terminal else contextlib.nullcontext():
        context = detect_project(workspace, config.ignore_dirs)

    try:
        provider = create_provider(config)
    except YCodeError as exc:
        if show_banner:
            from ycode.ui.banner import BannerInfo

            ui.print_banner(BannerInfo(version=__version__, model=config.model, directory=str(workspace),
                                       branch=context.branch, project_types=tuple(context.project_types),
                                       approval_mode=config.approval_mode), show_hint=False)
        ui.print_error(exc)
        return 1

    app = YCodeApp(config=config, workspace=workspace, provider=provider, ui=ui, context=context)
    if show_banner:
        ui.print_banner(app.banner_info())
    if not provider.supports_tools:
        ui.on_notice("Local model: answer-only mode. It answers questions and writes code in its reply, "
                     "but cannot read, edit or run files in your project.", "warning")
        ui.console.print()

    if task is not None:
        result = app.run_task(task)
        return 0 if result.status is TaskStatus.COMPLETED else 1
    return repl(app)


def repl(app) -> int:  # noqa: ANN001 - YCodeApp, imported lazily
    from ycode.commands import command_names
    from ycode.ui.prompts import InputReader

    reader = InputReader(ycode_home() / "history", commands=command_names())
    ui = app.ui
    last_interrupt = 0.0
    while not app.should_exit:
        try:
            line = reader.read()
        except KeyboardInterrupt:
            now = time.monotonic()
            if now - last_interrupt < 2.0:
                break
            last_interrupt = now
            ui.info("(Press Ctrl+C again or Ctrl+D to exit)")
            continue
        except EOFError:
            break
        try:
            app.handle_line(line)
        except KeyboardInterrupt:
            ui.stop()
            ui.info("Interrupted.")
        except YCodeError as exc:
            ui.print_error(exc)
        except Exception as exc:  # noqa: BLE001 - keep the session alive
            ui.stop()
            if _debug():
                traceback.print_exc()
            ui.print_error(YCodeError(
                f"Internal error: {type(exc).__name__}: {exc}",
                hint="Set YCODE_DEBUG=1 to see the full traceback.",
            ))
    ui.stop()
    ui.info("Goodbye.")
    return 0


if __name__ == "__main__":
    sys.exit(main())