monoco-toolkit 0.1.0__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.
- monoco/core/__init__.py +0 -0
- monoco/core/config.py +113 -0
- monoco/core/git.py +184 -0
- monoco/core/output.py +97 -0
- monoco/core/setup.py +285 -0
- monoco/core/telemetry.py +86 -0
- monoco/core/workspace.py +40 -0
- monoco/daemon/__init__.py +0 -0
- monoco/daemon/app.py +378 -0
- monoco/daemon/commands.py +36 -0
- monoco/daemon/models.py +24 -0
- monoco/daemon/reproduce_stats.py +41 -0
- monoco/daemon/services.py +265 -0
- monoco/daemon/stats.py +124 -0
- monoco/features/__init__.py +0 -0
- monoco/features/config/commands.py +70 -0
- monoco/features/i18n/__init__.py +0 -0
- monoco/features/i18n/commands.py +121 -0
- monoco/features/i18n/core.py +178 -0
- monoco/features/issue/commands.py +710 -0
- monoco/features/issue/core.py +1174 -0
- monoco/features/issue/linter.py +172 -0
- monoco/features/issue/models.py +154 -0
- monoco/features/skills/__init__.py +1 -0
- monoco/features/skills/core.py +96 -0
- monoco/features/spike/commands.py +110 -0
- monoco/features/spike/core.py +154 -0
- monoco/main.py +73 -0
- monoco_toolkit-0.1.0.dist-info/METADATA +86 -0
- monoco_toolkit-0.1.0.dist-info/RECORD +33 -0
- monoco_toolkit-0.1.0.dist-info/WHEEL +4 -0
- monoco_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- monoco_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
monoco/core/__init__.py
ADDED
|
File without changes
|
monoco/core/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
class PathsConfig(BaseModel):
|
|
8
|
+
"""Configuration for directory paths."""
|
|
9
|
+
root: str = Field(default=".", description="Project root directory")
|
|
10
|
+
issues: str = Field(default="Issues", description="Directory for issues")
|
|
11
|
+
spikes: str = Field(default=".references", description="Directory for spikes/research")
|
|
12
|
+
specs: str = Field(default="SPECS", description="Directory for specifications")
|
|
13
|
+
|
|
14
|
+
class CoreConfig(BaseModel):
|
|
15
|
+
"""Core system configuration."""
|
|
16
|
+
editor: str = Field(default_factory=lambda: os.getenv("EDITOR", "vim"), description="Preferred text editor")
|
|
17
|
+
log_level: str = Field(default="INFO", description="Logging verbosity")
|
|
18
|
+
author: Optional[str] = Field(default=None, description="Default author for new artifacts")
|
|
19
|
+
|
|
20
|
+
class ProjectConfig(BaseModel):
|
|
21
|
+
"""Project identity configuration."""
|
|
22
|
+
name: str = Field(default="Monoco Project", description="Project name")
|
|
23
|
+
key: str = Field(default="MON", description="Project key/prefix for IDs")
|
|
24
|
+
spike_repos: Dict[str, str] = Field(default_factory=dict, description="Managed external research repositories (name -> url)")
|
|
25
|
+
members: Dict[str, str] = Field(default_factory=dict, description="Workspace member projects (name -> relative_path)")
|
|
26
|
+
|
|
27
|
+
class I18nConfig(BaseModel):
|
|
28
|
+
"""Configuration for internationalization."""
|
|
29
|
+
source_lang: str = Field(default="en", description="Source language code")
|
|
30
|
+
target_langs: list[str] = Field(default_factory=lambda: ["zh"], description="Target language codes")
|
|
31
|
+
|
|
32
|
+
class UIConfig(BaseModel):
|
|
33
|
+
"""Configuration for UI customizations."""
|
|
34
|
+
dictionary: Dict[str, str] = Field(default_factory=dict, description="Custom domain terminology mapping")
|
|
35
|
+
|
|
36
|
+
class TelemetryConfig(BaseModel):
|
|
37
|
+
"""Configuration for Telemetry."""
|
|
38
|
+
enabled: Optional[bool] = Field(default=None, description="Whether telemetry is enabled")
|
|
39
|
+
|
|
40
|
+
class MonocoConfig(BaseModel):
|
|
41
|
+
"""
|
|
42
|
+
Main Configuration Schema.
|
|
43
|
+
Hierarchy: Defaults < User Config (~/.monoco/config.yaml) < Project Config (./.monoco/config.yaml)
|
|
44
|
+
"""
|
|
45
|
+
core: CoreConfig = Field(default_factory=CoreConfig)
|
|
46
|
+
paths: PathsConfig = Field(default_factory=PathsConfig)
|
|
47
|
+
project: ProjectConfig = Field(default_factory=ProjectConfig)
|
|
48
|
+
i18n: I18nConfig = Field(default_factory=I18nConfig)
|
|
49
|
+
ui: UIConfig = Field(default_factory=UIConfig)
|
|
50
|
+
telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _deep_merge(base: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, Any]:
|
|
54
|
+
"""Recursive dict merge."""
|
|
55
|
+
for k, v in update.items():
|
|
56
|
+
if isinstance(v, dict) and k in base and isinstance(base[k], dict):
|
|
57
|
+
MonocoConfig._deep_merge(base[k], v)
|
|
58
|
+
else:
|
|
59
|
+
base[k] = v
|
|
60
|
+
return base
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def load(cls, project_root: Optional[str] = None) -> "MonocoConfig":
|
|
64
|
+
"""
|
|
65
|
+
Load configuration from multiple sources.
|
|
66
|
+
"""
|
|
67
|
+
# 1. Start with empty dict (will use defaults via Pydantic)
|
|
68
|
+
config_data = {}
|
|
69
|
+
|
|
70
|
+
# 2. Define config paths
|
|
71
|
+
home_path = Path.home() / ".monoco" / "config.yaml"
|
|
72
|
+
|
|
73
|
+
# Determine project path
|
|
74
|
+
cwd = Path(project_root) if project_root else Path.cwd()
|
|
75
|
+
proj_path_hidden = cwd / ".monoco" / "config.yaml"
|
|
76
|
+
proj_path_root = cwd / "monoco.yaml"
|
|
77
|
+
|
|
78
|
+
# 3. Load User Config
|
|
79
|
+
if home_path.exists():
|
|
80
|
+
try:
|
|
81
|
+
with open(home_path, "r") as f:
|
|
82
|
+
user_config = yaml.safe_load(f)
|
|
83
|
+
if user_config:
|
|
84
|
+
cls._deep_merge(config_data, user_config)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
# We don't want to crash on config load fail, implementing simple warning equivalent
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# 4. Load Project Config (prefer .monoco/config.yaml, fallback to monoco.yaml)
|
|
90
|
+
target_proj_conf = proj_path_hidden if proj_path_hidden.exists() else (
|
|
91
|
+
proj_path_root if proj_path_root.exists() else None
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if target_proj_conf:
|
|
95
|
+
try:
|
|
96
|
+
with open(target_proj_conf, "r") as f:
|
|
97
|
+
proj_config = yaml.safe_load(f)
|
|
98
|
+
if proj_config:
|
|
99
|
+
cls._deep_merge(config_data, proj_config)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# 5. Instantiate Model
|
|
104
|
+
return cls(**config_data)
|
|
105
|
+
|
|
106
|
+
# Global singleton
|
|
107
|
+
_settings = None
|
|
108
|
+
|
|
109
|
+
def get_config(project_root: Optional[str] = None) -> MonocoConfig:
|
|
110
|
+
global _settings
|
|
111
|
+
if _settings is None or project_root is not None:
|
|
112
|
+
_settings = MonocoConfig.load(project_root)
|
|
113
|
+
return _settings
|
monoco/core/git.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple, Optional, Dict
|
|
5
|
+
|
|
6
|
+
def _run_git(args: List[str], cwd: Path) -> Tuple[int, str, str]:
|
|
7
|
+
"""Run a raw git command."""
|
|
8
|
+
try:
|
|
9
|
+
result = subprocess.run(
|
|
10
|
+
["git"] + args,
|
|
11
|
+
cwd=cwd,
|
|
12
|
+
capture_output=True,
|
|
13
|
+
text=True,
|
|
14
|
+
check=False
|
|
15
|
+
)
|
|
16
|
+
return result.returncode, result.stdout, result.stderr
|
|
17
|
+
except FileNotFoundError:
|
|
18
|
+
return 1, "", "Git executable not found"
|
|
19
|
+
|
|
20
|
+
def is_git_repo(path: Path) -> bool:
|
|
21
|
+
code, _, _ = _run_git(["rev-parse", "--is-inside-work-tree"], path)
|
|
22
|
+
return code == 0
|
|
23
|
+
|
|
24
|
+
def get_git_status(path: Path, subpath: Optional[str] = None) -> List[str]:
|
|
25
|
+
"""
|
|
26
|
+
Get list of modified files.
|
|
27
|
+
If subpath is provided, only check that path.
|
|
28
|
+
"""
|
|
29
|
+
cmd = ["status", "--porcelain"]
|
|
30
|
+
if subpath:
|
|
31
|
+
cmd.append(subpath)
|
|
32
|
+
|
|
33
|
+
code, stdout, _ = _run_git(cmd, path)
|
|
34
|
+
if code != 0:
|
|
35
|
+
raise RuntimeError("Failed to check git status")
|
|
36
|
+
|
|
37
|
+
lines = []
|
|
38
|
+
for line in stdout.splitlines():
|
|
39
|
+
line = line.strip()
|
|
40
|
+
if not line:
|
|
41
|
+
continue
|
|
42
|
+
# Porcelain format: XY PATH
|
|
43
|
+
if len(line) > 3:
|
|
44
|
+
path_str = line[3:]
|
|
45
|
+
if path_str.startswith('"') and path_str.endswith('"'):
|
|
46
|
+
path_str = path_str[1:-1]
|
|
47
|
+
lines.append(path_str)
|
|
48
|
+
return lines
|
|
49
|
+
|
|
50
|
+
def git_add(path: Path, files: List[str]) -> None:
|
|
51
|
+
if not files:
|
|
52
|
+
return
|
|
53
|
+
code, _, stderr = _run_git(["add"] + files, path)
|
|
54
|
+
if code != 0:
|
|
55
|
+
raise RuntimeError(f"Git add failed: {stderr}")
|
|
56
|
+
|
|
57
|
+
def git_commit(path: Path, message: str) -> str:
|
|
58
|
+
code, stdout, stderr = _run_git(["commit", "-m", message], path)
|
|
59
|
+
if code != 0:
|
|
60
|
+
raise RuntimeError(f"Git commit failed: {stderr}")
|
|
61
|
+
|
|
62
|
+
code, hash_out, _ = _run_git(["rev-parse", "HEAD"], path)
|
|
63
|
+
return hash_out.strip()
|
|
64
|
+
|
|
65
|
+
def search_commits_by_message(path: Path, grep_pattern: str) -> List[Dict[str, str]]:
|
|
66
|
+
cmd = ["log", f"--grep={grep_pattern}", "--name-only", "--format=COMMIT:%H|%s"]
|
|
67
|
+
code, stdout, stderr = _run_git(cmd, path)
|
|
68
|
+
if code != 0:
|
|
69
|
+
raise RuntimeError(f"Git log failed: {stderr}")
|
|
70
|
+
|
|
71
|
+
commits = []
|
|
72
|
+
current_commit = None
|
|
73
|
+
|
|
74
|
+
for line in stdout.splitlines():
|
|
75
|
+
if line.startswith("COMMIT:"):
|
|
76
|
+
if current_commit:
|
|
77
|
+
commits.append(current_commit)
|
|
78
|
+
|
|
79
|
+
parts = line[7:].split("|", 1)
|
|
80
|
+
current_commit = {
|
|
81
|
+
"hash": parts[0],
|
|
82
|
+
"subject": parts[1] if len(parts) > 1 else "",
|
|
83
|
+
"files": []
|
|
84
|
+
}
|
|
85
|
+
elif line.strip():
|
|
86
|
+
if current_commit:
|
|
87
|
+
current_commit["files"].append(line.strip())
|
|
88
|
+
|
|
89
|
+
if current_commit:
|
|
90
|
+
commits.append(current_commit)
|
|
91
|
+
|
|
92
|
+
return commits
|
|
93
|
+
|
|
94
|
+
def get_commit_stats(path: Path, commit_hash: str) -> Dict[str, int]:
|
|
95
|
+
cmd = ["show", "--shortstat", "--format=", commit_hash]
|
|
96
|
+
code, stdout, _ = _run_git(cmd, path)
|
|
97
|
+
stats = {"files": 0, "insertions": 0, "deletions": 0}
|
|
98
|
+
if code == 0 and stdout.strip():
|
|
99
|
+
parts = stdout.strip().split(",")
|
|
100
|
+
for p in parts:
|
|
101
|
+
p = p.strip()
|
|
102
|
+
if "file" in p:
|
|
103
|
+
stats["files"] = int(p.split()[0])
|
|
104
|
+
elif "insertion" in p:
|
|
105
|
+
stats["insertions"] = int(p.split()[0])
|
|
106
|
+
elif "deletion" in p:
|
|
107
|
+
stats["deletions"] = int(p.split()[0])
|
|
108
|
+
return stats
|
|
109
|
+
|
|
110
|
+
# --- Branch & Worktree Extensions ---
|
|
111
|
+
|
|
112
|
+
def get_current_branch(path: Path) -> str:
|
|
113
|
+
code, stdout, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], path)
|
|
114
|
+
if code != 0:
|
|
115
|
+
return ""
|
|
116
|
+
return stdout.strip()
|
|
117
|
+
|
|
118
|
+
def branch_exists(path: Path, branch_name: str) -> bool:
|
|
119
|
+
code, _, _ = _run_git(["rev-parse", "--verify", branch_name], path)
|
|
120
|
+
return code == 0
|
|
121
|
+
|
|
122
|
+
def create_branch(path: Path, branch_name: str, checkout: bool = False):
|
|
123
|
+
cmd = ["checkout", "-b", branch_name] if checkout else ["branch", branch_name]
|
|
124
|
+
code, _, stderr = _run_git(cmd, path)
|
|
125
|
+
if code != 0:
|
|
126
|
+
raise RuntimeError(f"Failed to create branch {branch_name}: {stderr}")
|
|
127
|
+
|
|
128
|
+
def checkout_branch(path: Path, branch_name: str):
|
|
129
|
+
code, _, stderr = _run_git(["checkout", branch_name], path)
|
|
130
|
+
if code != 0:
|
|
131
|
+
raise RuntimeError(f"Failed to checkout {branch_name}: {stderr}")
|
|
132
|
+
|
|
133
|
+
def delete_branch(path: Path, branch_name: str, force: bool = False):
|
|
134
|
+
flag = "-D" if force else "-d"
|
|
135
|
+
code, _, stderr = _run_git(["branch", flag, branch_name], path)
|
|
136
|
+
if code != 0:
|
|
137
|
+
raise RuntimeError(f"Failed to delete branch {branch_name}: {stderr}")
|
|
138
|
+
|
|
139
|
+
def get_worktrees(path: Path) -> List[Tuple[str, str, str]]:
|
|
140
|
+
"""Returns list of (path, head, branch)"""
|
|
141
|
+
code, stdout, stderr = _run_git(["worktree", "list", "--porcelain"], path)
|
|
142
|
+
if code != 0:
|
|
143
|
+
raise RuntimeError(f"Failed to list worktrees: {stderr}")
|
|
144
|
+
|
|
145
|
+
trees = []
|
|
146
|
+
current = {}
|
|
147
|
+
for line in stdout.splitlines():
|
|
148
|
+
if line.startswith("worktree "):
|
|
149
|
+
if current:
|
|
150
|
+
trees.append((current.get("worktree"), current.get("HEAD"), current.get("branch")))
|
|
151
|
+
current = {"worktree": line[9:].strip()}
|
|
152
|
+
elif line.startswith("HEAD "):
|
|
153
|
+
current["HEAD"] = line[5:].strip()
|
|
154
|
+
elif line.startswith("branch "):
|
|
155
|
+
current["branch"] = line[7:].strip()
|
|
156
|
+
|
|
157
|
+
if current:
|
|
158
|
+
trees.append((current.get("worktree"), current.get("HEAD"), current.get("branch")))
|
|
159
|
+
return trees
|
|
160
|
+
|
|
161
|
+
def worktree_add(path: Path, branch_name: str, worktree_path: Path):
|
|
162
|
+
# If branch doesn't exist, -b will create it.
|
|
163
|
+
# Logic: git worktree add [-b <new_branch>] <path> <commit-ish>
|
|
164
|
+
|
|
165
|
+
# We assume if branch_exists, use it. If not, create it.
|
|
166
|
+
cmd = ["worktree", "add"]
|
|
167
|
+
if not branch_exists(path, branch_name):
|
|
168
|
+
cmd.extend(["-b", branch_name])
|
|
169
|
+
|
|
170
|
+
cmd.extend([str(worktree_path), branch_name])
|
|
171
|
+
|
|
172
|
+
code, _, stderr = _run_git(cmd, path)
|
|
173
|
+
if code != 0:
|
|
174
|
+
raise RuntimeError(f"Failed to create worktree: {stderr}")
|
|
175
|
+
|
|
176
|
+
def worktree_remove(path: Path, worktree_path: Path, force: bool = False):
|
|
177
|
+
cmd = ["worktree", "remove"]
|
|
178
|
+
if force:
|
|
179
|
+
cmd.append("--force")
|
|
180
|
+
cmd.append(str(worktree_path))
|
|
181
|
+
|
|
182
|
+
code, _, stderr = _run_git(cmd, path)
|
|
183
|
+
if code != 0:
|
|
184
|
+
raise RuntimeError(f"Failed to remove worktree: {stderr}")
|
monoco/core/output.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import typer
|
|
4
|
+
from typing import Any, List, Union, Annotated
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import print as rprint
|
|
9
|
+
|
|
10
|
+
def _set_agent_mode(value: bool):
|
|
11
|
+
if value:
|
|
12
|
+
os.environ["AGENT_FLAG"] = "true"
|
|
13
|
+
|
|
14
|
+
# Reusable dependency for commands
|
|
15
|
+
AgentOutput = Annotated[bool, typer.Option("--json", help="Output in compact JSON for Agents", callback=_set_agent_mode)]
|
|
16
|
+
|
|
17
|
+
class OutputManager:
|
|
18
|
+
"""
|
|
19
|
+
Manages output rendering based on the environment (Human vs Agent).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def is_agent_mode() -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Check if running in Agent Mode.
|
|
26
|
+
Triggers:
|
|
27
|
+
1. Environment variable AGENT_FLAG=true (or 1)
|
|
28
|
+
2. Environment variable MONOCO_AGENT=true (or 1)
|
|
29
|
+
"""
|
|
30
|
+
return os.getenv("AGENT_FLAG", "").lower() in ("true", "1") or \
|
|
31
|
+
os.getenv("MONOCO_AGENT", "").lower() in ("true", "1")
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def print(data: Union[BaseModel, List[BaseModel], dict, list, str], title: str = ""):
|
|
35
|
+
"""
|
|
36
|
+
Dual frontend dispatcher.
|
|
37
|
+
"""
|
|
38
|
+
if OutputManager.is_agent_mode():
|
|
39
|
+
OutputManager._render_agent(data)
|
|
40
|
+
else:
|
|
41
|
+
OutputManager._render_human(data, title)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _render_agent(data: Any):
|
|
45
|
+
"""
|
|
46
|
+
Agent channel: Zero decoration, Pure Data, Max Token Density.
|
|
47
|
+
Uses compact JSON.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(data, BaseModel):
|
|
50
|
+
print(data.model_dump_json(exclude_none=True))
|
|
51
|
+
elif isinstance(data, list) and all(isinstance(item, BaseModel) for item in data):
|
|
52
|
+
# Pydantic v2 adapter for list of models
|
|
53
|
+
print(json.dumps([item.model_dump(mode='json', exclude_none=True) for item in data], separators=(',', ':')))
|
|
54
|
+
else:
|
|
55
|
+
# Fallback for dicts/lists/primitives
|
|
56
|
+
try:
|
|
57
|
+
print(json.dumps(data, separators=(',', ':'), default=str))
|
|
58
|
+
except TypeError:
|
|
59
|
+
print(str(data))
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _render_human(data: Any, title: str):
|
|
63
|
+
"""
|
|
64
|
+
Human channel: Visual priority.
|
|
65
|
+
"""
|
|
66
|
+
console = Console()
|
|
67
|
+
|
|
68
|
+
if title:
|
|
69
|
+
console.rule(f"[bold blue]{title}[/bold blue]")
|
|
70
|
+
|
|
71
|
+
if isinstance(data, str):
|
|
72
|
+
console.print(data)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Special handling for Lists of Pydantic Models -> Table
|
|
76
|
+
if isinstance(data, list) and data and isinstance(data[0], BaseModel):
|
|
77
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
78
|
+
|
|
79
|
+
# Introspect fields from the first item
|
|
80
|
+
model_type = type(data[0])
|
|
81
|
+
fields = model_type.model_fields.keys()
|
|
82
|
+
|
|
83
|
+
for field in fields:
|
|
84
|
+
table.add_column(field.replace("_", " ").title())
|
|
85
|
+
|
|
86
|
+
for item in data:
|
|
87
|
+
row = [str(getattr(item, field)) for field in fields]
|
|
88
|
+
table.add_row(*row)
|
|
89
|
+
|
|
90
|
+
console.print(table)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Fallback to rich pretty print
|
|
94
|
+
rprint(data)
|
|
95
|
+
|
|
96
|
+
# Global helper
|
|
97
|
+
print_output = OutputManager.print
|
monoco/core/setup.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from monoco.core.output import print_output
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
def get_git_user() -> str:
|
|
12
|
+
try:
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
["git", "config", "user.name"],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
timeout=1
|
|
18
|
+
)
|
|
19
|
+
return result.stdout.strip()
|
|
20
|
+
except Exception:
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
def generate_key(name: str) -> str:
|
|
24
|
+
"""Generate a 3-4 letter uppercase key from name."""
|
|
25
|
+
# Strategy 1: Upper case of first letters of words
|
|
26
|
+
parts = name.split()
|
|
27
|
+
if len(parts) >= 2:
|
|
28
|
+
candidate = "".join(p[0] for p in parts[:4]).upper()
|
|
29
|
+
if len(candidate) >= 2:
|
|
30
|
+
return candidate
|
|
31
|
+
|
|
32
|
+
# Strategy 2: First 3 letters
|
|
33
|
+
return name[:3].upper()
|
|
34
|
+
|
|
35
|
+
from prompt_toolkit.application import Application
|
|
36
|
+
from prompt_toolkit.layout.containers import Window, HSplit
|
|
37
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
38
|
+
from prompt_toolkit.layout.layout import Layout
|
|
39
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
40
|
+
from prompt_toolkit.styles import Style
|
|
41
|
+
import sys
|
|
42
|
+
|
|
43
|
+
def ask_with_selection(message: str, default: str) -> str:
|
|
44
|
+
"""Provides a selection-based prompt for stable rendering."""
|
|
45
|
+
options = [f"{default} (Default)", "Custom Input..."]
|
|
46
|
+
selected_index = 0
|
|
47
|
+
|
|
48
|
+
kb = KeyBindings()
|
|
49
|
+
|
|
50
|
+
@kb.add('up')
|
|
51
|
+
@kb.add('k')
|
|
52
|
+
def _(event):
|
|
53
|
+
nonlocal selected_index
|
|
54
|
+
selected_index = (selected_index - 1) % len(options)
|
|
55
|
+
|
|
56
|
+
@kb.add('down')
|
|
57
|
+
@kb.add('j')
|
|
58
|
+
def _(event):
|
|
59
|
+
nonlocal selected_index
|
|
60
|
+
selected_index = (selected_index + 1) % len(options)
|
|
61
|
+
|
|
62
|
+
@kb.add('enter')
|
|
63
|
+
def _(event):
|
|
64
|
+
event.app.exit(result=selected_index)
|
|
65
|
+
|
|
66
|
+
@kb.add('c-c')
|
|
67
|
+
def _(event):
|
|
68
|
+
console.print("\n[red]Aborted by user.[/red]")
|
|
69
|
+
sys.exit(0)
|
|
70
|
+
|
|
71
|
+
def get_text():
|
|
72
|
+
# Render the menu with explicit highlighting
|
|
73
|
+
res = [('class:message', f"{message}:\n")]
|
|
74
|
+
for i, opt in enumerate(options):
|
|
75
|
+
if i == selected_index:
|
|
76
|
+
res.append(('class:selected', f" ➔ {opt}\n"))
|
|
77
|
+
else:
|
|
78
|
+
res.append(('class:unselected', f" {opt}\n"))
|
|
79
|
+
return res
|
|
80
|
+
|
|
81
|
+
style = Style.from_dict({
|
|
82
|
+
'message': 'bold #ffffff',
|
|
83
|
+
'selected': 'bold #00ff00', # High contrast green
|
|
84
|
+
'unselected': '#888888',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
# Run a mini application to handle the selection
|
|
88
|
+
app = Application(
|
|
89
|
+
layout=Layout(HSplit([Window(content=FormattedTextControl(get_text), height=len(options)+1)])),
|
|
90
|
+
key_bindings=kb,
|
|
91
|
+
style=style,
|
|
92
|
+
full_screen=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Flush stdout to ensure previous output is visible
|
|
96
|
+
sys.stdout.flush()
|
|
97
|
+
|
|
98
|
+
choice = app.run()
|
|
99
|
+
|
|
100
|
+
if choice == 0:
|
|
101
|
+
return default
|
|
102
|
+
else:
|
|
103
|
+
# Prompt for custom input
|
|
104
|
+
from prompt_toolkit import prompt
|
|
105
|
+
return prompt(f"Enter custom {message.lower()}: ").strip() or default
|
|
106
|
+
|
|
107
|
+
def init_cli(
|
|
108
|
+
ctx: typer.Context,
|
|
109
|
+
global_only: bool = typer.Option(False, "--global", help="Only configure global user settings"),
|
|
110
|
+
project_only: bool = typer.Option(False, "--project", help="Only configure current project")
|
|
111
|
+
):
|
|
112
|
+
"""
|
|
113
|
+
Initialize Monoco configuration (Global and/or Project).
|
|
114
|
+
"""
|
|
115
|
+
from rich.prompt import Confirm
|
|
116
|
+
|
|
117
|
+
home_dir = Path.home() / ".monoco"
|
|
118
|
+
global_config_path = home_dir / "config.yaml"
|
|
119
|
+
|
|
120
|
+
# --- 1. Global Configuration ---
|
|
121
|
+
if not project_only:
|
|
122
|
+
if not global_config_path.exists() or global_only:
|
|
123
|
+
console.rule("[bold blue]Global Setup[/bold blue]")
|
|
124
|
+
|
|
125
|
+
# Ensure ~/.monoco exists
|
|
126
|
+
home_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
default_author = get_git_user() or os.getenv("USER", "developer")
|
|
129
|
+
author = ask_with_selection("Your Name (for issue tracking)", default_author)
|
|
130
|
+
|
|
131
|
+
telemetry_enabled = Confirm.ask("Enable anonymous telemetry to help improve Monoco?", default=True)
|
|
132
|
+
|
|
133
|
+
user_config = {
|
|
134
|
+
"core": {
|
|
135
|
+
"author": author,
|
|
136
|
+
# Editor is handled by env/config defaults, no need to prompt
|
|
137
|
+
},
|
|
138
|
+
"telemetry": {
|
|
139
|
+
"enabled": telemetry_enabled
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
with open(global_config_path, "w") as f:
|
|
144
|
+
yaml.dump(user_config, f, default_flow_style=False)
|
|
145
|
+
|
|
146
|
+
console.print(f"[green]✓ Global config saved to {global_config_path}[/green]\n")
|
|
147
|
+
|
|
148
|
+
if global_only:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
# --- 2. Project Configuration ---
|
|
152
|
+
cwd = Path.cwd()
|
|
153
|
+
project_config_dir = cwd / ".monoco"
|
|
154
|
+
project_config_path = project_config_dir / "config.yaml"
|
|
155
|
+
|
|
156
|
+
# Check if we should init project
|
|
157
|
+
if project_config_path.exists():
|
|
158
|
+
if not Confirm.ask(f"Project config already exists at [dim]{project_config_path}[/dim]. Overwrite?"):
|
|
159
|
+
console.print("[yellow]Skipping project initialization.[/yellow]")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
console.rule("[bold blue]Project Setup[/bold blue]")
|
|
163
|
+
|
|
164
|
+
default_name = cwd.name
|
|
165
|
+
project_name = ask_with_selection("Project Name", default_name)
|
|
166
|
+
|
|
167
|
+
default_key = generate_key(project_name)
|
|
168
|
+
project_key = ask_with_selection("Project Key (prefix for issues)", default_key)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
project_config_dir.mkdir(exist_ok=True)
|
|
172
|
+
|
|
173
|
+
project_config = {
|
|
174
|
+
"project": {
|
|
175
|
+
"name": project_name,
|
|
176
|
+
"key": project_key
|
|
177
|
+
},
|
|
178
|
+
"paths": {
|
|
179
|
+
"issues": "Issues",
|
|
180
|
+
"spikes": ".references",
|
|
181
|
+
"specs": "SPECS"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
with open(project_config_path, "w") as f:
|
|
186
|
+
yaml.dump(project_config, f, default_flow_style=False)
|
|
187
|
+
|
|
188
|
+
# 2b. Generate Config Template
|
|
189
|
+
template_path = project_config_dir / "config_template.yaml"
|
|
190
|
+
template_content = """# Monoco Configuration Template
|
|
191
|
+
# This file serves as a reference for all available configuration options.
|
|
192
|
+
# Rename this file to config.yaml to use it.
|
|
193
|
+
|
|
194
|
+
core:
|
|
195
|
+
# Default author for new artifacts (e.g. issues)
|
|
196
|
+
# author: "Developer Name"
|
|
197
|
+
|
|
198
|
+
# Logging verbosity (DEBUG, INFO, WARNING, ERROR)
|
|
199
|
+
# log_level: "INFO"
|
|
200
|
+
|
|
201
|
+
# Preferred text editor
|
|
202
|
+
# editor: "vim"
|
|
203
|
+
|
|
204
|
+
project:
|
|
205
|
+
# The display name of the project
|
|
206
|
+
name: "My Project"
|
|
207
|
+
|
|
208
|
+
# The prefix used for issue IDs (e.g. MON-001)
|
|
209
|
+
key: "MON"
|
|
210
|
+
|
|
211
|
+
# Managed external research repositories (name -> url)
|
|
212
|
+
# spike_repos:
|
|
213
|
+
# react: "https://github.com/facebook/react"
|
|
214
|
+
|
|
215
|
+
paths:
|
|
216
|
+
# Directory for tracking issues
|
|
217
|
+
issues: "Issues"
|
|
218
|
+
|
|
219
|
+
# Directory for specifications/documents
|
|
220
|
+
specs: "SPECS"
|
|
221
|
+
|
|
222
|
+
# Directory for research references (spikes)
|
|
223
|
+
spikes: ".references"
|
|
224
|
+
|
|
225
|
+
i18n:
|
|
226
|
+
# Source language code
|
|
227
|
+
source_lang: "en"
|
|
228
|
+
|
|
229
|
+
# Target language codes for translation
|
|
230
|
+
target_langs:
|
|
231
|
+
- "zh"
|
|
232
|
+
|
|
233
|
+
ui:
|
|
234
|
+
# Custom Domain Terminology Mapping
|
|
235
|
+
# Use this to rename core concepts in the UI without changing internal logic.
|
|
236
|
+
dictionary:
|
|
237
|
+
# Entities
|
|
238
|
+
epic: "Saga"
|
|
239
|
+
feature: "Story"
|
|
240
|
+
chore: "Task"
|
|
241
|
+
fix: "Bug"
|
|
242
|
+
|
|
243
|
+
# Statuses
|
|
244
|
+
todo: "Pending"
|
|
245
|
+
doing: "In Progress"
|
|
246
|
+
review: "QA"
|
|
247
|
+
done: "Released"
|
|
248
|
+
"""
|
|
249
|
+
with open(template_path, "w") as f:
|
|
250
|
+
f.write(template_content)
|
|
251
|
+
|
|
252
|
+
# 3. Scaffold Directories & Modules
|
|
253
|
+
|
|
254
|
+
# Import Feature Cores locally to avoid circular deps if any (though setup is core)
|
|
255
|
+
from monoco.features.issue import core as issue_core
|
|
256
|
+
from monoco.features.spike import core as spike_core
|
|
257
|
+
from monoco.features.i18n import core as i18n_core
|
|
258
|
+
from monoco.features import skills
|
|
259
|
+
|
|
260
|
+
# Initialize Issues
|
|
261
|
+
issues_path = cwd / project_config["paths"]["issues"]
|
|
262
|
+
issue_core.init(issues_path)
|
|
263
|
+
|
|
264
|
+
# Initialize Spikes
|
|
265
|
+
spikes_name = project_config["paths"]["spikes"]
|
|
266
|
+
spike_core.init(cwd, spikes_name)
|
|
267
|
+
|
|
268
|
+
# Initialize I18n
|
|
269
|
+
i18n_core.init(cwd)
|
|
270
|
+
|
|
271
|
+
# Initialize Skills & Agent Docs
|
|
272
|
+
resources = [
|
|
273
|
+
issue_core.get_resources(),
|
|
274
|
+
spike_core.get_resources(),
|
|
275
|
+
i18n_core.get_resources()
|
|
276
|
+
]
|
|
277
|
+
skills.init(cwd, resources)
|
|
278
|
+
|
|
279
|
+
console.print(f"[green]✓ Project config initialized at {project_config_path}[/green]")
|
|
280
|
+
console.print(f"[green]✓ Config template generated at {template_path}[/green]")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
console.print(f"[green]Access configured! issues will be created as {project_key}-XXX[/green]")
|
|
285
|
+
|