monoco-toolkit 0.2.2__py3-none-any.whl → 0.2.4__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 ADDED
File without changes
monoco/cli/project.py ADDED
@@ -0,0 +1,79 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ import yaml
7
+ import json
8
+ import os
9
+
10
+ from monoco.core.workspace import find_projects, is_project_root
11
+ from monoco.core.config import get_config
12
+
13
+ app = typer.Typer(help="Manage Monoco Projects")
14
+ console = Console()
15
+
16
+ @app.command("list")
17
+ def list_projects(
18
+ json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
19
+ root: Optional[str] = typer.Option(None, "--root", help="Workspace root")
20
+ ):
21
+ """List all discovered projects in the workspace."""
22
+ cwd = Path(root).resolve() if root else Path.cwd()
23
+ projects = find_projects(cwd)
24
+
25
+ if json_output:
26
+ data = [
27
+ {
28
+ "id": p.id,
29
+ "name": p.name,
30
+ "path": str(p.path),
31
+ "key": p.config.project.key if p.config.project else ""
32
+ }
33
+ for p in projects
34
+ ]
35
+ print(json.dumps(data))
36
+ else:
37
+ table = Table(title=f"Projects in {cwd}")
38
+ table.add_column("ID", style="cyan")
39
+ table.add_column("Name", style="magenta")
40
+ table.add_column("Key", style="green")
41
+ table.add_column("Path", style="dim")
42
+
43
+ for p in projects:
44
+ path_str = str(p.path.relative_to(cwd)) if p.path.is_relative_to(cwd) else str(p.path)
45
+ if path_str == ".":
46
+ path_str = "(root)"
47
+ key = p.config.project.key if p.config.project else "N/A"
48
+ table.add_row(p.id, p.name, key, path_str)
49
+
50
+ console.print(table)
51
+
52
+ @app.command("init")
53
+ def init_project(
54
+ name: str = typer.Option(..., "--name", "-n", help="Project Name"),
55
+ key: str = typer.Option(..., "--key", "-k", help="Project Key"),
56
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config")
57
+ ):
58
+ """Initialize a new project in the current directory."""
59
+ cwd = Path.cwd()
60
+ project_config_path = cwd / ".monoco" / "project.yaml"
61
+
62
+ if project_config_path.exists() and not force:
63
+ console.print(f"[yellow]Project already initialized in {cwd}. Use --force to overwrite.[/yellow]")
64
+ raise typer.Exit(code=1)
65
+
66
+ cwd.mkdir(parents=True, exist_ok=True)
67
+ (cwd / ".monoco").mkdir(exist_ok=True)
68
+
69
+ config = {
70
+ "project": {
71
+ "name": name,
72
+ "key": key
73
+ }
74
+ }
75
+
76
+ with open(project_config_path, "w") as f:
77
+ yaml.dump(config, f, default_flow_style=False)
78
+
79
+ console.print(f"[green]Initialized project '{name}' ({key}) in {cwd}[/green]")
@@ -0,0 +1,38 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from rich.console import Console
4
+ import yaml
5
+ import json
6
+
7
+ app = typer.Typer(help="Manage Monoco Workspace")
8
+ console = Console()
9
+
10
+ @app.command("init")
11
+ def init_workspace(
12
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config")
13
+ ):
14
+ """Initialize a workspace environment in the current directory."""
15
+ cwd = Path.cwd()
16
+ workspace_config_path = cwd / ".monoco" / "workspace.yaml"
17
+
18
+ if workspace_config_path.exists() and not force:
19
+ console.print(f"[yellow]Workspace already initialized in {cwd}. Use --force to overwrite.[/yellow]")
20
+ raise typer.Exit(code=1)
21
+
22
+ cwd.mkdir(parents=True, exist_ok=True)
23
+ (cwd / ".monoco").mkdir(exist_ok=True)
24
+
25
+ # Default workspace config
26
+ config = {
27
+ "paths": {
28
+ "issues": "Issues", # Default
29
+ "spikes": ".references",
30
+ "specs": "SPECS"
31
+ }
32
+ }
33
+
34
+ with open(workspace_config_path, "w") as f:
35
+ yaml.dump(config, f, default_flow_style=False)
36
+
37
+ console.print(f"[green]Initialized workspace in {cwd}[/green]")
38
+
@@ -62,56 +62,45 @@ class AgentStateManager:
62
62
  return self.refresh()
