opsward 0.0.2__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.
opsward/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Diagnose, generate, and maintain the AI agent setup of your projects."""
2
+
3
+ from opsward.base import (
4
+ AgentInfo,
5
+ ComponentScore,
6
+ DiagnosisReport,
7
+ DocSpec,
8
+ GeneratedFile,
9
+ MaintenanceSuggestion,
10
+ ProjectType,
11
+ RuleInfo,
12
+ ScanResult,
13
+ SkillInfo,
14
+ )
15
+ from opsward.scan import scan
16
+ from opsward.score import diagnose
17
+ from opsward.generate import generate
18
+ from opsward.maintain import maintain
opsward/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """CLI entry point: python -m opsward."""
2
+
3
+ import argh
4
+
5
+ from opsward.cli import _dispatch_funcs
6
+
7
+
8
+ def main():
9
+ argh.dispatch_commands(_dispatch_funcs)
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
opsward/base.py ADDED
@@ -0,0 +1,204 @@
1
+ """All dataclasses and type definitions for opsward."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ class ProjectType(Enum):
10
+ """Detected project type."""
11
+
12
+ python = "python"
13
+ jsts = "jsts"
14
+ mixed = "mixed"
15
+ unknown = "unknown"
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Inventory items
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SkillInfo:
25
+ """A skill found in .claude/skills/."""
26
+
27
+ name: str
28
+ path: Path
29
+ has_skill_md: bool = False
30
+ description: str = ""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AgentInfo:
35
+ """An agent found in .claude/agents/."""
36
+
37
+ name: str
38
+ path: Path
39
+ description: str = ""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RuleInfo:
44
+ """A rule found in .claude/rules/."""
45
+
46
+ name: str
47
+ path: Path
48
+ content: str = ""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class DocSpec:
53
+ """A document found in the docs directory."""
54
+
55
+ name: str
56
+ path: Path
57
+ size_bytes: int = 0
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Scan output
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @dataclass
66
+ class ScanResult:
67
+ """Everything we learned by reading (never writing) a target project."""
68
+
69
+ project_root: Path
70
+ project_type: ProjectType = ProjectType.unknown
71
+
72
+ # CLAUDE.md
73
+ claude_md_path: Optional[Path] = None
74
+ claude_md_content: str = ""
75
+
76
+ # .claude/ inventories
77
+ skills: list[SkillInfo] = field(default_factory=list)
78
+ agents: list[AgentInfo] = field(default_factory=list)
79
+ rules: list[RuleInfo] = field(default_factory=list)
80
+
81
+ # Hooks
82
+ hooks_path: Optional[Path] = None
83
+ hooks_config: Optional[dict] = None
84
+
85
+ # Docs
86
+ docs: list[DocSpec] = field(default_factory=list)
87
+ has_docs_guide: bool = False
88
+ docs_guide_path: Optional[Path] = None
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Scoring / diagnosis output
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ @dataclass
97
+ class ComponentScore:
98
+ """Score for a single component (0–100) with optional notes."""
99
+
100
+ name: str
101
+ score: int
102
+ max_score: int = 100
103
+ notes: list[str] = field(default_factory=list)
104
+
105
+
106
+ @dataclass
107
+ class DiagnosisReport:
108
+ """Report card produced by scoring a ScanResult."""
109
+
110
+ project_root: Path
111
+ project_type: ProjectType
112
+ scores: list[ComponentScore] = field(default_factory=list)
113
+ missing_items: list[str] = field(default_factory=list)
114
+ suggestions: list[str] = field(default_factory=list)
115
+
116
+ # Weighted overall score (0–100), set by score.py
117
+ weighted_score: float = 0.0
118
+
119
+ @property
120
+ def overall_score(self) -> float:
121
+ """Weighted score if set, else simple average."""
122
+ if self.weighted_score:
123
+ return self.weighted_score
124
+ if not self.scores:
125
+ return 0.0
126
+ return sum(s.score for s in self.scores) / len(self.scores)
127
+
128
+ @property
129
+ def grade(self) -> str:
130
+ """Letter grade: A (90-100), B (80-89), C (70-79), D (60-69), F (<60)."""
131
+ s = self.overall_score
132
+ if s >= 90:
133
+ return "A"
134
+ if s >= 80:
135
+ return "B"
136
+ if s >= 70:
137
+ return "C"
138
+ if s >= 60:
139
+ return "D"
140
+ return "F"
141
+
142
+ def __str__(self) -> str:
143
+ lines = [
144
+ f"Diagnosis Report: {self.project_root.name}",
145
+ f"Project type: {self.project_type.value}",
146
+ f"Overall score: {self.overall_score:.0f}/100 (Grade: {self.grade})",
147
+ "",
148
+ ]
149
+
150
+ if self.scores:
151
+ lines.append("Components:")
152
+ for cs in self.scores:
153
+ bar = _score_bar(cs.score, cs.max_score)
154
+ lines.append(f" {cs.name:<25s} {bar} {cs.score}/{cs.max_score}")
155
+ for note in cs.notes:
156
+ lines.append(f" - {note}")
157
+ lines.append("")
158
+
159
+ if self.missing_items:
160
+ lines.append("Missing:")
161
+ for item in self.missing_items:
162
+ lines.append(f" [ ] {item}")
163
+ lines.append("")
164
+
165
+ if self.suggestions:
166
+ lines.append("Suggestions:")
167
+ for i, sug in enumerate(self.suggestions, 1):
168
+ lines.append(f" {i}. {sug}")
169
+
170
+ return "\n".join(lines)
171
+
172
+
173
+ def _score_bar(score: int, max_score: int, *, width: int = 20) -> str:
174
+ """Return a simple ASCII progress bar."""
175
+ filled = round(width * score / max_score) if max_score else 0
176
+ return "[" + "#" * filled + "." * (width - filled) + "]"
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Generation output
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class GeneratedFile:
186
+ """A file to be written by the generate step."""
187
+
188
+ target_path: Path
189
+ content: str
190
+ overwrite_policy: str = "skip" # 'skip' | 'overwrite' | 'merge'
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Maintenance output
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class MaintenanceSuggestion:
200
+ """A single maintenance action proposed by maintain.py."""
201
+
202
+ category: str # e.g. 'stale_path', 'outdated_doc', 'sync_issue'
203
+ description: str
204
+ diff: str = ""
opsward/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """CLI dispatch for opsward."""
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+
8
+ from opsward.base import DiagnosisReport
9
+ from opsward.generate import generate
10
+ from opsward.maintain import maintain
11
+ from opsward.scan import scan
12
+ from opsward.score import diagnose
13
+
14
+
15
+ def _serialize_report(report: DiagnosisReport) -> dict:
16
+ """Convert a DiagnosisReport to a JSON-serialisable dict."""
17
+ d = asdict(report)
18
+ d["project_root"] = str(report.project_root)
19
+ d["project_type"] = report.project_type.value
20
+ d["overall_score"] = report.overall_score
21
+ d["grade"] = report.grade
22
+ return d
23
+
24
+
25
+ def diagnose_cmd(
26
+ *project_roots: str,
27
+ format: str = "text",
28
+ verbose: bool = False,
29
+ ):
30
+ """Diagnose the AI agent setup of one or more projects.
31
+
32
+ :param project_roots: one or more paths to project directories
33
+ :param format: output format — 'text' or 'json'
34
+ :param verbose: show additional detail in text output
35
+ """
36
+ if not project_roots:
37
+ project_roots = (".",)
38
+
39
+ reports = []
40
+ for root_str in project_roots:
41
+ root = Path(root_str).resolve()
42
+ if not root.is_dir():
43
+ print(f"Error: {root_str} is not a directory", file=sys.stderr)
44
+ sys.exit(2)
45
+
46
+ sr = scan(root)
47
+ report = diagnose(sr)
48
+ reports.append(report)
49
+
50
+ if format == "json":
51
+ data = [_serialize_report(r) for r in reports]
52
+ output = data[0] if len(data) == 1 else data
53
+ print(json.dumps(output, indent=2, default=str))
54
+ else:
55
+ for i, report in enumerate(reports):
56
+ if i > 0:
57
+ print("\n" + "=" * 60 + "\n")
58
+ print(report)
59
+ if verbose:
60
+ sr = scan(report.project_root)
61
+ _print_verbose(sr)
62
+
63
+ worst = min(r.overall_score for r in reports)
64
+ sys.exit(0 if worst >= 80 else 1)
65
+
66
+
67
+ def generate_cmd(
68
+ *project_roots: str,
69
+ write: bool = False,
70
+ format: str = "text",
71
+ ):
72
+ """Generate missing AI setup artifacts for one or more projects.
73
+
74
+ By default, shows what would be created (dry run). Use --write to
75
+ actually write files. Existing files are never overwritten.
76
+
77
+ :param project_roots: one or more paths to project directories
78
+ :param write: actually write files (default: dry run)
79
+ :param format: output format — 'text' or 'json'
80
+ """
81
+ if not project_roots:
82
+ project_roots = (".",)
83
+
84
+ all_files = []
85
+ for root_str in project_roots:
86
+ root = Path(root_str).resolve()
87
+ if not root.is_dir():
88
+ print(f"Error: {root_str} is not a directory", file=sys.stderr)
89
+ sys.exit(2)
90
+
91
+ sr = scan(root)
92
+ files = generate(sr)
93
+ all_files.extend(files)
94
+
95
+ if format == "json":
96
+ continue
97
+
98
+ # Text output
99
+ if not files:
100
+ print(f"{root.name}: nothing to generate — all artifacts present")
101
+ continue
102
+
103
+ action = "Creating" if write else "Would create"
104
+ print(f"{root.name}: {len(files)} artifact(s)\n")
105
+ for gf in files:
106
+ rel = _relative_path(gf.target_path, root)
107
+ exists = gf.target_path.exists()
108
+ if exists:
109
+ print(f" SKIP {rel} (already exists)")
110
+ else:
111
+ print(f" {action} {rel}")
112
+
113
+ if write and not exists:
114
+ gf.target_path.parent.mkdir(parents=True, exist_ok=True)
115
+ gf.target_path.write_text(gf.content, encoding="utf-8")
116
+
117
+ if not write:
118
+ print(f"\nDry run — pass --write to create files.")
119
+
120
+ if format == "json":
121
+ data = [
122
+ {
123
+ "target_path": str(gf.target_path),
124
+ "exists": gf.target_path.exists(),
125
+ "overwrite_policy": gf.overwrite_policy,
126
+ "content_length": len(gf.content),
127
+ }
128
+ for gf in all_files
129
+ ]
130
+ print(json.dumps(data, indent=2))
131
+
132
+
133
+ def maintain_cmd(
134
+ *project_roots: str,
135
+ format: str = "text",
136
+ ):
137
+ """Check for stale references, out-of-sync docs, and other drift.
138
+
139
+ :param project_roots: one or more paths to project directories
140
+ :param format: output format — 'text' or 'json'
141
+ """
142
+ if not project_roots:
143
+ project_roots = (".",)
144
+
145
+ all_suggestions = []
146
+ for root_str in project_roots:
147
+ root = Path(root_str).resolve()
148
+ if not root.is_dir():
149
+ print(f"Error: {root_str} is not a directory", file=sys.stderr)
150
+ sys.exit(2)
151
+
152
+ sr = scan(root)
153
+ suggestions = maintain(sr)
154
+ all_suggestions.extend(suggestions)
155
+
156
+ if format == "json":
157
+ continue
158
+
159
+ if not suggestions:
160
+ print(f"{root.name}: no maintenance issues found")
161
+ continue
162
+
163
+ print(f"{root.name}: {len(suggestions)} issue(s)\n")
164
+ for ms in suggestions:
165
+ print(f" [{ms.category}] {ms.description}")
166
+ if ms.diff:
167
+ for line in ms.diff.splitlines():
168
+ print(f" {line}")
169
+ print()
170
+
171
+ if format == "json":
172
+ data = [
173
+ {
174
+ "category": ms.category,
175
+ "description": ms.description,
176
+ "diff": ms.diff,
177
+ }
178
+ for ms in all_suggestions
179
+ ]
180
+ print(json.dumps(data, indent=2))
181
+
182
+ sys.exit(0 if not all_suggestions else 1)
183
+
184
+
185
+ def _relative_path(path: Path, base: Path) -> str:
186
+ """Return path relative to base, or absolute if not under base."""
187
+ try:
188
+ return str(path.relative_to(base))
189
+ except ValueError:
190
+ return str(path)
191
+
192
+
193
+ def _print_verbose(sr):
194
+ """Print extra scan details."""
195
+ print("\nDetailed inventory:")
196
+ print(f" Skills: {len(sr.skills)}")
197
+ for s in sr.skills:
198
+ print(f" - {s.name} (SKILL.md: {'yes' if s.has_skill_md else 'no'})")
199
+ print(f" Agents: {len(sr.agents)}")
200
+ for a in sr.agents:
201
+ print(f" - {a.name}")
202
+ print(f" Rules: {len(sr.rules)}")
203
+ for r in sr.rules:
204
+ print(f" - {r.name}")
205
+ print(f" Docs: {len(sr.docs)}")
206
+ for d in sr.docs:
207
+ print(f" - {d.name} ({d.size_bytes} bytes)")
208
+ print(f" Hooks: {'yes' if sr.hooks_config else 'no'}")
209
+
210
+
211
+ _dispatch_funcs = [diagnose_cmd, generate_cmd, maintain_cmd]
File without changes
File without changes
@@ -0,0 +1,30 @@
1
+ # Conventions — ${project_name}
2
+
3
+ ## Code Style
4
+
5
+ - **Formatter:** ${formatter}
6
+ - **Linter:** ${linter}
7
+ - **Package manager:** ${package_manager}
8
+
9
+ ## Naming
10
+
11
+ - Files: `camelCase.ts` or `PascalCase.tsx` (components)
12
+ - Functions/variables: `camelCase`
13
+ - Classes/components: `PascalCase`
14
+ - Constants: `UPPER_SNAKE_CASE`
15
+ - Types/interfaces: `PascalCase`
16
+
17
+ ## Imports
18
+
19
+ - Use named exports over default exports
20
+ - Group imports: external, then internal, then relative
21
+
22
+ ## TypeScript
23
+
24
+ - Enable strict mode
25
+ - Prefer `interface` over `type` for object shapes
26
+ - Use `const` assertions where applicable
27
+
28
+ ## Project-Specific Conventions
29
+
30
+ <!-- Add conventions specific to this project -->
@@ -0,0 +1,28 @@
1
+ # Testing — ${project_name}
2
+
3
+ ## Test Framework
4
+
5
+ ${test_framework}
6
+
7
+ ## Running Tests
8
+
9
+ ```bash
10
+ ${test_command}
11
+ ```
12
+
13
+ ## Test Structure
14
+
15
+ - Tests live alongside source files or in `${test_dir}/`
16
+ - Test files follow `*.test.ts` or `*.spec.ts` naming
17
+
18
+ ## Coverage
19
+
20
+ ```bash
21
+ ${coverage_command}
22
+ ```
23
+
24
+ ## Writing Tests
25
+
26
+ - Each test block tests one behavior
27
+ - Use descriptive test names
28
+ - Prefer `describe` / `it` structure for grouping
@@ -0,0 +1,33 @@
1
+ # Conventions — ${project_name}
2
+
3
+ ## Code Style
4
+
5
+ - **Formatter:** ${formatter}
6
+ - **Linter:** ${linter}
7
+ - **Line length:** ${line_length}
8
+
9
+ ## Naming
10
+
11
+ - Modules: `snake_case`
12
+ - Classes: `PascalCase`
13
+ - Functions/variables: `snake_case`
14
+ - Constants: `UPPER_SNAKE_CASE`
15
+
16
+ ## Imports
17
+
18
+ - Standard library first, then third-party, then local
19
+ - Prefer absolute imports
20
+
21
+ ## Type Hints
22
+
23
+ - Use type hints for all public function signatures
24
+ - Prefer `collections.abc` types (`Mapping`, `Iterable`, `Sequence`) over concrete types
25
+
26
+ ## Testing
27
+
28
+ - Tests in `tests/` with `test_{module_name}.py` naming
29
+ - Use pytest fixtures for shared setup
30
+
31
+ ## Project-Specific Conventions
32
+
33
+ <!-- Add conventions specific to this project -->
@@ -0,0 +1,29 @@
1
+ # Testing — ${project_name}
2
+
3
+ ## Test Framework
4
+
5
+ ${test_framework}
6
+
7
+ ## Running Tests
8
+
9
+ ```bash
10
+ ${test_command}
11
+ ```
12
+
13
+ ## Test Structure
14
+
15
+ - Tests live in `${test_dir}/`
16
+ - Test files follow `test_{module_name}.py` naming
17
+ - Fixtures and shared helpers in `${test_dir}/conftest.py`
18
+
19
+ ## Coverage
20
+
21
+ ```bash
22
+ ${coverage_command}
23
+ ```
24
+
25
+ ## Writing Tests
26
+
27
+ - Each test function tests one behavior
28
+ - Use descriptive test names: `test_{what}_{condition}_{expected}`
29
+ - Prefer pytest fixtures over setUp/tearDown
@@ -0,0 +1,21 @@
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ ${project_name} — ${project_description}
6
+
7
+ ## Tech Stack
8
+
9
+ ${tech_stack}
10
+
11
+ ## Module Map
12
+
13
+ ${module_map}
14
+
15
+ ## Data Flow
16
+
17
+ <!-- Describe the primary data flow through the system -->
18
+
19
+ ## Key Invariants
20
+
21
+ <!-- List any important invariants that must be maintained -->
@@ -0,0 +1,28 @@
1
+ # ${project_name}
2
+
3
+ ${project_description}
4
+
5
+ ## Project Overview
6
+
7
+ ${project_name} is a ${project_type} project.
8
+
9
+ ## Tech Stack
10
+
11
+ ${tech_stack}
12
+
13
+ ## Documentation
14
+
15
+ For detailed project knowledge, see `${docs_path}/docs_guide.md`.
16
+ Read it to discover which docs to consult for your current task.
17
+
18
+ ## Module Map
19
+
20
+ ${module_map}
21
+
22
+ ## Commands
23
+
24
+ ${commands}
25
+
26
+ ## Conventions
27
+
28
+ ${conventions}
@@ -0,0 +1,17 @@
1
+ # NNNN — Title of Decision
2
+
3
+ ## Status
4
+
5
+ Proposed | Accepted | Deprecated | Superseded by [NNNN](NNNN-title.md)
6
+
7
+ ## Context
8
+
9
+ <!-- What is the issue motivating this decision? -->
10
+
11
+ ## Decision
12
+
13
+ <!-- What is the change being proposed? -->
14
+
15
+ ## Consequences
16
+
17
+ <!-- What are the positive and negative consequences of this decision? -->
@@ -0,0 +1,17 @@
1
+ # External Dependencies — ${project_name}
2
+
3
+ Services, APIs, and system-level dependencies beyond the package manager.
4
+
5
+ ## Environment Variables
6
+
7
+ | Variable | Purpose | Required |
8
+ |----------|---------|----------|
9
+ ${env_vars_table}
10
+
11
+ ## External Services
12
+
13
+ <!-- List any APIs, databases, or third-party services the project depends on -->
14
+
15
+ ## System Dependencies
16
+
17
+ <!-- List any system packages, binaries, or tools required beyond the language runtime -->
@@ -0,0 +1,17 @@
1
+ # Deployment — ${project_name}
2
+
3
+ ## Environments
4
+
5
+ <!-- List deployment environments (dev, staging, production) -->
6
+
7
+ ## How to Deploy
8
+
9
+ <!-- Step-by-step deployment instructions -->
10
+
11
+ ## CI/CD Pipeline
12
+
13
+ <!-- Describe the continuous integration and deployment pipeline -->
14
+
15
+ ## Rollback Procedure
16
+
17
+ <!-- How to roll back a bad deployment -->