monoco-toolkit 0.1.0__py3-none-any.whl → 0.2.5__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 (69) 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 +106 -0
  7. monoco/core/agent/protocol.py +31 -0
  8. monoco/core/agent/state.py +106 -0
  9. monoco/core/config.py +152 -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 +234 -0
  15. monoco/core/lsp.py +61 -0
  16. monoco/core/output.py +13 -2
  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 +66 -0
  22. monoco/core/setup.py +88 -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/agent/commands.py +166 -0
  32. monoco/features/agent/doctor.py +30 -0
  33. monoco/features/config/commands.py +125 -44
  34. monoco/features/i18n/adapter.py +29 -0
  35. monoco/features/i18n/commands.py +89 -10
  36. monoco/features/i18n/core.py +113 -27
  37. monoco/features/i18n/resources/en/AGENTS.md +8 -0
  38. monoco/features/i18n/resources/en/SKILL.md +94 -0
  39. monoco/features/i18n/resources/zh/AGENTS.md +8 -0
  40. monoco/features/i18n/resources/zh/SKILL.md +94 -0
  41. monoco/features/issue/adapter.py +34 -0
  42. monoco/features/issue/commands.py +183 -65
  43. monoco/features/issue/core.py +172 -77
  44. monoco/features/issue/linter.py +215 -116
  45. monoco/features/issue/migration.py +134 -0
  46. monoco/features/issue/models.py +23 -19
  47. monoco/features/issue/monitor.py +94 -0
  48. monoco/features/issue/resources/en/AGENTS.md +15 -0
  49. monoco/features/issue/resources/en/SKILL.md +87 -0
  50. monoco/features/issue/resources/zh/AGENTS.md +15 -0
  51. monoco/features/issue/resources/zh/SKILL.md +114 -0
  52. monoco/features/issue/validator.py +269 -0
  53. monoco/features/pty/core.py +185 -0
  54. monoco/features/pty/router.py +138 -0
  55. monoco/features/pty/server.py +56 -0
  56. monoco/features/spike/adapter.py +30 -0
  57. monoco/features/spike/commands.py +45 -24
  58. monoco/features/spike/core.py +4 -21
  59. monoco/features/spike/resources/en/AGENTS.md +7 -0
  60. monoco/features/spike/resources/en/SKILL.md +74 -0
  61. monoco/features/spike/resources/zh/AGENTS.md +7 -0
  62. monoco/features/spike/resources/zh/SKILL.md +74 -0
  63. monoco/main.py +115 -2
  64. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.dist-info}/METADATA +10 -3
  65. monoco_toolkit-0.2.5.dist-info/RECORD +77 -0
  66. monoco_toolkit-0.1.0.dist-info/RECORD +0 -33
  67. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.dist-info}/WHEEL +0 -0
  68. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.dist-info}/entry_points.txt +0 -0
  69. {monoco_toolkit-0.1.0.dist-info → monoco_toolkit-0.2.5.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()
@@ -0,0 +1,166 @@
1
+
2
+ import typer
3
+ from typing import Optional, Annotated
4
+ from pathlib import Path
5
+ from monoco.core.output import print_output, print_error, AgentOutput, OutputManager
6
+ from monoco.core.agent.adapters import get_agent_client
7
+ from monoco.core.agent.state import AgentStateManager
8
+ from monoco.core.agent.action import ActionRegistry, ActionContext
9
+ from monoco.core.config import get_config
10
+ import asyncio
11
+ import re
12
+ import json as j
13
+
14
+ app = typer.Typer()
15
+
16
+ @app.command(name="run")
17
+ def run_command(
18
+ prompt_or_task: str = typer.Argument(..., help="Prompt string OR execution task name (e.g. 'refine-issue')"),
19
+ target: Optional[str] = typer.Argument(None, help="Target file argument for the task"),
20
+ provider: Optional[str] = typer.Option(None, "--using", "-u", help="Override agent provider"),
21
+ instruction: Optional[str] = typer.Option(None, "--instruction", "-i", help="Additional instruction for the agent"),
22
+ json: AgentOutput = False,
23
+ ):
24
+ """
25
+ Execute a prompt or a named task using an Agent CLI.
26
+ """
27
+ # 0. Setup
28
+ settings = get_config()
29
+ state_manager = AgentStateManager()
30
+ registry = ActionRegistry(Path(settings.paths.root))
31
+
32
+ # 1. Check if it's a named task
33
+ action = registry.get(prompt_or_task)
34
+
35
+ final_prompt = prompt_or_task
36
+ context_files = []
37
+
38
+ # Determine Provider Priority: CLI > Action Def > Config > Default
39
+ prov_name = provider
40
+
41
+ if action:
42
+ # It IS an action
43
+ if not OutputManager.is_agent_mode():
44
+ print(f"Running action: {action.name}")
45
+
46
+ # Simple template substitution
47
+ final_prompt = action.template
48
+
49
+ if "{{file}}" in final_prompt:
50
+ if not target:
51
+ print_error("This task requires a target file argument.")
52
+ raise typer.Exit(1)
53
+
54
+ target_path = Path(target).resolve()
55
+ if not target_path.exists():
56
+ print_error(f"Target file not found: {target}")
57
+ raise typer.Exit(1)
58
+
59
+ final_prompt = final_prompt.replace("{{file}}", target_path.read_text())
60
+ # Also add to context files? Ideally the prompt has it.
61
+ # Let's add it to context files list to be safe if prompt didn't embed it fully
62
+ context_files.append(target_path)
63
+
64
+ if not prov_name:
65
+ prov_name = action.provider
66
+
67
+ # 2. Append Instruction if provided
68
+ if instruction:
69
+ final_prompt = f"{final_prompt}\n\n[USER INSTRUCTION]\n{instruction}"
70
+
71
+ # 2. Provider Resolution Fallback
72
+ prov_name = prov_name or settings.agent.framework or "gemini"
73
+
74
+ # 3. State Check
75
+ state = state_manager.load()
76
+ if not state or state.is_stale:
77
+ if not OutputManager.is_agent_mode():
78
+ print("Agent state stale or missing, refreshing...")
79
+ state = state_manager.refresh()
80
+
81
+ if prov_name not in state.providers:
82
+ print_error(f"Provider '{prov_name}' unknown.")
83
+ raise typer.Exit(1)
84
+
85
+ if not state.providers[prov_name].available:
86
+ print_error(f"Provider '{prov_name}' is not available. Run 'monoco doctor' to diagnose.")
87
+ raise typer.Exit(1)
88
+
89
+ # 4. Execute
90
+ try:
91
+ client = get_agent_client(prov_name)
92
+ result = asyncio.run(client.execute(final_prompt, context_files=context_files))
93
+
94
+ if OutputManager.is_agent_mode():
95
+ OutputManager.print({"result": result, "provider": prov_name})
96
+ else:
97
+ print(result)
98
+
99
+ except Exception as e:
100
+ print_error(f"Execution failed: {e}")
101
+ raise typer.Exit(1)
102
+
103
+ @app.command()
104
+ def list(
105
+ json: AgentOutput = False,
106
+ context: Optional[str] = typer.Option(None, "--context", help="Context for filtering (JSON string)")
107
+ ):
108
+ """List available actions."""
109
+ settings = get_config()
110
+ registry = ActionRegistry(Path(settings.paths.root))
111
+
112
+ action_context = None
113
+ if context:
114
+ try:
115
+ ctx_data = j.loads(context)
116
+ action_context = ActionContext(**ctx_data)
117
+ except Exception as e:
118
+ print_error(f"Invalid context JSON: {e}")
119
+
120
+ actions = registry.list_available(action_context)
121
+ # OutputManager handles list of Pydantic models automatically for both JSON and Table
122
+ print_output(actions, title="Available Actions")
123
+
124
+ @app.command()
125
+ def status(
126
+ json: AgentOutput = False,
127
+ force: bool = typer.Option(False, "--force", "-f", help="Force refresh of agent state")
128
+ ):
129
+ """View status of Agent Providers."""
130
+ state_manager = AgentStateManager()
131
+ state = state_manager.get_or_refresh(force=force)
132
+
133
+ if OutputManager.is_agent_mode():
134
+ # Convert datetime to ISO string for JSON serialization
135
+ data = state.dict()
136
+ data["last_checked"] = data["last_checked"].isoformat()
137
+ OutputManager.print(data)
138
+ else:
139
+ # Standard output using existing print_output or custom formatting
140
+ from monoco.core.output import Table
141
+ from rich import print as rprint
142
+
143
+ table = Table(title=f"Agent Status (Last Checked: {state.last_checked.strftime('%Y-%m-%d %H:%M:%S')})")
144
+ table.add_column("Provider")
145
+ table.add_column("Available")
146
+ table.add_column("Path")
147
+ table.add_column("Error")
148
+
149
+ for name, p_state in state.providers.items():
150
+ table.add_row(
151
+ name,
152
+ "✅" if p_state.available else "❌",
153
+ p_state.path or "-",
154
+ p_state.error or "-"
155
+ )
156
+ rprint(table)
157
+
158
+ @app.command()
159
+ def doctor(
160
+ force: bool = typer.Option(False, "--force", "-f", help="Force refresh of agent state")
161
+ ):
162
+ """
163
+ Diagnose Agent Environment and refresh state.
164
+ """
165
+ from monoco.features.agent.doctor import doctor as doc_impl
166
+ doc_impl(force)
@@ -0,0 +1,30 @@
1
+ import typer
2
+ from monoco.core.output import print_output, print_error
3
+ from monoco.core.agent.state import AgentStateManager
4
+
5
+ app = typer.Typer()
6
+
7
+ @app.command()
8
+ def doctor(
9
+ force: bool = typer.Option(False, "--force", "-f", help="Force refresh of agent state")
10
+ ):
11
+ """
12
+ Diagnose Agent Environment and refresh state.
13
+ """
14
+ manager = AgentStateManager()
15
+ try:
16
+ if force:
17
+ print("Force refreshing agent state...")
18
+ state = manager.refresh()
19
+ else:
20
+ state = manager.get_or_refresh()
21
+
22
+ print_output(state, title="Agent Diagnosis Report")
23
+
24
+ # Simple summary
25
+ available = [k for k, v in state.providers.items() if v.available]
26
+ print(f"\n✅ Available Agents: {', '.join(available) if available else 'None'}")
27
+
28
+ except Exception as e:
29
+ print_error(f"Doctor failed: {e}")
30
+ raise typer.Exit(1)
@@ -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
+ )