remote-coder 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/ai/__init__.py ADDED
File without changes
app/ai/base.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import threading
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from app.jobs.schemas import JobMode
11
+ from app.monitoring.events import EventLogger
12
+
13
+
14
+ def instruction_for_runner_mode(instruction: str, mode: JobMode) -> str:
15
+ if mode == JobMode.PLAN:
16
+ return (
17
+ "You are in PLAN mode. Read the codebase and produce a concrete change plan. "
18
+ "Do not modify files.\n\n"
19
+ f"User request:\n{instruction}"
20
+ )
21
+ if mode == JobMode.ASK:
22
+ return (
23
+ "You are in ASK mode. Analyze the codebase and answer the user's question. "
24
+ "Do not modify files.\n\n"
25
+ f"User question:\n{instruction}"
26
+ )
27
+ return instruction
28
+
29
+
30
+ @dataclass
31
+ class RunnerInput:
32
+ instruction: str
33
+ cwd: Path
34
+ timeout_seconds: int
35
+ model_id: str | None = None
36
+ env: dict[str, str] | None = None
37
+ cancel_event: threading.Event | None = field(default=None, compare=False)
38
+ mode: JobMode = JobMode.AGENT
39
+
40
+
41
+ @dataclass
42
+ class RunnerResult:
43
+ exit_code: int
44
+ stdout: str
45
+ stderr: str
46
+ started_at: datetime
47
+ finished_at: datetime
48
+
49
+
50
+ class AiRunner(ABC):
51
+ name: str
52
+
53
+ @abstractmethod
54
+ def run(self, runner_input: RunnerInput) -> RunnerResult:
55
+ raise NotImplementedError
56
+
57
+
58
+ class BaseCliRunner(AiRunner):
59
+ _log: EventLogger
60
+
61
+ @abstractmethod
62
+ def build_argv(self, runner_input: RunnerInput) -> list[str]:
63
+ raise NotImplementedError
64
+
65
+ def _start_log_detail(self, runner_input: RunnerInput) -> str:
66
+ return ""
67
+
68
+ def run(self, runner_input: RunnerInput) -> RunnerResult:
69
+ cwd_name = runner_input.cwd.name
70
+ self._log.info(
71
+ "start cwd=%s timeout=%d%s instruction_len=%d",
72
+ cwd_name,
73
+ runner_input.timeout_seconds,
74
+ self._start_log_detail(runner_input),
75
+ len(runner_input.instruction),
76
+ )
77
+ started_at = datetime.now(UTC)
78
+ argv = self.build_argv(runner_input)
79
+ proc = subprocess.Popen(
80
+ argv,
81
+ cwd=runner_input.cwd,
82
+ env=runner_input.env,
83
+ stdout=subprocess.PIPE,
84
+ stderr=subprocess.PIPE,
85
+ text=True,
86
+ )
87
+ self._log.info("process spawned pid=%s cwd=%s", proc.pid, cwd_name)
88
+ cancelled = threading.Event()
89
+ if runner_input.cancel_event is not None:
90
+ cancel_event = runner_input.cancel_event
91
+
92
+ def _watch() -> None:
93
+ cancel_event.wait()
94
+ if proc.poll() is None:
95
+ self._log.warning("cancel requested pid=%s", proc.pid)
96
+ proc.terminate()
97
+ cancelled.set()
98
+
99
+ threading.Thread(target=_watch, daemon=True).start()
100
+ try:
101
+ stdout_data, stderr_data = proc.communicate(timeout=runner_input.timeout_seconds)
102
+ except subprocess.TimeoutExpired:
103
+ proc.kill()
104
+ stdout_data, stderr_data = proc.communicate()
105
+ self._log.warning(
106
+ "timeout after %ds stdout_len=%d stderr_len=%d",
107
+ runner_input.timeout_seconds,
108
+ len(stdout_data),
109
+ len(stderr_data),
110
+ )
111
+ raise
112
+ finished_at = datetime.now(UTC)
113
+ if cancelled.is_set():
114
+ raise RuntimeError("The job was cancelled.")
115
+ dur_ms = int((finished_at - started_at).total_seconds() * 1000)
116
+ self._log.info(
117
+ "done exit=%d dur_ms=%d stdout_len=%d stderr_len=%d",
118
+ proc.returncode,
119
+ dur_ms,
120
+ len(stdout_data),
121
+ len(stderr_data),
122
+ )
123
+ return RunnerResult(
124
+ exit_code=proc.returncode,
125
+ stdout=stdout_data,
126
+ stderr=stderr_data,
127
+ started_at=started_at,
128
+ finished_at=finished_at,
129
+ )
app/ai/claude.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
4
+ from app.jobs.schemas import JobMode
5
+ from app.monitoring.events import EventLogger
6
+
7
+
8
+ class ClaudeRunner(BaseCliRunner):
9
+ name = "claude"
10
+ _log = EventLogger("app.ai.claude", "ai.runner")
11
+
12
+ def build_argv(self, runner_input: RunnerInput) -> list[str]:
13
+ if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
14
+ prompt = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
15
+ argv = ["claude", "-p", prompt, "--permission-mode", "plan"]
16
+ else:
17
+ argv = ["claude", "-p", runner_input.instruction, "--dangerously-skip-permissions"]
18
+ if runner_input.model_id:
19
+ argv.extend(["--model", runner_input.model_id])
20
+ return argv
app/ai/codex.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
4
+ from app.jobs.schemas import JobMode
5
+ from app.models import CodexSandboxMode
6
+ from app.monitoring.events import EventLogger
7
+
8
+
9
+ class CodexRunner(BaseCliRunner):
10
+ name = "codex"
11
+ _log = EventLogger("app.ai.codex", "ai.runner")
12
+
13
+ def __init__(self, sandbox: CodexSandboxMode = CodexSandboxMode.WORKSPACE_WRITE) -> None:
14
+ self._sandbox = sandbox
15
+
16
+ def _resolve_sandbox(self, runner_input: RunnerInput) -> CodexSandboxMode:
17
+ if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
18
+ return CodexSandboxMode.READ_ONLY
19
+ return self._sandbox
20
+
21
+ def _start_log_detail(self, runner_input: RunnerInput) -> str:
22
+ return f" sandbox={self._resolve_sandbox(runner_input).value}"
23
+
24
+ def build_argv(self, runner_input: RunnerInput) -> list[str]:
25
+ sandbox = self._resolve_sandbox(runner_input)
26
+ if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
27
+ instruction = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
28
+ else:
29
+ instruction = runner_input.instruction
30
+ argv = ["codex", "exec"]
31
+ if runner_input.model_id:
32
+ argv.extend(["--model", runner_input.model_id])
33
+ argv.extend(["--sandbox", sandbox.value, instruction])
34
+ return argv
app/ai/factory.py ADDED
@@ -0,0 +1,27 @@
1
+ from collections.abc import Callable
2
+
3
+ from app.ai.base import AiRunner
4
+ from app.ai.claude import ClaudeRunner
5
+ from app.ai.codex import CodexRunner
6
+ from app.ai.gemini import GeminiRunner
7
+ from app.models import CodexSandboxMode, ModelName
8
+
9
+
10
+ class UnknownModelError(ValueError):
11
+ pass
12
+
13
+
14
+ class AiRunnerFactory:
15
+ def __init__(self, codex_sandbox: CodexSandboxMode = CodexSandboxMode.WORKSPACE_WRITE) -> None:
16
+ self._codex_sandbox = codex_sandbox
17
+
18
+ def create(self, model_name: ModelName) -> AiRunner:
19
+ builders: dict[ModelName, Callable[[], AiRunner]] = {
20
+ ModelName.CLAUDE: ClaudeRunner,
21
+ ModelName.CODEX: lambda: CodexRunner(sandbox=self._codex_sandbox),
22
+ ModelName.GEMINI: GeminiRunner,
23
+ }
24
+ builder = builders.get(model_name)
25
+ if builder is None:
26
+ raise UnknownModelError(f"Unsupported model: {model_name}")
27
+ return builder()
app/ai/gemini.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
4
+ from app.jobs.schemas import JobMode
5
+ from app.monitoring.events import EventLogger
6
+
7
+
8
+ class GeminiRunner(BaseCliRunner):
9
+ name = "gemini"
10
+ _log = EventLogger("app.ai.gemini", "ai.runner")
11
+
12
+ def build_argv(self, runner_input: RunnerInput) -> list[str]:
13
+ if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
14
+ prompt = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
15
+ argv = ["gemini", "--skip-trust", "-p", prompt]
16
+ else:
17
+ argv = ["gemini", "--approval-mode", "yolo", "--skip-trust", "-p", runner_input.instruction]
18
+ if runner_input.model_id:
19
+ argv.extend(["--model", runner_input.model_id])
20
+ return argv
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from app.models import ModelName
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ModelOption:
10
+ label: str
11
+ value: str
12
+
13
+
14
+ MODEL_CATALOG: dict[ModelName, tuple[ModelOption, ...]] = {
15
+ ModelName.CLAUDE: (
16
+ ModelOption("opus", "opus"),
17
+ ModelOption("sonnet", "sonnet"),
18
+ ModelOption("claude-opus-4-7", "claude-opus-4-7"),
19
+ ModelOption("claude-sonnet-4-6", "claude-sonnet-4-6"),
20
+ ),
21
+ ModelName.CODEX: (
22
+ ModelOption("gpt-5.3-codex", "gpt-5.3-codex"),
23
+ ModelOption("gpt-5.5", "gpt-5.5"),
24
+ ModelOption("gpt-5.4", "gpt-5.4"),
25
+ ModelOption("gpt-5.4-mini", "gpt-5.4-mini"),
26
+ ModelOption("gpt-5", "gpt-5"),
27
+ ),
28
+ ModelName.GEMINI: (
29
+ ModelOption("auto", "auto"),
30
+ ModelOption("gemini-3.1-pro-preview", "gemini-3.1-pro-preview"),
31
+ ModelOption("gemini-3-pro-preview", "gemini-3-pro-preview"),
32
+ ModelOption("gemini-3-flash-preview", "gemini-3-flash-preview"),
33
+ ModelOption("gemini-2.5-pro", "gemini-2.5-pro"),
34
+ ),
35
+ }
36
+
37
+
38
+ def get_model_options(provider: ModelName) -> tuple[ModelOption, ...]:
39
+ return MODEL_CATALOG.get(provider, ())
40
+
41
+
42
+ def is_valid_model_id(provider: ModelName, model_id: str) -> bool:
43
+ return any(option.value == model_id for option in get_model_options(provider))
44
+
45
+
46
+ def format_model_selection(provider: ModelName, model_id: str | None = None) -> str:
47
+ return f"{provider.value} / {model_id}" if model_id else provider.value
app/ai/usage.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import Final
6
+
7
+ _MODEL_VALUE_LIMIT: Final[int] = 80
8
+
9
+ _MODEL_FIELD_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
10
+ re.compile(
11
+ r"(?im)^\s*(?:actual\s+model|selected\s+model|current\s+model|model|์‚ฌ์šฉ\s*๋ชจ๋ธ)\s*[:=]\s*([^\n,;]+)"
12
+ ),
13
+ re.compile(r"(?im)\b(?:using|selected)\s+(?:model\s+)?([A-Za-z][\w .:/-]{1,70}\d(?:\.\d+)?)"),
14
+ )
15
+ _TOKEN_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
16
+ re.compile(
17
+ r"(?i)\b(input|prompt|output|completion|cached|cache\s+read|cache\s+write|total)\s*"
18
+ r"(?:tokens?|ํ† ํฐ)\s*[:=]\s*([0-9][0-9,._]*)"
19
+ ),
20
+ re.compile(
21
+ r"(?i)\b(input_tokens|prompt_tokens|output_tokens|completion_tokens|cached_tokens|total_tokens)"
22
+ r'["\']?\s*[:=]\s*([0-9][0-9,._]*)'
23
+ ),
24
+ re.compile(
25
+ r"(?i)\b(input|prompt|output|completion|cached|total)\s*[:=]\s*([0-9][0-9,._]*)\s*(?:tokens?|ํ† ํฐ)"
26
+ ),
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RunnerUsage:
32
+ actual_model: str | None = None
33
+ token_usage: dict[str, int] = field(default_factory=dict)
34
+
35
+ @property
36
+ def total_tokens(self) -> int | None:
37
+ if "total" in self.token_usage:
38
+ return self.token_usage["total"]
39
+ values = [
40
+ value
41
+ for label, value in self.token_usage.items()
42
+ if label not in {"total", "cache read", "cache write"}
43
+ ]
44
+ if not values:
45
+ return None
46
+ return sum(values)
47
+
48
+
49
+ def extract_runner_usage(text: str) -> RunnerUsage:
50
+ return RunnerUsage(
51
+ actual_model=_extract_actual_model(text),
52
+ token_usage=_extract_token_metrics(text),
53
+ )
54
+
55
+
56
+ def merge_token_usage(target: dict[str, int], source: dict[str, int]) -> None:
57
+ for label, value in source.items():
58
+ target[label] = target.get(label, 0) + value
59
+
60
+
61
+ def format_token_usage(token_usage: dict[str, int]) -> str | None:
62
+ total = RunnerUsage(token_usage=token_usage).total_tokens
63
+ if total is None:
64
+ return None
65
+ details = _format_token_usage_details(token_usage)
66
+ if details:
67
+ return f"{total:,} ({details})"
68
+ return f"{total:,}"
69
+
70
+
71
+ def _format_token_usage_details(token_usage: dict[str, int]) -> str | None:
72
+ labels = ("input", "output", "cached", "total", "cache read", "cache write")
73
+ rendered = [
74
+ f"{label}={token_usage[label]:,}"
75
+ for label in labels
76
+ if label in token_usage
77
+ ]
78
+ rendered.extend(
79
+ f"{label}={value:,}"
80
+ for label, value in sorted(token_usage.items())
81
+ if label not in labels
82
+ )
83
+ return ", ".join(rendered) if rendered else None
84
+
85
+
86
+ def _extract_actual_model(text: str) -> str | None:
87
+ for pattern in _MODEL_FIELD_PATTERNS:
88
+ match = pattern.search(text)
89
+ if not match:
90
+ continue
91
+ value = _sanitize_metric_value(match.group(1))
92
+ if value:
93
+ return value[:_MODEL_VALUE_LIMIT]
94
+ return None
95
+
96
+
97
+ def _extract_token_metrics(text: str) -> dict[str, int]:
98
+ metrics: dict[str, int] = {}
99
+ for pattern in _TOKEN_PATTERNS:
100
+ for match in pattern.finditer(text):
101
+ label = _normalize_token_label(match.group(1))
102
+ value = _parse_int(match.group(2))
103
+ if value is None:
104
+ continue
105
+ metrics[label] = metrics.get(label, 0) + value
106
+ return metrics
107
+
108
+
109
+ def _normalize_token_label(raw: str) -> str:
110
+ key = raw.lower().replace("_", " ").strip()
111
+ if key in {"prompt", "prompt tokens"}:
112
+ return "input"
113
+ if key in {"completion", "completion tokens"}:
114
+ return "output"
115
+ if key in {"input tokens"}:
116
+ return "input"
117
+ if key in {"output tokens"}:
118
+ return "output"
119
+ if key in {"cached tokens"}:
120
+ return "cached"
121
+ if key in {"total tokens"}:
122
+ return "total"
123
+ return key
124
+
125
+
126
+ def _parse_int(raw: str) -> int | None:
127
+ normalized = raw.replace(",", "").replace("_", "").strip()
128
+ if not normalized.isdigit():
129
+ return None
130
+ return int(normalized)
131
+
132
+
133
+ def _sanitize_metric_value(raw: str) -> str:
134
+ return re.sub(r"\s+", " ", raw).strip().strip("`'\"")
app/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ import uvicorn
10
+
11
+ from app import __version__
12
+
13
+ _AI_CLI_TOOLS = ("claude", "codex", "gemini")
14
+ _VALID_MODELS = ("claude", "codex", "gemini")
15
+
16
+
17
+ def _add_server_args(subparser: argparse.ArgumentParser) -> None:
18
+ subparser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
19
+ subparser.add_argument("--port", default=8000, type=int, help="Port to bind")
20
+ subparser.add_argument("--reload", action="store_true", help="Enable Uvicorn reload mode")
21
+ subparser.add_argument("--log-level", default="info", help="Uvicorn log level")
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="remote-coder")
26
+ parser.add_argument("--version", action="version", version=f"remote-coder {__version__}")
27
+
28
+ subparsers = parser.add_subparsers(dest="command")
29
+
30
+ init = subparsers.add_parser("init", help="Interactive first-time setup (writes global config)")
31
+ init.add_argument("--force", action="store_true", help="Overwrite existing config without asking")
32
+
33
+ up = subparsers.add_parser(
34
+ "up", help="Start ngrok tunnel, register Telegram webhooks, and run the server"
35
+ )
36
+ _add_server_args(up)
37
+ up.add_argument(
38
+ "--no-tunnel",
39
+ action="store_true",
40
+ help="Run the server only (skip ngrok and webhook registration)",
41
+ )
42
+
43
+ subparsers.add_parser("doctor", help="Check prerequisites (ngrok, AI CLIs)")
44
+ return parser
45
+
46
+
47
+ def main(argv: Sequence[str] | None = None) -> None:
48
+ parser = build_parser()
49
+ args = parser.parse_args(argv)
50
+ if args.command is None:
51
+ args = parser.parse_args(["up"])
52
+
53
+ if args.command == "init":
54
+ run_init(force=args.force)
55
+ elif args.command == "doctor":
56
+ run_doctor()
57
+ else:
58
+ run_up(
59
+ host=args.host,
60
+ port=args.port,
61
+ reload=args.reload,
62
+ log_level=args.log_level,
63
+ tunnel=not args.no_tunnel,
64
+ )
65
+
66
+
67
+ def _run_server(*, host: str, port: int, reload: bool, log_level: str) -> None:
68
+ print("๐Ÿš€ ์„œ๋ฒ„๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค... (์ข…๋ฃŒ: Ctrl+C)")
69
+ uvicorn.run("app.main:app", host=host, port=port, reload=reload, log_level=log_level)
70
+
71
+
72
+ def run_up(*, host: str, port: int, reload: bool, log_level: str, tunnel: bool = True) -> None:
73
+ if not tunnel:
74
+ _run_server(host=host, port=port, reload=reload, log_level=log_level)
75
+ return
76
+
77
+ from app.config import get_settings
78
+ from app.telegram.webhook_registration import register_all_enabled_projects
79
+ from app.tunnel import NgrokTunnel, TunnelError
80
+
81
+ ngrok = NgrokTunnel(port)
82
+ try:
83
+ print("๐ŸŒ ngrok ํ„ฐ๋„์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค...")
84
+ public_url = ngrok.start()
85
+ except TunnelError as exc:
86
+ print(f"โŒ {exc}")
87
+ raise SystemExit(1) from exc
88
+
89
+ print(f"๐Ÿ”— ๊ณต๊ฐœ HTTPS ์ฃผ์†Œ: {public_url}")
90
+ os.environ["TELEGRAM_WEBHOOK_PUBLIC_BASE_URL"] = public_url
91
+ get_settings.cache_clear()
92
+ settings = get_settings()
93
+
94
+ print("๐Ÿ“จ Telegram webhook/๋ช…๋ น์–ด ๋ฉ”๋‰ด๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค...")
95
+ if not register_all_enabled_projects(public_url, settings):
96
+ print("โš ๏ธ ์ผ๋ถ€ ํ”„๋กœ์ ํŠธ webhook ๋“ฑ๋ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ธฐ์กด ๋“ฑ๋ก์ด ์œ ํšจํ•˜๋ฉด ๊ณ„์† ๋™์ž‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
97
+
98
+ try:
99
+ _run_server(host=host, port=port, reload=reload, log_level=log_level)
100
+ finally:
101
+ ngrok.stop()
102
+ print("๐Ÿ›‘ ngrok ํ„ฐ๋„์„ ์ข…๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.")
103
+
104
+
105
+ def run_init(*, force: bool) -> None:
106
+ from app.config import Settings, remote_coder_home
107
+ from app.projects.registry import ProjectRegistry, projects_config_path_for_settings
108
+
109
+ home = remote_coder_home()
110
+ env_path = home / ".env"
111
+ if env_path.exists() and not force:
112
+ answer = input(f"์ด๋ฏธ ์„ค์ • ํŒŒ์ผ์ด ์žˆ์Šต๋‹ˆ๋‹ค ({env_path}). ๋ฎ์–ด์“ธ๊นŒ์š”? [y/N]: ").strip().lower()
113
+ if answer not in ("y", "yes"):
114
+ print("์„ค์ •์„ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค.")
115
+ return
116
+
117
+ print("Remote AI Coder ์„ค์ •์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. (Ctrl+C๋กœ ์ทจ์†Œ)\n")
118
+
119
+ project_root = _prompt_existing_dir("AI ์ž‘์—… ๋Œ€์ƒ Git ์ €์žฅ์†Œ ๊ฒฝ๋กœ")
120
+ worktree_dir = Path(
121
+ _prompt("worktree ๋””๋ ‰ํ„ฐ๋ฆฌ", default=str(home / "worktrees"))
122
+ ).expanduser()
123
+ default_project = _prompt("ํ”„๋กœ์ ํŠธ ์ด๋ฆ„", default=project_root.name)
124
+ bot_token = _prompt_required("Telegram Bot Token (BotFather)")
125
+ chat_ids = _prompt_chat_ids("ํ—ˆ์šฉํ•  Telegram Chat ID (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„)")
126
+ model = _prompt_choice("๊ธฐ๋ณธ ๋ชจ๋ธ", _VALID_MODELS, default="claude")
127
+
128
+ env_values = {
129
+ "TELEGRAM_BOT_TOKEN": bot_token,
130
+ "TELEGRAM_ALLOWED_CHAT_IDS": ",".join(str(cid) for cid in chat_ids),
131
+ "DEFAULT_MODEL": model,
132
+ "DEFAULT_PROJECT": default_project,
133
+ "PROJECT_ROOT": str(project_root),
134
+ "WORKTREE_BASE_DIR": str(worktree_dir),
135
+ }
136
+ _write_env_file(env_path, env_values)
137
+ print(f"\nโœ… ์„ค์ •์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค: {env_path}")
138
+
139
+ settings = Settings(
140
+ telegram_bot_token=bot_token,
141
+ telegram_allowed_chat_ids=chat_ids,
142
+ default_model=model,
143
+ default_project=default_project,
144
+ project_root=project_root,
145
+ worktree_base_dir=worktree_dir,
146
+ _env_file=None,
147
+ )
148
+ config_path = projects_config_path_for_settings(
149
+ settings.project_root, settings.projects_config_path
150
+ )
151
+ registry = ProjectRegistry(config_path)
152
+ registry.ensure_seeded_from_settings(settings)
153
+ if registry.list_projects():
154
+ print(f"โœ… ํ”„๋กœ์ ํŠธ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ: {config_path}")
155
+ else:
156
+ print(f"โ„น๏ธ ๊ธฐ์กด ํ”„๋กœ์ ํŠธ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋ฅผ ์œ ์ง€ํ–ˆ์Šต๋‹ˆ๋‹ค: {config_path}")
157
+
158
+ print()
159
+ run_doctor()
160
+ print("\n๋‹ค์Œ ๋‹จ๊ณ„: `remote-coder up` ์œผ๋กœ ์„œ๋ฒ„๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”.")
161
+
162
+
163
+ def run_doctor() -> None:
164
+ from app.tunnel import TunnelError, ensure_ngrok_available, ensure_ngrok_configured
165
+
166
+ print("์ „์ œ์กฐ๊ฑด ์ ๊ฒ€:")
167
+ try:
168
+ ensure_ngrok_available()
169
+ ensure_ngrok_configured()
170
+ print(" โœ… ngrok: ์„ค์น˜ ๋ฐ AuthToken ์„ค์ • ์™„๋ฃŒ")
171
+ except TunnelError as exc:
172
+ print(f" โš ๏ธ ngrok: {exc}")
173
+
174
+ available = [tool for tool in _AI_CLI_TOOLS if shutil.which(tool)]
175
+ if available:
176
+ print(f" โœ… AI CLI: {', '.join(available)}")
177
+ else:
178
+ print(
179
+ " โš ๏ธ AI CLI(claude/codex/gemini)๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ตœ์†Œ 1๊ฐœ๋ฅผ ์„ค์น˜ํ•˜์„ธ์š”. "
180
+ "(์˜ˆ: npm install -g @anthropic-ai/claude-code)"
181
+ )
182
+
183
+
184
+ def _prompt(label: str, *, default: str | None = None) -> str:
185
+ suffix = f" [{default}]" if default else ""
186
+ value = input(f"{label}{suffix}: ").strip()
187
+ if not value and default is not None:
188
+ return default
189
+ return value
190
+
191
+
192
+ def _prompt_required(label: str) -> str:
193
+ while True:
194
+ value = input(f"{label}: ").strip()
195
+ if value:
196
+ return value
197
+ print(" ๊ฐ’์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
198
+
199
+
200
+ def _prompt_existing_dir(label: str) -> Path:
201
+ while True:
202
+ raw = _prompt_required(label)
203
+ path = Path(raw).expanduser().resolve()
204
+ if path.is_dir():
205
+ if not (path / ".git").exists():
206
+ print(f" โš ๏ธ {path} ๋Š” Git ์ €์žฅ์†Œ๊ฐ€ ์•„๋‹Œ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ๊ณ„์† ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค.")
207
+ return path
208
+ print(f" ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {path}")
209
+
210
+
211
+ def _prompt_chat_ids(label: str) -> list[int]:
212
+ while True:
213
+ raw = _prompt_required(label)
214
+ try:
215
+ return [int(item.strip()) for item in raw.split(",") if item.strip()]
216
+ except ValueError:
217
+ print(" ์ˆซ์ž Chat ID๋งŒ ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„ํ•ด ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. (์˜ˆ: 123456789,987654321)")
218
+
219
+
220
+ def _prompt_choice(label: str, choices: Sequence[str], *, default: str) -> str:
221
+ options = "/".join(choices)
222
+ while True:
223
+ value = _prompt(f"{label} ({options})", default=default).lower()
224
+ if value in choices:
225
+ return value
226
+ print(f" ๋‹ค์Œ ์ค‘ ํ•˜๋‚˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”: {options}")
227
+
228
+
229
+ def _write_env_file(env_path: Path, values: dict[str, str]) -> None:
230
+ env_path.parent.mkdir(parents=True, exist_ok=True)
231
+ lines = [f"{key}={value}" for key, value in values.items()]
232
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
233
+ # SECURITY: the global .env stores the plaintext bot token; restrict to the owner.
234
+ os.chmod(env_path, 0o600)
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()