63
63
 
64
64
  def refresh(self) -> AgentState:
65
- """Run the diagnostic script and update state file."""
65
+ """Run diagnostics on all integrations and update state."""
66
66
  logger.info("Refreshing agent state...")
67
67
 
68
- # Locate the shell script
69
- # Assuming monoco is installed as a package, we need to find where the script lives.
70
- # For dev environment: Toolkit/scripts/check_agents.sh
71
- # For production: It might need to be packaged or generated.
68
+ from monoco.core.integrations import get_all_integrations
69
+ from monoco.core.config import get_config
72
70
 
73
- # Current strategy: Look in known relative locations
74
- script_path = self._find_script()
75
- if not script_path:
76
- raise FileNotFoundError("Could not find check_agents.sh script")
77
-
78
- try:
79
- # Ensure the directory exists
80
- self.state_path.parent.mkdir(parents=True, exist_ok=True)
71
+ # Load config to get possible overrides
72
+ # Determine root (hacky for now, should be passed)
73
+ root = Path.cwd()
74
+ config = get_config(str(root))
75
+
76
+ integrations = get_all_integrations(config_overrides=config.agent.integrations, enabled_only=True)
77
+
78
+ providers = {}
79
+ for key, integration in integrations.items():
80
+ if not integration.bin_name:
81
+ continue # Skip integrations that don't have a binary component
81
82
 
