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.
Files changed (76) hide show
  1. monoco/cli/__init__.py +0 -0
  2. monoco/cli/project.py +87 -0
  3. monoco/cli/workspace.py +46 -0
  4. monoco/core/agent/__init__.py +5 -0
  5. monoco/core/agent/action.py +144 -0
  6. monoco/core/agent/adapters.py +129 -0
  7. monoco/core/agent/protocol.py +31 -0
  8. monoco/core/agent/state.py +106 -0
  9. monoco/core/config.py +212 -17
  10. monoco/core/execution.py +62 -0
  11. monoco/core/feature.py +58 -0
  12. monoco/core/git.py +51 -2
  13. monoco/core/injection.py +196 -0
  14. monoco/core/integrations.py +242 -0
  15. monoco/core/lsp.py +68 -0
  16. monoco/core/output.py +21 -3
  17. monoco/core/registry.py +36 -0
  18. monoco/core/resources/en/AGENTS.md +8 -0
  19. monoco/core/resources/en/SKILL.md +66 -0
  20. monoco/core/resources/zh/AGENTS.md +8 -0
  21. monoco/core/resources/zh/SKILL.md +65 -0
  22. monoco/core/setup.py +96 -110
  23. monoco/core/skills.py +444 -0
  24. monoco/core/state.py +53 -0
  25. monoco/core/sync.py +224 -0
  26. monoco/core/telemetry.py +4 -1
  27. monoco/core/workspace.py +85 -20
  28. monoco/daemon/app.py +127 -58
  29. monoco/daemon/models.py +4 -0
  30. monoco/daemon/services.py +56 -155
  31. monoco/features/config/commands.py +125 -44
  32. monoco/features/i18n/adapter.py +29 -0
  33. monoco/features/i18n/commands.py +89 -10
  34. monoco/features/i18n/core.py +113 -27
  35. monoco/features/i18n/resources/en/AGENTS.md +8 -0
  36. monoco/features/i18n/resources/en/SKILL.md +94 -0
  37. monoco/features/i18n/resources/zh/AGENTS.md +8 -0
  38. monoco/features/i18n/resources/zh/SKILL.md +94 -0
  39. monoco/features/issue/adapter.py +34 -0
  40. monoco/features/issue/commands.py +343 -101
  41. monoco/features/issue/core.py +384 -150
  42. monoco/features/issue/domain/__init__.py +0 -0
  43. monoco/features/issue/domain/lifecycle.py +126 -0
  44. monoco/features/issue/domain/models.py +170 -0
  45. monoco/features/issue/domain/parser.py +223 -0
  46. monoco/features/issue/domain/workspace.py +104 -0
  47. monoco/features/issue/engine/__init__.py +22 -0
  48. monoco/features/issue/engine/config.py +172 -0
  49. monoco/features/issue/engine/machine.py +185 -0
  50. monoco/features/issue/engine/models.py +18 -0
  51. monoco/features/issue/linter.py +325 -120
  52. monoco/features/issue/lsp/__init__.py +3 -0
  53. monoco/features/issue/lsp/definition.py +72 -0
  54. monoco/features/issue/migration.py +134 -0
  55. monoco/features/issue/models.py +46 -24
  56. monoco/features/issue/monitor.py +94 -0
  57. monoco/features/issue/resources/en/AGENTS.md +20 -0
  58. monoco/features/issue/resources/en/SKILL.md +111 -0
  59. monoco/features/issue/resources/zh/AGENTS.md +20 -0
  60. monoco/features/issue/resources/zh/SKILL.md +138 -0
  61. monoco/features/issue/validator.py +455 -0
  62. monoco/features/spike/adapter.py +30 -0
  63. monoco/features/spike/commands.py +45 -24
  64. monoco/features/spike/core.py +6 -40
  65. monoco/features/spike/resources/en/AGENTS.md +7 -0
  66. monoco/features/spike/resources/en/SKILL.md +74 -0
  67. monoco/features/spike/resources/zh/AGENTS.md +7 -0
  68. monoco/features/spike/resources/zh/SKILL.md +74 -0
  69. monoco/main.py +91 -2
  70. monoco_toolkit-0.2.8.dist-info/METADATA +136 -0
  71. monoco_toolkit-0.2.8.dist-info/RECORD +83 -0
  72. monoco_toolkit-0.1.1.dist-info/METADATA +0 -93
  73. monoco_toolkit-0.1.1.dist-info/RECORD +0 -33
  74. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/WHEEL +0 -0
  75. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/entry_points.txt +0 -0
  76. {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
- class GitMonitor:
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, path: Path, config: MonocoConfig, broadcaster: Broadcaster):
107
- self.path = path
108
- self.config = config
109
- self.id = path.name # Use directory name as ID for now
110
- self.name = config.project.name
111
- self.issues_root = path / config.paths.issues
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 potential Monoco projects.
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
- from monoco.core.workspace import find_projects
91
+ workspace = Workspace.discover(self.workspace_root)
136
92
 
137
- projects = find_projects(self.workspace_root)
138
- for p in projects:
139
- self._register_project(p)
140
-
141
- def _register_project(self, path: Path):
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
- class IssueEventHandler(FileSystemEventHandler):
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
- def _process_delete(self, path_str: str):
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
- Monitor the Issues directory for changes using Watchdog and broadcast update events.
126
+ Holds the runtime state for a single project.
127
+ Now wraps the core MonocoProject primitive.
240
128
  """
241
- def __init__(self, issues_root: Path, broadcaster: Broadcaster, project_id: str):
242
- self.issues_root = issues_root
243
- self.broadcaster = broadcaster
244
- self.project_id = project_id
245
- self.observer = Observer()
246
- self.loop = None
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
- # Ensure directory exists
253
- if not self.issues_root.exists():
254
- logger.warning(f"Issues root {self.issues_root} does not exist. creating...")
255
- self.issues_root.mkdir(parents=True, exist_ok=True)
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
- self.observer.schedule(event_handler, str(self.issues_root), recursive=True)
258
- self.observer.start()
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
- if self.observer.is_alive():
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 show():
14
- """Show current configuration."""
15
- settings = get_config()
16
- print_output(settings, title="Current Configuration")
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
- scope: str = typer.Option("global", "--scope", "-s", help="Configuration scope: global or project")
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
- # This is a simplified implementation of config setting
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
- if scope == "global":
29
- config_path = Path.home() / ".monoco" / "config.yaml"
30
- else:
31
- # Check project root
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 = config_data
45
- for part in parts[:-1]:
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
- # Type conversion
51
- if value.lower() in ("true", "yes", "on"):
52
- val = True
53
- elif value.lower() in ("false", "no", "off"):
54
- val = False
55
- else:
56
- try:
57
- val = int(value)
58
- except ValueError:
59
- val = value
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
- target[parts[-1]] = val
62
-
63
- config_path.parent.mkdir(parents=True, exist_ok=True)
64
- with open(config_path, "w") as f:
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
- console.print(f"[green]✓ Set {key} = {val} in {scope} config.[/green]")
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
+ )
@@ -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 monoco.core.config import get_config
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
- config = get_config()
29
- target_root = Path(root).resolve() if root else Path(config.paths.root)
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
- console.print(f"Scanning i18n coverage in [bold cyan]{target_root}[/bold cyan]...")
33
- console.print(f"Target Languages: [bold yellow]{', '.join(target_langs)}[/bold yellow] (Source: {config.i18n.source_lang})")
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
- missing_langs = core.check_translation_exists(f, target_root, target_langs)
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)