monoco-toolkit 0.1.1__py3-none-any.whl → 0.2.8__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/cli/__init__.py +0 -0
- monoco/cli/project.py +87 -0
- monoco/cli/workspace.py +46 -0
- monoco/core/agent/__init__.py +5 -0
- monoco/core/agent/action.py +144 -0
- monoco/core/agent/adapters.py +129 -0
- monoco/core/agent/protocol.py +31 -0
- monoco/core/agent/state.py +106 -0
- monoco/core/config.py +212 -17
- monoco/core/execution.py +62 -0
- monoco/core/feature.py +58 -0
- monoco/core/git.py +51 -2
- monoco/core/injection.py +196 -0
- monoco/core/integrations.py +242 -0
- monoco/core/lsp.py +68 -0
- monoco/core/output.py +21 -3
- monoco/core/registry.py +36 -0
- monoco/core/resources/en/AGENTS.md +8 -0
- monoco/core/resources/en/SKILL.md +66 -0
- monoco/core/resources/zh/AGENTS.md +8 -0
- monoco/core/resources/zh/SKILL.md +65 -0
- monoco/core/setup.py +96 -110
- monoco/core/skills.py +444 -0
- monoco/core/state.py +53 -0
- monoco/core/sync.py +224 -0
- monoco/core/telemetry.py +4 -1
- monoco/core/workspace.py +85 -20
- monoco/daemon/app.py +127 -58
- monoco/daemon/models.py +4 -0
- monoco/daemon/services.py +56 -155
- monoco/features/config/commands.py +125 -44
- monoco/features/i18n/adapter.py +29 -0
- monoco/features/i18n/commands.py +89 -10
- monoco/features/i18n/core.py +113 -27
- monoco/features/i18n/resources/en/AGENTS.md +8 -0
- monoco/features/i18n/resources/en/SKILL.md +94 -0
- monoco/features/i18n/resources/zh/AGENTS.md +8 -0
- monoco/features/i18n/resources/zh/SKILL.md +94 -0
- monoco/features/issue/adapter.py +34 -0
- monoco/features/issue/commands.py +343 -101
- monoco/features/issue/core.py +384 -150
- monoco/features/issue/domain/__init__.py +0 -0
- monoco/features/issue/domain/lifecycle.py +126 -0
- monoco/features/issue/domain/models.py +170 -0
- monoco/features/issue/domain/parser.py +223 -0
- monoco/features/issue/domain/workspace.py +104 -0
- monoco/features/issue/engine/__init__.py +22 -0
- monoco/features/issue/engine/config.py +172 -0
- monoco/features/issue/engine/machine.py +185 -0
- monoco/features/issue/engine/models.py +18 -0
- monoco/features/issue/linter.py +325 -120
- monoco/features/issue/lsp/__init__.py +3 -0
- monoco/features/issue/lsp/definition.py +72 -0
- monoco/features/issue/migration.py +134 -0
- monoco/features/issue/models.py +46 -24
- monoco/features/issue/monitor.py +94 -0
- monoco/features/issue/resources/en/AGENTS.md +20 -0
- monoco/features/issue/resources/en/SKILL.md +111 -0
- monoco/features/issue/resources/zh/AGENTS.md +20 -0
- monoco/features/issue/resources/zh/SKILL.md +138 -0
- monoco/features/issue/validator.py +455 -0
- monoco/features/spike/adapter.py +30 -0
- monoco/features/spike/commands.py +45 -24
- monoco/features/spike/core.py +6 -40
- monoco/features/spike/resources/en/AGENTS.md +7 -0
- monoco/features/spike/resources/en/SKILL.md +74 -0
- monoco/features/spike/resources/zh/AGENTS.md +7 -0
- monoco/features/spike/resources/zh/SKILL.md +74 -0
- monoco/main.py +91 -2
- monoco_toolkit-0.2.8.dist-info/METADATA +136 -0
- monoco_toolkit-0.2.8.dist-info/RECORD +83 -0
- monoco_toolkit-0.1.1.dist-info/METADATA +0 -93
- monoco_toolkit-0.1.1.dist-info/RECORD +0 -33
- {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/licenses/LICENSE +0 -0
monoco/daemon/services.py
CHANGED
|
@@ -46,69 +46,25 @@ class Broadcaster:
|
|
|
46
46
|
logger.debug(f"Broadcasted {event_type} to {len(self.subscribers)} clients.")
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
"""
|
|
51
|
-
Polls the Git repository for HEAD changes and triggers updates.
|
|
52
|
-
"""
|
|
53
|
-
def __init__(self, broadcaster: Broadcaster, poll_interval: float = 2.0):
|
|
54
|
-
self.broadcaster = broadcaster
|
|
55
|
-
self.poll_interval = poll_interval
|
|
56
|
-
self.last_head_hash: Optional[str] = None
|
|
57
|
-
self.is_running = False
|
|
58
|
-
|
|
59
|
-
async def get_head_hash(self) -> Optional[str]:
|
|
60
|
-
try:
|
|
61
|
-
# Run git rev-parse HEAD asynchronously
|
|
62
|
-
process = await asyncio.create_subprocess_exec(
|
|
63
|
-
"git", "rev-parse", "HEAD",
|
|
64
|
-
stdout=asyncio.subprocess.PIPE,
|
|
65
|
-
stderr=asyncio.subprocess.PIPE
|
|
66
|
-
)
|
|
67
|
-
stdout, _ = await process.communicate()
|
|
68
|
-
if process.returncode == 0:
|
|
69
|
-
return stdout.decode().strip()
|
|
70
|
-
return None
|
|
71
|
-
except Exception as e:
|
|
72
|
-
logger.error(f"Git polling error: {e}")
|
|
73
|
-
return None
|
|
74
|
-
|
|
75
|
-
async def start(self):
|
|
76
|
-
self.is_running = True
|
|
77
|
-
logger.info("Git Monitor started.")
|
|
78
|
-
|
|
79
|
-
# Initial check
|
|
80
|
-
self.last_head_hash = await self.get_head_hash()
|
|
81
|
-
|
|
82
|
-
while self.is_running:
|
|
83
|
-
await asyncio.sleep(self.poll_interval)
|
|
84
|
-
current_hash = await self.get_head_hash()
|
|
85
|
-
|
|
86
|
-
if current_hash and current_hash != self.last_head_hash:
|
|
87
|
-
logger.info(f"Git HEAD changed: {self.last_head_hash} -> {current_hash}")
|
|
88
|
-
self.last_head_hash = current_hash
|
|
89
|
-
await self.broadcaster.broadcast("HEAD_UPDATED", {
|
|
90
|
-
"ref": "HEAD",
|
|
91
|
-
"hash": current_hash
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
def stop(self):
|
|
95
|
-
self.is_running = False
|
|
96
|
-
logger.info("Git Monitor stopping...")
|
|
49
|
+
# Monitors moved to monoco.core.git and monoco.features.issue.monitor
|
|
97
50
|
|
|
98
51
|
from watchdog.observers import Observer
|
|
99
52
|
from watchdog.events import FileSystemEventHandler
|
|
100
53
|
from monoco.core.config import MonocoConfig, get_config
|
|
101
54
|
|
|
55
|
+
from monoco.core.workspace import MonocoProject, Workspace
|
|
56
|
+
|
|
102
57
|
class ProjectContext:
|
|
103
58
|
"""
|
|
104
59
|
Holds the runtime state for a single project.
|
|
60
|
+
Now wraps the core MonocoProject primitive.
|
|
105
61
|
"""
|
|
106
|
-
def __init__(self,
|
|
107
|
-
self.
|
|
108
|
-
self.
|
|
109
|
-
self.
|
|
110
|
-
self.
|
|
111
|
-
self.issues_root =
|
|
62
|
+
def __init__(self, project: MonocoProject, broadcaster: Broadcaster):
|
|
63
|
+
self.project = project
|
|
64
|
+
self.id = project.id
|
|
65
|
+
self.name = project.name
|
|
66
|
+
self.path = project.path
|
|
67
|
+
self.issues_root = project.issues_root
|
|
112
68
|
self.monitor = IssueMonitor(self.issues_root, broadcaster, project_id=self.id)
|
|
113
69
|
|
|
114
70
|
async def start(self):
|
|
@@ -120,6 +76,7 @@ class ProjectContext:
|
|
|
120
76
|
class ProjectManager:
|
|
121
77
|
"""
|
|
122
78
|
Discovers and manages multiple Monoco projects within a workspace.
|
|
79
|
+
Uses core Workspace primitive for discovery.
|
|
123
80
|
"""
|
|
124
81
|
def __init__(self, workspace_root: Path, broadcaster: Broadcaster):
|
|
125
82
|
self.workspace_root = workspace_root
|
|
@@ -128,28 +85,16 @@ class ProjectManager:
|
|
|
128
85
|
|
|
129
86
|
def scan(self):
|
|
130
87
|
"""
|
|
131
|
-
Scans workspace for
|
|
132
|
-
A directory is a project if it has monoco.yaml or .monoco/config.yaml or an Issues directory.
|
|
88
|
+
Scans workspace for Monoco projects using core logic.
|
|
133
89
|
"""
|
|
134
90
|
logger.info(f"Scanning workspace: {self.workspace_root}")
|
|
135
|
-
|
|
91
|
+
workspace = Workspace.discover(self.workspace_root)
|
|
136
92
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
try:
|
|
143
|
-
config = get_config(str(path))
|
|
144
|
-
# If name is default, try to use directory name
|
|
145
|
-
if config.project.name == "Monoco Project":
|
|
146
|
-
config.project.name = path.name
|
|
147
|
-
|
|
148
|
-
ctx = ProjectContext(path, config, self.broadcaster)
|
|
149
|
-
self.projects[ctx.id] = ctx
|
|
150
|
-
logger.info(f"Registered project: {ctx.id} ({ctx.path})")
|
|
151
|
-
except Exception as e:
|
|
152
|
-
logger.error(f"Failed to register project at {path}: {e}")
|
|
93
|
+
for project in workspace.projects:
|
|
94
|
+
if project.id not in self.projects:
|
|
95
|
+
ctx = ProjectContext(project, self.broadcaster)
|
|
96
|
+
self.projects[ctx.id] = ctx
|
|
97
|
+
logger.info(f"Registered project: {ctx.id} ({ctx.path})")
|
|
153
98
|
|
|
154
99
|
async def start_all(self):
|
|
155
100
|
self.scan()
|
|
@@ -174,92 +119,48 @@ class ProjectManager:
|
|
|
174
119
|
for p in self.projects.values()
|
|
175
120
|
]
|
|
176
121
|
|
|
177
|
-
|
|
178
|
-
def __init__(self, loop, broadcaster: Broadcaster, project_id: str):
|
|
179
|
-
self.loop = loop
|
|
180
|
-
self.broadcaster = broadcaster
|
|
181
|
-
self.project_id = project_id
|
|
182
|
-
|
|
183
|
-
def _process_upsert(self, path_str: str):
|
|
184
|
-
if not path_str.endswith(".md"):
|
|
185
|
-
return
|
|
186
|
-
asyncio.run_coroutine_threadsafe(self._handle_upsert(path_str), self.loop)
|
|
187
|
-
|
|
188
|
-
async def _handle_upsert(self, path_str: str):
|
|
189
|
-
try:
|
|
190
|
-
path = Path(path_str)
|
|
191
|
-
if not path.exists():
|
|
192
|
-
return
|
|
193
|
-
issue = parse_issue(path)
|
|
194
|
-
if issue:
|
|
195
|
-
await self.broadcaster.broadcast("issue_upserted", {
|
|
196
|
-
"issue": issue.model_dump(mode='json'),
|
|
197
|
-
"project_id": self.project_id
|
|
198
|
-
})
|
|
199
|
-
except Exception as e:
|
|
200
|
-
logger.error(f"Error handling upsert for {path_str}: {e}")
|
|
122
|
+
from monoco.features.issue.monitor import IssueMonitor
|
|
201
123
|
|
|
202
|
-
|
|
203
|
-
if not path_str.endswith(".md"):
|
|
204
|
-
return
|
|
205
|
-
asyncio.run_coroutine_threadsafe(self._handle_delete(path_str), self.loop)
|
|
206
|
-
|
|
207
|
-
async def _handle_delete(self, path_str: str):
|
|
208
|
-
try:
|
|
209
|
-
filename = Path(path_str).name
|
|
210
|
-
match = re.match(r"([A-Z]+-\d{4})", filename)
|
|
211
|
-
if match:
|
|
212
|
-
issue_id = match.group(1)
|
|
213
|
-
await self.broadcaster.broadcast("issue_deleted", {
|
|
214
|
-
"id": issue_id,
|
|
215
|
-
"project_id": self.project_id
|
|
216
|
-
})
|
|
217
|
-
except Exception as e:
|
|
218
|
-
logger.error(f"Error handling delete for {path_str}: {e}")
|
|
219
|
-
|
|
220
|
-
def on_created(self, event):
|
|
221
|
-
if not event.is_directory:
|
|
222
|
-
self._process_upsert(event.src_path)
|
|
223
|
-
|
|
224
|
-
def on_modified(self, event):
|
|
225
|
-
if not event.is_directory:
|
|
226
|
-
self._process_upsert(event.src_path)
|
|
227
|
-
|
|
228
|
-
def on_deleted(self, event):
|
|
229
|
-
if not event.is_directory:
|
|
230
|
-
self._process_delete(event.src_path)
|
|
231
|
-
|
|
232
|
-
def on_moved(self, event):
|
|
233
|
-
if not event.is_directory:
|
|
234
|
-
self._process_delete(event.src_path)
|
|
235
|
-
self._process_upsert(event.dest_path)
|
|
236
|
-
|
|
237
|
-
class IssueMonitor:
|
|
124
|
+
class ProjectContext:
|
|
238
125
|
"""
|
|
239
|
-
|
|
126
|
+
Holds the runtime state for a single project.
|
|
127
|
+
Now wraps the core MonocoProject primitive.
|
|
240
128
|
"""
|
|
241
|
-
def __init__(self,
|
|
242
|
-
self.
|
|
243
|
-
self.
|
|
244
|
-
self.
|
|
245
|
-
self.
|
|
246
|
-
self.
|
|
247
|
-
|
|
248
|
-
async def start(self):
|
|
249
|
-
self.loop = asyncio.get_running_loop()
|
|
250
|
-
event_handler = IssueEventHandler(self.loop, self.broadcaster, self.project_id)
|
|
129
|
+
def __init__(self, project: MonocoProject, broadcaster: Broadcaster):
|
|
130
|
+
self.project = project
|
|
131
|
+
self.id = project.id
|
|
132
|
+
self.name = project.name
|
|
133
|
+
self.path = project.path
|
|
134
|
+
self.issues_root = project.issues_root
|
|
251
135
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
136
|
+
async def on_upsert(issue_data: dict):
|
|
137
|
+
await broadcaster.broadcast("issue_upserted", {
|
|
138
|
+
"issue": issue_data,
|
|
139
|
+
"project_id": self.id
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
async def on_delete(issue_data: dict):
|
|
143
|
+
# We skip broadcast here if it's part of a move?
|
|
144
|
+
# Actually, standard upsert/delete is fine, but we need a specialized event for MOVE
|
|
145
|
+
# to help VS Code redirect without closing/reopening.
|
|
146
|
+
await broadcaster.broadcast("issue_deleted", {
|
|
147
|
+
"id": issue_data["id"],
|
|
148
|
+
"project_id": self.id
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
self.monitor = IssueMonitor(self.issues_root, on_upsert, on_delete)
|
|
152
|
+
|
|
153
|
+
async def notify_move(self, old_path: str, new_path: str, issue_data: dict):
|
|
154
|
+
"""Explicitly notify frontend about a logical move (Physical path changed)."""
|
|
155
|
+
await self.broadcaster.broadcast("issue_moved", {
|
|
156
|
+
"old_path": old_path,
|
|
157
|
+
"new_path": new_path,
|
|
158
|
+
"issue": issue_data,
|
|
159
|
+
"project_id": self.id
|
|
160
|
+
})
|
|
256
161
|
|
|
257
|
-
|
|
258
|
-
self.
|
|
259
|
-
logger.info(f"Issue Monitor started (Watchdog). Watching {self.issues_root}")
|
|
162
|
+
async def start(self):
|
|
163
|
+
await self.monitor.start()
|
|
260
164
|
|
|
261
165
|
def stop(self):
|
|
262
|
-
|
|
263
|
-
self.observer.stop()
|
|
264
|
-
self.observer.join()
|
|
265
|
-
logger.info("Issue Monitor stopped.")
|
|
166
|
+
self.monitor.stop()
|
|
@@ -1,70 +1,151 @@
|
|
|
1
1
|
import typer
|
|
2
2
|
import yaml
|
|
3
|
+
import json
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from typing import Optional
|
|
5
|
-
from monoco.core.config import get_config, MonocoConfig
|
|
6
|
-
from monoco.core.output import print_output
|
|
5
|
+
from typing import Optional, Any, Annotated
|
|
7
6
|
from rich.console import Console
|
|
7
|
+
from rich.syntax import Syntax
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from monoco.core.config import (
|
|
11
|
+
get_config,
|
|
12
|
+
MonocoConfig,
|
|
13
|
+
ConfigScope,
|
|
14
|
+
load_raw_config,
|
|
15
|
+
save_raw_config,
|
|
16
|
+
get_config_path
|
|
17
|
+
)
|
|
18
|
+
from monoco.core.output import AgentOutput, OutputManager
|
|
8
19
|
|
|
9
20
|
app = typer.Typer(help="Manage Monoco configuration")
|
|
10
21
|
console = Console()
|
|
11
22
|
|
|
23
|
+
def _parse_value(value: str) -> Any:
|
|
24
|
+
"""Parse string value into appropriate type (bool, int, float, str)."""
|
|
25
|
+
if value.lower() in ("true", "yes", "on"):
|
|
26
|
+
return True
|
|
27
|
+
if value.lower() in ("false", "no", "off"):
|
|
28
|
+
return False
|
|
29
|
+
if value.lower() == "null":
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
return int(value)
|
|
33
|
+
except ValueError:
|
|
34
|
+
try:
|
|
35
|
+
return float(value)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def show(
|
|
41
|
+
output: str = typer.Option("yaml", "--output", "-o", help="Output format: yaml or json"),
|
|
42
|
+
json_output: AgentOutput = False,
|
|
43
|
+
):
|
|
44
|
+
"""Show the currently active (merged) configuration."""
|
|
45
|
+
config = get_config()
|
|
46
|
+
# Pydantic v1/v2 compat: use dict() or model_dump()
|
|
47
|
+
data = config.dict()
|
|
48
|
+
|
|
49
|
+
if OutputManager.is_agent_mode():
|
|
50
|
+
OutputManager.print(data)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if output == "json":
|
|
54
|
+
print(json.dumps(data, indent=2))
|
|
55
|
+
else:
|
|
56
|
+
yaml_str = yaml.dump(data, default_flow_style=False)
|
|
57
|
+
syntax = Syntax(yaml_str, "yaml")
|
|
58
|
+
console.print(syntax)
|
|
59
|
+
|
|
12
60
|
@app.command()
|
|
13
|
-
def
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
61
|
+
def get(
|
|
62
|
+
key: str = typer.Argument(..., help="Configuration key (e.g. project.name)"),
|
|
63
|
+
json_output: AgentOutput = False,
|
|
64
|
+
):
|
|
65
|
+
"""Get a specific configuration value."""
|
|
66
|
+
config = get_config()
|
|
67
|
+
data = config.dict()
|
|
68
|
+
|
|
69
|
+
parts = key.split(".")
|
|
70
|
+
current = data
|
|
71
|
+
|
|
72
|
+
for part in parts:
|
|
73
|
+
if isinstance(current, dict) and part in current:
|
|
74
|
+
current = current[part]
|
|
75
|
+
else:
|
|
76
|
+
OutputManager.error(f"Key '{key}' not found.")
|
|
77
|
+
raise typer.Exit(code=1)
|
|
78
|
+
|
|
79
|
+
if OutputManager.is_agent_mode():
|
|
80
|
+
OutputManager.print({"key": key, "value": current})
|
|
81
|
+
else:
|
|
82
|
+
if isinstance(current, (dict, list)):
|
|
83
|
+
if isinstance(current, dict):
|
|
84
|
+
print(yaml.dump(current, default_flow_style=False))
|
|
85
|
+
else:
|
|
86
|
+
print(json.dumps(current))
|
|
87
|
+
else:
|
|
88
|
+
print(current)
|
|
17
89
|
|
|
18
90
|
@app.command(name="set")
|
|
19
91
|
def set_val(
|
|
20
92
|
key: str = typer.Argument(..., help="Config key (e.g. telemetry.enabled)"),
|
|
21
93
|
value: str = typer.Argument(..., help="Value to set"),
|
|
22
|
-
|
|
94
|
+
global_scope: bool = typer.Option(False, "--global", "-g", help="Update global configuration"),
|
|
95
|
+
json_output: AgentOutput = False,
|
|
23
96
|
):
|
|
24
|
-
"""Set a configuration value."""
|
|
25
|
-
|
|
26
|
-
# In a real system, we'd want to validate the key against the schema
|
|
97
|
+
"""Set a configuration value in specific scope (project by default)."""
|
|
98
|
+
scope = ConfigScope.GLOBAL if global_scope else ConfigScope.PROJECT
|
|
27
99
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
cwd = Path.cwd()
|
|
33
|
-
config_path = cwd / ".monoco" / "config.yaml"
|
|
34
|
-
if not (cwd / ".monoco").exists():
|
|
35
|
-
config_path = cwd / "monoco.yaml"
|
|
36
|
-
|
|
37
|
-
config_data = {}
|
|
38
|
-
if config_path.exists():
|
|
39
|
-
with open(config_path, "r") as f:
|
|
40
|
-
config_data = yaml.safe_load(f) or {}
|
|
41
|
-
|
|
42
|
-
# Simple nested key support (e.g. telemetry.enabled)
|
|
100
|
+
# 1. Load Raw Config for the target scope
|
|
101
|
+
raw_data = load_raw_config(scope)
|
|
102
|
+
|
|
103
|
+
# 2. Parse Key & Update Data
|
|
43
104
|
parts = key.split(".")
|
|
44
|
-
target =
|
|
45
|
-
|
|
105
|
+
target = raw_data
|
|
106
|
+
|
|
107
|
+
# Context management for nested updates
|
|
108
|
+
for i, part in enumerate(parts[:-1]):
|
|
46
109
|
if part not in target:
|
|
47
110
|
target[part] = {}
|
|
48
111
|
target = target[part]
|
|
112
|
+
if not isinstance(target, dict):
|
|
113
|
+
parent_key = ".".join(parts[:i+1])
|
|
114
|
+
OutputManager.error(f"Cannot set '{key}': '{parent_key}' is not a dictionary ({type(target)}).")
|
|
115
|
+
raise typer.Exit(code=1)
|
|
116
|
+
|
|
117
|
+
parsed_val = _parse_value(value)
|
|
118
|
+
target[parts[-1]] = parsed_val
|
|
49
119
|
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
120
|
+
# 3. Validate against Schema
|
|
121
|
+
# We simulate a full load by creating a temporary MonocoConfig with these overrides.
|
|
122
|
+
# Note: This validation is "active" - we want to ensure the resulting config WOULD be valid.
|
|
123
|
+
# However, raw_data is partial. Pydantic models with defaults will accept partials.
|
|
124
|
+
try:
|
|
125
|
+
# We can try to validate just the relevant model part if we knew which one it was.
|
|
126
|
+
# But simpler is to check if MonocoConfig accepts this structure.
|
|
127
|
+
MonocoConfig(**raw_data)
|
|
128
|
+
except ValidationError as e:
|
|
129
|
+
OutputManager.error(f"Validation failed for key '{key}':\n{e}")
|
|
130
|
+
raise typer.Exit(code=1)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
OutputManager.error(f"Unexpected validation error: {e}")
|
|
133
|
+
raise typer.Exit(code=1)
|
|
60
134
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
yaml.dump(config_data, f, default_flow_style=False)
|
|
135
|
+
# 4. Save
|
|
136
|
+
save_raw_config(scope, raw_data)
|
|
137
|
+
|
|
138
|
+
scope_display = "Global" if global_scope else "Project"
|
|
66
139
|
|
|
67
|
-
|
|
140
|
+
if OutputManager.is_agent_mode():
|
|
141
|
+
OutputManager.print({
|
|
142
|
+
"status": "updated",
|
|
143
|
+
"scope": scope_display.lower(),
|
|
144
|
+
"key": key,
|
|
145
|
+
"value": parsed_val
|
|
146
|
+
})
|
|
147
|
+
else:
|
|
148
|
+
console.print(f"[green]✓ Set {key} = {parsed_val} in {scope_display} config.[/green]")
|
|
68
149
|
|
|
69
150
|
if __name__ == "__main__":
|
|
70
151
|
app()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict
|
|
3
|
+
from monoco.core.feature import MonocoFeature, IntegrationData
|
|
4
|
+
from monoco.features.i18n import core
|
|
5
|
+
|
|
6
|
+
class I18nFeature(MonocoFeature):
|
|
7
|
+
@property
|
|
8
|
+
def name(self) -> str:
|
|
9
|
+
return "i18n"
|
|
10
|
+
|
|
11
|
+
def initialize(self, root: Path, config: Dict) -> None:
|
|
12
|
+
core.init(root)
|
|
13
|
+
|
|
14
|
+
def integrate(self, root: Path, config: Dict) -> IntegrationData:
|
|
15
|
+
# Determine language from config, default to 'en'
|
|
16
|
+
lang = config.get("i18n", {}).get("source_lang", "en")
|
|
17
|
+
base_dir = Path(__file__).parent / "resources"
|
|
18
|
+
|
|
19
|
+
prompt_file = base_dir / lang / "AGENTS.md"
|
|
20
|
+
if not prompt_file.exists():
|
|
21
|
+
prompt_file = base_dir / "en" / "AGENTS.md"
|
|
22
|
+
|
|
23
|
+
content = ""
|
|
24
|
+
if prompt_file.exists():
|
|
25
|
+
content = prompt_file.read_text(encoding="utf-8").strip()
|
|
26
|
+
|
|
27
|
+
return IntegrationData(
|
|
28
|
+
system_prompts={"Documentation I18n": content}
|
|
29
|
+
)
|
monoco/features/i18n/commands.py
CHANGED
|
@@ -4,7 +4,9 @@ from rich.console import Console
|
|
|
4
4
|
from rich.table import Table
|
|
5
5
|
from rich.panel import Panel
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from typing import Optional, Annotated
|
|
8
|
+
from monoco.core.config import get_config, find_monoco_root
|
|
9
|
+
from monoco.core.output import AgentOutput, OutputManager
|
|
8
10
|
from . import core
|
|
9
11
|
|
|
10
12
|
app = typer.Typer(help="Management tools for Documentation Internationalization (i18n).")
|
|
@@ -14,6 +16,9 @@ console = Console()
|
|
|
14
16
|
def scan(
|
|
15
17
|
root: str = typer.Option(None, "--root", help="Target root directory to scan. Defaults to the project root."),
|
|
16
18
|
limit: int = typer.Option(10, "--limit", help="Maximum number of missing files to display. Use 0 for unlimited."),
|
|
19
|
+
check_issues: bool = typer.Option(False, "--check-issues", help="Include Issues directory in the scan."),
|
|
20
|
+
check_source_lang: bool = typer.Option(False, "--check-source-lang", help="Verify if source files content matches source language (heuristic)."),
|
|
21
|
+
json: AgentOutput = False,
|
|
17
22
|
):
|
|
18
23
|
"""
|
|
19
24
|
Scan the project for internationalization (i18n) status.
|
|
@@ -25,36 +30,92 @@ def scan(
|
|
|
25
30
|
|
|
26
31
|
Returns a report of files missing translations in the checking target languages.
|
|
27
32
|
"""
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
if root:
|
|
34
|
+
target_root = Path(root).resolve()
|
|
35
|
+
else:
|
|
36
|
+
target_root = find_monoco_root(Path.cwd())
|
|
37
|
+
|
|
38
|
+
# Load config with correct root
|
|
39
|
+
config = get_config(project_root=str(target_root))
|
|
30
40
|
target_langs = config.i18n.target_langs
|
|
41
|
+
source_lang = config.i18n.source_lang
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
if not OutputManager.is_agent_mode():
|
|
44
|
+
console.print(f"Scanning i18n coverage in [bold cyan]{target_root}[/bold cyan]...")
|
|
45
|
+
console.print(f"Target Languages: [bold yellow]{', '.join(target_langs)}[/bold yellow] (Source: {source_lang})")
|
|
34
46
|
|
|
35
|
-
all_files = core.discover_markdown_files(target_root)
|
|
47
|
+
all_files = core.discover_markdown_files(target_root, include_issues=check_issues)
|
|
36
48
|
|
|
37
49
|
source_files = [f for f in all_files if not core.is_translation_file(f, target_langs)]
|
|
38
50
|
|
|
39
51
|
# Store missing results: { file_path: [missing_langs] }
|
|
40
52
|
missing_map = {}
|
|
53
|
+
# Store lang mismatch results: [file_path]
|
|
54
|
+
lang_mismatch_files = []
|
|
55
|
+
|
|
41
56
|
total_checks = len(source_files) * len(target_langs)
|
|
42
57
|
found_count = 0
|
|
43
58
|
|
|
44
59
|
for f in source_files:
|
|
45
|
-
|
|
60
|
+
# Check translation existence
|
|
61
|
+
missing_langs = core.check_translation_exists(f, target_root, target_langs, source_lang)
|
|
46
62
|
if missing_langs:
|
|
47
63
|
missing_map[f] = missing_langs
|
|
48
64
|
found_count += (len(target_langs) - len(missing_langs))
|
|
49
65
|
else:
|
|
50
66
|
found_count += len(target_langs)
|
|
51
67
|
|
|
68
|
+
# Check source content language if enabled
|
|
69
|
+
if check_source_lang:
|
|
70
|
+
if not core.is_content_source_language(f, source_lang):
|
|
71
|
+
# Try to detect actual language for better error message
|
|
72
|
+
try:
|
|
73
|
+
content = f.read_text(encoding="utf-8")
|
|
74
|
+
detected = core.detect_language(content)
|
|
75
|
+
except:
|
|
76
|
+
detected = "unknown"
|
|
77
|
+
lang_mismatch_files.append((f, detected))
|
|
78
|
+
|
|
52
79
|
# Reporting
|
|
53
80
|
coverage = (found_count / total_checks * 100) if total_checks > 0 else 100
|
|
54
81
|
|
|
55
82
|
# Sort missing_map by file path for stable output
|
|
56
83
|
sorted_missing = sorted(missing_map.items(), key=lambda x: str(x[0]))
|
|
57
|
-
|
|
84
|
+
|
|
85
|
+
if OutputManager.is_agent_mode():
|
|
86
|
+
# JSON Output
|
|
87
|
+
report = {
|
|
88
|
+
"root": str(target_root),
|
|
89
|
+
"source_lang": source_lang,
|
|
90
|
+
"target_langs": target_langs,
|
|
91
|
+
"stats": {
|
|
92
|
+
"total_source_files": len(source_files),
|
|
93
|
+
"total_checks": total_checks,
|
|
94
|
+
"found_translations": found_count,
|
|
95
|
+
"coverage_percent": round(coverage, 2),
|
|
96
|
+
"missing_files_count": len(sorted_missing),
|
|
97
|
+
"mismatch_files_count": len(lang_mismatch_files)
|
|
98
|
+
},
|
|
99
|
+
"missing_files": [
|
|
100
|
+
{
|
|
101
|
+
"file": str(f.relative_to(target_root)),
|
|
102
|
+
"missing_langs": langs,
|
|
103
|
+
"expected_paths": [
|
|
104
|
+
str(core.get_target_translation_path(f, target_root, l, source_lang).relative_to(target_root))
|
|
105
|
+
for l in langs
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
for f, langs in sorted_missing
|
|
109
|
+
],
|
|
110
|
+
"language_mismatches": [
|
|
111
|
+
{"file": str(f.relative_to(target_root)), "detected": detected}
|
|
112
|
+
for f, detected in lang_mismatch_files
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
OutputManager.print(report)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Human Output
|
|
58
119
|
# Apply limit
|
|
59
120
|
total_missing_files = len(sorted_missing)
|
|
60
121
|
display_limit = limit if limit > 0 else total_missing_files
|
|
@@ -77,7 +138,7 @@ def scan(
|
|
|
77
138
|
rel_path = f.relative_to(target_root)
|
|
78
139
|
expected_paths = []
|
|
79
140
|
for lang in langs:
|
|
80
|
-
target = core.get_target_translation_path(f, target_root, lang)
|
|
141
|
+
target = core.get_target_translation_path(f, target_root, lang, source_lang)
|
|
81
142
|
expected_paths.append(str(target.relative_to(target_root)))
|
|
82
143
|
|
|
83
144
|
table.add_row(
|
|
@@ -88,6 +149,21 @@ def scan(
|
|
|
88
149
|
|
|
89
150
|
console.print(table)
|
|
90
151
|
|
|
152
|
+
# Show Language Mismatch Warnings
|
|
153
|
+
if lang_mismatch_files:
|
|
154
|
+
console.print("\n")
|
|
155
|
+
mismatch_table = Table(title=f"Source Language Mismatch (Expected: {source_lang})", box=None)
|
|
156
|
+
mismatch_table.add_column("File", style="yellow")
|
|
157
|
+
mismatch_table.add_column("Detected", style="red")
|
|
158
|
+
|
|
159
|
+
limit_mismatch = 10
|
|
160
|
+
for f, detected in lang_mismatch_files[:limit_mismatch]:
|
|
161
|
+
mismatch_table.add_row(str(f.relative_to(target_root)), detected)
|
|
162
|
+
|
|
163
|
+
console.print(mismatch_table)
|
|
164
|
+
if len(lang_mismatch_files) > limit_mismatch:
|
|
165
|
+
console.print(f"[dim]... and {len(lang_mismatch_files) - limit_mismatch} more.[/dim]")
|
|
166
|
+
|
|
91
167
|
# Show hint if output was truncated
|
|
92
168
|
if display_limit < total_missing_files:
|
|
93
169
|
console.print(f"\n[dim]💡 Tip: Use [bold]--limit 0[/bold] to show all {total_missing_files} missing files.[/dim]\n")
|
|
@@ -111,11 +187,14 @@ def scan(
|
|
|
111
187
|
if total_missing_files > 0:
|
|
112
188
|
summary_lines.append(f" - Partial Missing: {partial_missing}")
|
|
113
189
|
summary_lines.append(f" - Complete Missing: {complete_missing}")
|
|
190
|
+
|
|
191
|
+
if lang_mismatch_files:
|
|
192
|
+
summary_lines.append(f"Language Mismatches: {len(lang_mismatch_files)}")
|
|
114
193
|
|
|
115
194
|
summary_lines.append(f"Coverage: [{status_color}]{coverage:.1f}%[/{status_color}]")
|
|
116
195
|
|
|
117
196
|
summary = "\n".join(summary_lines)
|
|
118
197
|
console.print(Panel(summary, title="I18N STATUS", expand=False))
|
|
119
198
|
|
|
120
|
-
if missing_map:
|
|
199
|
+
if missing_map or lang_mismatch_files:
|
|
121
200
|
raise typer.Exit(code=1)
|