82
- subprocess.run(
83
- [str(script_path), str(self.state_path)],
84
- check=True,
85
- capture_output=True,
86
- text=True
83
+ health = integration.check_health()
84
+ providers[key] = AgentProviderState(
85
+ available=health.available,
86
+ path=health.path,
87
+ error=health.error,
88
+ latency_ms=health.latency_ms
87
89
  )
88
90
 
89
- # Reload to get the object
90
- state = self.load()
91
- if not state:
92
- raise ValueError("Script ran but state file is invalid or empty")
93
-
94
- self._state = state
95
- return state
91
+ state = AgentState(
92
+ last_checked=datetime.now(timezone.utc),
93
+ providers=providers
94
+ )
95
+
96
+ # Save state
97
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
98
+ with open(self.state_path, "w") as f:
99
+ yaml.dump(state.model_dump(mode='json'), f)
96
100
 
97
- except subprocess.CalledProcessError as e:
98
- logger.error(f"Agent check script failed: {e.stderr}")
99
- raise RuntimeError(f"Agent check failed: {e.stderr}") from e
101
+ self._state = state
102
+ return state
100
103
 
101
104
  def _find_script(self) -> Optional[Path]:
102
- """Find the check_agents.sh script."""
103
- # Check dev path relative to this file
104
- # this file: monoco/core/agent/state.py
105
- # root: monoco/../../
106
-
107
- current_file = Path(__file__).resolve()
108
-
109
- # Strategy 1: Development logic (Toolkit/scripts/check_agents.sh)
110
- dev_path = current_file.parents[3] / "scripts" / "check_agents.sh"
111
- if dev_path.exists():
112
- return dev_path
113
-
114
- # Strategy 2: If installed in site-packages, maybe we package scripts nearby?
115
- # TODO: Define packaging strategy for scripts
116
-
105
+ """[Deprecated] No longer used."""
117
106
  return None
monoco/core/config.py CHANGED
@@ -10,10 +10,7 @@ import asyncio
10
10
  from watchdog.observers import Observer
11
11
  from watchdog.events import FileSystemEventHandler
12
12
 
13
- # Import AgentIntegration for type hints
14
- from typing import TYPE_CHECKING
15
- if TYPE_CHECKING:
16
- from monoco.core.integrations import AgentIntegration
13
+ from monoco.core.integrations import AgentIntegration
17
14
 
18
15
  logger = logging.getLogger("monoco.core.config")
19
16
 
@@ -55,7 +52,7 @@ class AgentConfig(BaseModel):
55
52
  targets: Optional[list[str]] = Field(default=None, description="Specific target files to inject into (e.g. .cursorrules)")
56
53
  framework: Optional[str] = Field(default=None, description="Manually specified agent framework (cursor, windsurf, etc.)")
57
54
  includes: Optional[list[str]] = Field(default=None, description="List of specific features to include in injection")
58
- integrations: Optional[Dict[str, Any]] = Field(
55
+ integrations: Optional[Dict[str, "AgentIntegration"]] = Field(
59
56
  default=None,
60
57
  description="Custom agent framework integrations (overrides defaults from monoco.core.integrations)"
61
58
  )
@@ -84,9 +81,13 @@ class MonocoConfig(BaseModel):
84
81
  return base
85
82
 
86
83
  @classmethod
87
- def load(cls, project_root: Optional[str] = None) -> "MonocoConfig":
84
+ def load(cls, project_root: Optional[str] = None, require_project: bool = False) -> "MonocoConfig":
88
85
  """
89
86
  Load configuration from multiple sources.
87
+
88
+ Args:
89
+ project_root: Explicit root path. If None, uses CWD.
90
+ require_project: If True, raises error if .monoco directory is missing.
90
91
  """
91
92
  # 1. Start with empty dict (will use defaults via Pydantic)
92
93
  config_data = {}
@@ -96,12 +97,13 @@ class MonocoConfig(BaseModel):
96
97
 
97
98
  # Determine project path
98
99
  cwd = Path(project_root) if project_root else Path.cwd()
99
- proj_path_hidden = cwd / ".monoco" / "config.yaml"
100
+ # FIX-0009: strict separation of workspace and project config
101
+ workspace_config_path = cwd / ".monoco" / "workspace.yaml"
102
+ project_config_path = cwd / ".monoco" / "project.yaml"
100
103
 
101
- # [Legacy] Check for monoco.yaml and warn
102
- proj_path_legacy = cwd / "monoco.yaml"
103
- if proj_path_legacy.exists():
104
- logger.warning(f"Legacy configuration found: {proj_path_legacy}. Please move it to .monoco/config.yaml")
104
+ # Strict Workspace Check
105
+ if require_project and not (cwd / ".monoco").exists():
106
+ raise FileNotFoundError(f"Monoco workspace not found in {cwd}. (No .monoco directory)")
105
107
 
106
108
  # 3. Load User Config
107
109
  if home_path.exists():
@@ -114,13 +116,35 @@ class MonocoConfig(BaseModel):
114
116
  # We don't want to crash on config load fail, implementing simple warning equivalent
115
117
  pass
116
118
 
117
- # 4. Load Project Config (Only .monoco/config.yaml)
118
- if proj_path_hidden.exists():
119
+ # 4. Load Project/Workspace Config
120
+
121
+ # 4a. Load workspace.yaml (Global Environment)
122
+ if workspace_config_path.exists():
123
+ try:
124
+ with open(workspace_config_path, "r") as f:
125
+ ws_config = yaml.safe_load(f)
126
+ if ws_config:
127
+ # workspace.yaml contains core, paths, i18n, ui, telemetry, agent
128
+ cls._deep_merge(config_data, ws_config)
129
+ except Exception:
130
+ pass
131
+
132
+ # 4b. Load project.yaml (Identity)
133
+ if project_config_path.exists():
119
134
  try:
120
- with open(proj_path_hidden, "r") as f:
121
- proj_config = yaml.safe_load(f)
122
- if proj_config:
123
- cls._deep_merge(config_data, proj_config)
135
+ with open(project_config_path, "r") as f:
136
+ pj_config = yaml.safe_load(f)
137
+ if pj_config:
138
+ # project.yaml contains 'project' fields directly? or under 'project' key?
139
+ # Design decision: project.yaml should be clean, e.g. "name: foo".
140
+ # But to simplify merging, let's check if it has a 'project' key or is flat.
141
+ if "project" in pj_config:
142
+ cls._deep_merge(config_data, pj_config)
143
+ else:
144
+ # Assume flat structure mapping to 'project' section
145
+ if "project" not in config_data:
146
+ config_data["project"] = {}
147
+ cls._deep_merge(config_data["project"], pj_config)
124
148
  except Exception:
125
149
  pass
126
150
 
@@ -130,10 +154,12 @@ class MonocoConfig(BaseModel):
130
154
  # Global singleton
131
155
  _settings = None
132
156
 
133
- def get_config(project_root: Optional[str] = None) -> MonocoConfig:
157
+ def get_config(project_root: Optional[str] = None, require_project: bool = False) -> MonocoConfig:
134
158
  global _settings
135
- if _settings is None or project_root is not None:
136
- _settings = MonocoConfig.load(project_root)
159
+ # If explicit root provided, always reload.
160
+ # If require_project is True, we must reload to ensure validation happens (in case a previous loose load occurred).
161
+ if _settings is None or project_root is not None or require_project:
162
+ _settings = MonocoConfig.load(project_root, require_project=require_project)
137
163
  return _settings
138
164
 
139
165
  class ConfigScope(str, Enum):
@@ -22,14 +22,59 @@ class AgentIntegration(BaseModel):
22
22
  name: Human-readable name of the framework
23
23
  system_prompt_file: Path to the system prompt/rules file relative to project root
24
24
  skill_root_dir: Path to the skills directory relative to project root
25
+ bin_name: Optional name of the binary to check for (e.g., 'cursor', 'gemini')
26
+ version_cmd: Optional command to check version (e.g., '--version')
25
27
  enabled: Whether this integration is active (default: True)
26
28
  """
27
29
  key: str = Field(..., description="Unique framework identifier")
28
30
  name: str = Field(..., description="Human-readable framework name")
29
31
  system_prompt_file: str = Field(..., description="Path to system prompt file (relative to project root)")
30
32
  skill_root_dir: str = Field(..., description="Path to skills directory (relative to project root)")
33
+ bin_name: Optional[str] = Field(None, description="Binary name to check for availability")
34
+ version_cmd: Optional[str] = Field(None, description="Command to check version")
31
35
  enabled: bool = Field(default=True, description="Whether this integration is active")
32
36
 
37
+ def check_health(self) -> "AgentProviderHealth":
38
+ """
39
+ Check health of this integration.
40
+ """
41
+ import shutil
42
+ import subprocess
43
+ import time
44
+
45
+ if not self.bin_name:
46
+ return AgentProviderHealth(available=True) # If no binary required, assume ok
47
+
48
+ bin_path = shutil.which(self.bin_name)
49
+ if not bin_path:
50
+ return AgentProviderHealth(available=False, error=f"Binary '{self.bin_name}' not found in PATH")
51
+
52
+ if not self.version_cmd:
53
+ return AgentProviderHealth(available=True, path=bin_path)
54
+
55
+ start_time = time.time()
56
+ try:
57
+ # We use a shortcut to check version cmd
58
+ # Some tools might need e.g. 'cursor --version'
59
+ subprocess.run(
60
+ [self.bin_name] + self.version_cmd.split(),
61
+ check=True,
62
+ capture_output=True,
63
+ timeout=5
64
+ )
65
+ latency = int((time.time() - start_time) * 1000)
66
+ return AgentProviderHealth(available=True, path=bin_path, latency_ms=latency)
67
+ except Exception as e:
68
+ return AgentProviderHealth(available=False, path=bin_path, error=str(e))
69
+
70
+
71
+ class AgentProviderHealth(BaseModel):
72
+ """Health check result for an agent provider."""
73
+ available: bool
74
+ path: Optional[str] = None
75
+ error: Optional[str] = None
76
+ latency_ms: Optional[int] = None
77
+
33
78
 
34
79
  # Default Integration Registry
35
80
  DEFAULT_INTEGRATIONS: Dict[str, AgentIntegration] = {
@@ -38,24 +83,32 @@ DEFAULT_INTEGRATIONS: Dict[str, AgentIntegration] = {
38
83
  name="Cursor",
39
84
  system_prompt_file=".cursorrules",
40
85
  skill_root_dir=".cursor/skills/",
86
+ bin_name="cursor",
87
+ version_cmd="--version",
41
88
  ),
42
89
  "claude": AgentIntegration(
43
90
  key="claude",
44
91
  name="Claude Code",
45
92
  system_prompt_file="CLAUDE.md",
46
93
  skill_root_dir=".claude/skills/",
94
+ bin_name="claude",
95
+ version_cmd="--version",
47
96
  ),
48
97
  "gemini": AgentIntegration(
49
98
  key="gemini",
50
99
  name="Gemini CLI",
51
100
  system_prompt_file="GEMINI.md",
52
101
  skill_root_dir=".gemini/skills/",
102
+ bin_name="gemini",
103
+ version_cmd="--version",
53
104
  ),
54
105
  "qwen": AgentIntegration(
55
106
  key="qwen",
56
107
  name="Qwen Code",
57
108
  system_prompt_file="QWEN.md",
58
109
  skill_root_dir=".qwen/skills/",
110
+ bin_name="qwen",
111
+ version_cmd="--version",
59
112
  ),
60
113
  "agent": AgentIntegration(
61
114
  key="agent",
monoco/core/lsp.py ADDED
@@ -0,0 +1,61 @@
1
+ from enum import IntEnum
2
+ from typing import List, Optional, Union
3
+ from pydantic import BaseModel
4
+
5
+ class Position(BaseModel):
6
+ """
7
+ Position in a text document expressed as zero-based line and character offset.
8
+ """
9
+ line: int
10
+ character: int
11
+
12
+ def __lt__(self, other):
13
+ if self.line != other.line:
14
+ return self.line < other.line
15
+ return self.character < other.character
16
+
17
+ class Range(BaseModel):
18
+ """
19
+ A range in a text document expressed as (zero-based) start and end positions.
20
+ """
21
+ start: Position
22
+ end: Position
23
+
24
+ def __repr__(self):
25
+ return f"{self.start.line}:{self.start.character}-{self.end.line}:{self.end.character}"
26
+
27
+ class DiagnosticSeverity(IntEnum):
28
+ Error = 1
29
+ Warning = 2
30
+ Information = 3
31
+ Hint = 4
32
+
33
+ class DiagnosticRelatedInformation(BaseModel):
34
+ """
35
+ Represents a related message and source code location for a diagnostic.
36
+ """
37
+ # location: Location # Defined elsewhere or simplified here
38
+ message: str
39
+
40
+ class Diagnostic(BaseModel):
41
+ """
42
+ Represents a diagnostic, such as a compiler error or warning.
43
+ """
44
+ range: Range
45
+ severity: Optional[DiagnosticSeverity] = None
46
+ code: Optional[Union[int, str]] = None
47
+ source: Optional[str] = "monoco"
48
+ message: str
49
+ related_information: Optional[List[DiagnosticRelatedInformation]] = None
50
+ data: Optional[dict] = None # To carry extra info (e.g. for code actions)
51
+
52
+ def to_user_string(self) -> str:
53
+ """Helper to format for CLI output"""
54
+ severity_map = {
55
+ 1: "[red]Error[/red]",
56
+ 2: "[yellow]Warning[/yellow]",
57
+ 3: "[blue]Info[/blue]",
58
+ 4: "[dim]Hint[/dim]"
59
+ }
60
+ sev = severity_map.get(self.severity, "Error")
61
+ return f"{sev}: {self.message} (Line {self.range.start.line + 1})"