feature-map-cli 1.0.0__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 (40) hide show
  1. feature_map/__init__.py +3 -0
  2. feature_map/__main__.py +3 -0
  3. feature_map/_version.py +1 -0
  4. feature_map/bootstrap.py +189 -0
  5. feature_map/cli.py +273 -0
  6. feature_map/commands/__init__.py +0 -0
  7. feature_map/commands/check_cmd.py +23 -0
  8. feature_map/commands/find_cmd.py +36 -0
  9. feature_map/commands/graph_cmd.py +18 -0
  10. feature_map/commands/impact_cmd.py +55 -0
  11. feature_map/commands/init_cmd.py +72 -0
  12. feature_map/commands/install_cmd.py +54 -0
  13. feature_map/commands/list_cmd.py +37 -0
  14. feature_map/commands/search_cmd.py +21 -0
  15. feature_map/commands/show_cmd.py +34 -0
  16. feature_map/commands/stats_cmd.py +85 -0
  17. feature_map/commands/validate_cmd.py +59 -0
  18. feature_map/config.py +24 -0
  19. feature_map/discover.py +59 -0
  20. feature_map/errors.py +17 -0
  21. feature_map/graph.py +79 -0
  22. feature_map/loader.py +62 -0
  23. feature_map/output.py +54 -0
  24. feature_map/path_extract.py +139 -0
  25. feature_map/path_normalize.py +100 -0
  26. feature_map/path_resolve.py +36 -0
  27. feature_map/paths.py +34 -0
  28. feature_map/share/schema/feature-map.schema.json +39 -0
  29. feature_map/share/skill/SKILL.md +58 -0
  30. feature_map/share/skill/references/authoring.md +37 -0
  31. feature_map/share/skill/references/commands.md +47 -0
  32. feature_map/share/skill/references/existing-repos.md +43 -0
  33. feature_map/share/templates/feature.yaml.tpl +17 -0
  34. feature_map/text_index.py +49 -0
  35. feature_map/validate.py +148 -0
  36. feature_map_cli-1.0.0.dist-info/METADATA +121 -0
  37. feature_map_cli-1.0.0.dist-info/RECORD +40 -0
  38. feature_map_cli-1.0.0.dist-info/WHEEL +4 -0
  39. feature_map_cli-1.0.0.dist-info/entry_points.txt +2 -0
  40. feature_map_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,54 @@
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ from feature_map.paths import skill_dir as bundled_skill_dir
6
+
7
+
8
+ def _detect_deployed_skill(repo_root: Path) -> Path:
9
+ candidates = [
10
+ repo_root / ".agents" / "skills" / "feature-map",
11
+ repo_root / ".grok" / "skills" / "feature-map",
12
+ ]
13
+ for path in candidates:
14
+ if (path / "SKILL.md").is_file():
15
+ return path
16
+ return candidates[0]
17
+
18
+
19
+ def run_install(repo_root: Path, as_json: bool = False):
20
+ shim = repo_root / "bin" / "feature-map"
21
+ features_dir = repo_root / ".features"
22
+ config_file = repo_root / ".feature-map.yaml"
23
+ skill_path = _detect_deployed_skill(repo_root)
24
+ on_path = shutil.which("feature-map")
25
+
26
+ shim_ok = shim.is_file() and os.access(shim, os.X_OK)
27
+ skill_ok = (skill_path / "SKILL.md").is_file()
28
+ bundled_ok = (bundled_skill_dir() / "SKILL.md").is_file()
29
+ features_ok = features_dir.is_dir()
30
+ config_ok = config_file.is_file()
31
+
32
+ payload = {
33
+ "ok": bundled_ok and (shim_ok or bool(on_path)),
34
+ "cli_on_path": {"command": "feature-map", "path": on_path, "exists": bool(on_path)},
35
+ "shim": {"path": str(shim), "exists": shim.is_file(), "executable": shim_ok},
36
+ "skill": {"path": str(skill_path), "exists": skill_ok},
37
+ "bundled_skill": {"path": str(bundled_skill_dir()), "exists": bundled_ok},
38
+ "features_dir": {"path": str(features_dir), "exists": features_ok},
39
+ "config": {"path": str(config_file), "exists": config_ok},
40
+ }
41
+
42
+ if as_json:
43
+ return payload
44
+
45
+ print("Feature Map install status:")
46
+ print(f" feature-map on PATH: {'ok' if on_path else 'missing'} ({on_path or 'not found'})")
47
+ print(f" shim: {'ok' if shim_ok else 'missing or not executable'} ({shim})")
48
+ print(f" skill: {'ok' if skill_ok else 'missing'} ({skill_path})")
49
+ print(f" bundled skill: {'ok' if bundled_ok else 'missing'} ({bundled_skill_dir()})")
50
+ print(f" .features/: {'ok' if features_ok else 'missing'} ({features_dir})")
51
+ print(f" .feature-map.yaml: {'ok' if config_ok else 'missing'} ({config_file})")
52
+ if not features_ok:
53
+ print('Suggestion: run "feature-map init" to bootstrap this repository.')
54
+ return payload
@@ -0,0 +1,37 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+ from feature_map.loader import list_map_files
5
+
6
+
7
+ def run_list(features_dir: Path, as_json: bool = False):
8
+ files = list_map_files(features_dir)
9
+ slugs = [path.stem for path in files]
10
+
11
+ if as_json:
12
+ items = []
13
+ for path in files:
14
+ mtime = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
15
+ apps_count = 0
16
+ try:
17
+ import yaml as yaml_lib
18
+
19
+ data = yaml_lib.safe_load(path.read_text(encoding="utf-8")) or {}
20
+ apps = data.get("apps")
21
+ if isinstance(apps, list):
22
+ apps_count = len(apps)
23
+ elif isinstance(apps, dict):
24
+ apps_count = len(apps)
25
+ except Exception:
26
+ pass
27
+ items.append(
28
+ {
29
+ "slug": path.stem,
30
+ "file": path.name,
31
+ "mtime": mtime,
32
+ "apps_count": apps_count,
33
+ }
34
+ )
35
+ return {"ok": True, "count": len(items), "features": items}
36
+
37
+ return {"features": slugs}
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+ from feature_map.text_index import search_maps
4
+
5
+
6
+ def run_search(features_dir: Path, query: str, as_json: bool = False):
7
+ results = search_maps(features_dir, query)
8
+ payload = {"ok": True, "query": query, "count": len(results), "results": results}
9
+
10
+ if as_json:
11
+ return payload
12
+
13
+ if not results:
14
+ print(f"No features matched '{query}'.")
15
+ return payload
16
+
17
+ for item in results:
18
+ print(item["feature"])
19
+ for snippet in item.get("snippets", []):
20
+ print(f" {snippet}")
21
+ return payload
@@ -0,0 +1,34 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ from feature_map.loader import load_map, load_map_text, resolve_map_path
6
+
7
+
8
+ def run_show(features_dir: Path, name: str, section=None, as_json: bool = False):
9
+ path = resolve_map_path(features_dir, name)
10
+ if as_json:
11
+ data = load_map(path)
12
+ if section:
13
+ if section not in data:
14
+ from feature_map.errors import CliError
15
+
16
+ raise CliError(
17
+ f'Section "{section}" not found in {path.stem}.',
18
+ suggestion=f"Available keys: {', '.join(sorted(data.keys()))}",
19
+ )
20
+ return {"ok": True, "feature": path.stem, "section": section, "data": data[section]}
21
+ return {"ok": True, "feature": path.stem, "data": data}
22
+
23
+ if section:
24
+ data = load_map(path)
25
+ if section not in data:
26
+ from feature_map.errors import CliError
27
+
28
+ raise CliError(
29
+ f'Section "{section}" not found in {path.stem}.',
30
+ suggestion=f"Available keys: {', '.join(sorted(data.keys()))}",
31
+ )
32
+ return yaml.dump({section: data[section]}, sort_keys=False)
33
+
34
+ return load_map_text(path)
@@ -0,0 +1,85 @@
1
+ import re
2
+ from collections import Counter
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import yaml
7
+
8
+ from feature_map.loader import all_slugs, extract_related_slug, list_map_files
9
+ from feature_map.validate import RECOMMENDED_KEYS, REQUIRED_KEYS
10
+
11
+
12
+ def normalize_app_name(app) -> Optional[str]:
13
+ if not isinstance(app, str):
14
+ return None
15
+ app = app.strip()
16
+ if not app or app.startswith("("):
17
+ return None
18
+ match = re.match(r'^["\']?([a-zA-Z0-9_-]+)', app)
19
+ if match:
20
+ return match.group(1)
21
+ return None
22
+
23
+
24
+ def run_stats(features_dir: Path, as_json: bool = False):
25
+ slugs = all_slugs(features_dir)
26
+ apps_counter = Counter()
27
+ missing_sections = 0
28
+ broken_links = 0
29
+ parse_failures = 0
30
+
31
+ for path in list_map_files(features_dir):
32
+ try:
33
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
34
+ except yaml.YAMLError:
35
+ parse_failures += 1
36
+ missing_sections += len(REQUIRED_KEYS) + len(RECOMMENDED_KEYS)
37
+ continue
38
+
39
+ if not isinstance(data, dict):
40
+ continue
41
+
42
+ for key in REQUIRED_KEYS + RECOMMENDED_KEYS:
43
+ if key not in data or data[key] in (None, "", []):
44
+ missing_sections += 1
45
+
46
+ apps = data.get("apps")
47
+ if isinstance(apps, list):
48
+ for app in apps:
49
+ name = normalize_app_name(app)
50
+ if name:
51
+ apps_counter[name] += 1
52
+ elif isinstance(apps, dict):
53
+ for app in apps:
54
+ name = normalize_app_name(app)
55
+ if name:
56
+ apps_counter[name] += 1
57
+
58
+ related = data.get("related_features") or []
59
+ if isinstance(related, list):
60
+ for entry in related:
61
+ slug = extract_related_slug(entry)
62
+ if slug and slug not in slugs:
63
+ broken_links += 1
64
+
65
+ payload = {
66
+ "ok": True,
67
+ "map_count": len(list(list_map_files(features_dir))),
68
+ "parse_failures": parse_failures,
69
+ "missing_sections": missing_sections,
70
+ "broken_related_features": broken_links,
71
+ "maps_per_app": dict(sorted(apps_counter.items())),
72
+ }
73
+
74
+ if as_json:
75
+ return payload
76
+
77
+ print(f"Maps: {payload['map_count']}")
78
+ print(f"Parse failures: {payload['parse_failures']}")
79
+ print(f"Missing sections: {payload['missing_sections']}")
80
+ print(f"Broken related_features: {payload['broken_related_features']}")
81
+ if apps_counter:
82
+ print("Maps per app:")
83
+ for app, count in sorted(apps_counter.items()):
84
+ print(f" {app}: {count}")
85
+ return payload
@@ -0,0 +1,59 @@
1
+ from pathlib import Path
2
+
3
+ from feature_map.loader import all_slugs, list_map_files
4
+ from feature_map.validate import validate_map_file
5
+
6
+
7
+ def run_validate(features_dir: Path, strict: bool = False, as_json: bool = False):
8
+ slugs = all_slugs(features_dir)
9
+ all_errors = []
10
+ all_warnings = []
11
+ map_results = []
12
+
13
+ for path in list_map_files(features_dir):
14
+ errors, warnings = validate_map_file(path, slugs, strict=strict)
15
+ all_errors.extend(errors)
16
+ all_warnings.extend(warnings)
17
+ map_results.append(
18
+ {
19
+ "file": path.name,
20
+ "slug": path.stem,
21
+ "errors": errors,
22
+ "warnings": warnings,
23
+ "ok": len(errors) == 0,
24
+ }
25
+ )
26
+
27
+ result = {
28
+ "ok": len(all_errors) == 0 and (len(all_warnings) == 0 if strict else True),
29
+ "map_count": len(map_results),
30
+ "error_count": len(all_errors),
31
+ "warning_count": len(all_warnings),
32
+ "maps": map_results,
33
+ "errors": all_errors,
34
+ "warnings": all_warnings,
35
+ }
36
+
37
+ if not as_json:
38
+ if all_errors:
39
+ for err in all_errors:
40
+ print(f"ERROR: {err}")
41
+ if all_warnings:
42
+ for warn in all_warnings:
43
+ print(f"WARNING: {warn}")
44
+ if not all_errors and not all_warnings:
45
+ print(f"Validated {len(map_results)} feature maps: all passed.")
46
+ elif not all_errors:
47
+ print(
48
+ f"Validated {len(map_results)} feature maps: "
49
+ f"{len(all_warnings)} warning(s), 0 error(s)."
50
+ )
51
+
52
+ exit_code = 0
53
+ if all_errors:
54
+ exit_code = 2 if strict else 1
55
+ elif strict and all_warnings:
56
+ exit_code = 2
57
+
58
+ result["exit_code"] = exit_code
59
+ return result, exit_code
feature_map/config.py ADDED
@@ -0,0 +1,24 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ DEFAULT_APPS = []
6
+ DEFAULT_REQUIRED_SECTIONS = ["purpose", "entry_points"]
7
+ DEFAULT_FEATURES_DIR = ".features"
8
+
9
+
10
+ def load_config(repo_root: Path) -> dict:
11
+ config_path = repo_root / ".feature-map.yaml"
12
+ config = {
13
+ "features_dir": DEFAULT_FEATURES_DIR,
14
+ "apps": list(DEFAULT_APPS),
15
+ "required_sections": list(DEFAULT_REQUIRED_SECTIONS),
16
+ }
17
+ if config_path.is_file():
18
+ with config_path.open(encoding="utf-8") as handle:
19
+ loaded = yaml.safe_load(handle) or {}
20
+ if isinstance(loaded, dict):
21
+ config.update(loaded)
22
+ if not isinstance(config.get("apps"), list):
23
+ config["apps"] = list(DEFAULT_APPS)
24
+ return config
@@ -0,0 +1,59 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from feature_map.errors import FeaturesNotFoundError
5
+
6
+
7
+ def _is_git_root(path: Path) -> bool:
8
+ return (path / ".git").exists()
9
+
10
+
11
+ def find_repo_root(start: Path) -> Path:
12
+ start = start.resolve()
13
+ try:
14
+ result = subprocess.run(
15
+ ["git", "rev-parse", "--show-toplevel"],
16
+ capture_output=True,
17
+ text=True,
18
+ check=True,
19
+ cwd=start,
20
+ )
21
+ toplevel = result.stdout.strip()
22
+ if toplevel:
23
+ return Path(toplevel)
24
+ except (subprocess.CalledProcessError, FileNotFoundError):
25
+ pass
26
+
27
+ current = start
28
+ while current != current.parent:
29
+ if _is_git_root(current):
30
+ return current
31
+ current = current.parent
32
+ return start
33
+
34
+
35
+ def find_features_dir(start: Path) -> Path:
36
+ start = start.resolve()
37
+ repo_root = find_repo_root(start)
38
+
39
+ if _is_git_root(repo_root):
40
+ root_candidate = repo_root / ".features"
41
+ if root_candidate.is_dir():
42
+ return root_candidate
43
+
44
+ current = start
45
+ while True:
46
+ candidate = current / ".features"
47
+ if candidate.is_dir():
48
+ return candidate
49
+ if _is_git_root(current):
50
+ break
51
+ if current.parent == current:
52
+ break
53
+ current = current.parent
54
+
55
+ root_candidate = repo_root / ".features"
56
+ if root_candidate.is_dir():
57
+ return root_candidate
58
+
59
+ raise FeaturesNotFoundError()
feature_map/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ class FeaturesNotFoundError(Exception):
2
+ def __init__(self, message=None, suggestion=None):
3
+ self.message = message or (
4
+ "No .features/ found. Run `feature-map init` from the repo root."
5
+ )
6
+ self.suggestion = suggestion or (
7
+ 'Run "feature-map init" to scaffold .features/, the agent skill, and a bin shim.'
8
+ )
9
+ super().__init__(self.message)
10
+
11
+
12
+ class CliError(Exception):
13
+ def __init__(self, message, suggestion=None, exit_code=1):
14
+ self.message = message
15
+ self.suggestion = suggestion
16
+ self.exit_code = exit_code
17
+ super().__init__(message)
feature_map/graph.py ADDED
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+ from typing import Dict, List, Optional, Set
3
+
4
+ import yaml
5
+
6
+ from feature_map.loader import extract_related_slug, list_map_files
7
+
8
+
9
+ def build_graph(features_dir: Path) -> Dict[str, List[str]]:
10
+ graph: Dict[str, List[str]] = {}
11
+ for path in list_map_files(features_dir):
12
+ slug = path.stem
13
+ graph.setdefault(slug, [])
14
+ try:
15
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
16
+ except yaml.YAMLError:
17
+ continue
18
+ related = data.get("related_features") or []
19
+ if not isinstance(related, list):
20
+ continue
21
+ for entry in related:
22
+ target = extract_related_slug(entry)
23
+ if target:
24
+ graph[slug].append(target)
25
+ return graph
26
+
27
+
28
+ def graph_data(
29
+ features_dir: Path,
30
+ root: Optional[str] = None,
31
+ ) -> dict:
32
+ full_graph = build_graph(features_dir)
33
+ nodes: Set[str] = set()
34
+ edges = []
35
+
36
+ if root:
37
+ root = root.replace(".yaml", "")
38
+ visited = set()
39
+ stack = [root]
40
+
41
+ def visit(node):
42
+ if node in visited:
43
+ return
44
+ visited.add(node)
45
+ nodes.add(node)
46
+ for target in full_graph.get(node, []):
47
+ edges.append({"from": node, "to": target})
48
+ visit(target)
49
+
50
+ visit(root)
51
+ for edge in edges:
52
+ nodes.add(edge["to"])
53
+ else:
54
+ nodes = set(full_graph.keys())
55
+ for source, targets in full_graph.items():
56
+ nodes.add(source)
57
+ for target in targets:
58
+ nodes.add(target)
59
+ edges.append({"from": source, "to": target})
60
+
61
+ return {
62
+ "nodes": sorted(nodes),
63
+ "edges": edges,
64
+ }
65
+
66
+
67
+ def format_mermaid(data: dict) -> str:
68
+ lines = ["graph LR"]
69
+ for edge in data.get("edges", []):
70
+ lines.append(f" {edge['from']} --> {edge['to']}")
71
+ return "\n".join(lines) + "\n"
72
+
73
+
74
+ def format_dot(data: dict) -> str:
75
+ lines = ["digraph feature_map {"]
76
+ for edge in data.get("edges", []):
77
+ lines.append(f' "{edge["from"]}" -> "{edge["to"]}";')
78
+ lines.append("}")
79
+ return "\n".join(lines) + "\n"
feature_map/loader.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import yaml
8
+
9
+ from feature_map.errors import CliError
10
+
11
+
12
+ def normalize_slug(name: str) -> str:
13
+ return name.lower().replace(" ", "_").replace("-", "_")
14
+
15
+
16
+ def list_map_files(features_dir: Path):
17
+ return sorted(features_dir.glob("*.yaml"))
18
+
19
+
20
+ def resolve_map_path(features_dir: Path, name: str) -> Path:
21
+ slug = normalize_slug(name)
22
+ exact = features_dir / f"{slug}.yaml"
23
+ if exact.exists():
24
+ return exact
25
+ fallback = features_dir / f"{name}.yaml"
26
+ if fallback.exists():
27
+ return fallback
28
+ for path in features_dir.glob("*.yaml"):
29
+ if path.stem == slug or path.stem == name:
30
+ return path
31
+ raise CliError(
32
+ f'Feature "{name}" not found.',
33
+ suggestion='Run "feature-map list" to see available features.',
34
+ )
35
+
36
+
37
+ def load_map(path: Path) -> dict:
38
+ text = path.read_text(encoding="utf-8")
39
+ try:
40
+ data = yaml.safe_load(text)
41
+ except yaml.YAMLError as exc:
42
+ raise CliError(f"Failed to parse {path.name}: {exc}") from exc
43
+ if not isinstance(data, dict):
44
+ raise CliError(f"Feature map {path.name} must be a YAML mapping.")
45
+ return data
46
+
47
+
48
+ def load_map_text(path: Path) -> str:
49
+ return path.read_text(encoding="utf-8")
50
+
51
+
52
+ def extract_related_slug(entry) -> Optional[str]:
53
+ if not isinstance(entry, str):
54
+ return None
55
+ match = re.match(r'^["\']?([a-zA-Z0-9_-]+)', entry.strip())
56
+ if not match:
57
+ return None
58
+ return normalize_slug(match.group(1))
59
+
60
+
61
+ def all_slugs(features_dir: Path) -> set[str]:
62
+ return {path.stem for path in list_map_files(features_dir)}
feature_map/output.py ADDED
@@ -0,0 +1,54 @@
1
+ import json
2
+ import sys
3
+
4
+ import yaml
5
+
6
+ from feature_map.errors import CliError, FeaturesNotFoundError
7
+
8
+
9
+ def emit(data, as_json=False, stream=None):
10
+ stream = stream or sys.stdout
11
+ if as_json:
12
+ json.dump(data, stream, indent=2, default=str)
13
+ stream.write("\n")
14
+ elif isinstance(data, str):
15
+ stream.write(data)
16
+ if not data.endswith("\n"):
17
+ stream.write("\n")
18
+ elif isinstance(data, dict) and "features" in data and len(data) == 1:
19
+ yaml.dump(data, stream, default_flow_style=False)
20
+ else:
21
+ yaml.dump(data, stream, default_flow_style=False, sort_keys=False)
22
+
23
+
24
+ def emit_error(error, as_json=False, stream=None):
25
+ stream = stream or (sys.stdout if as_json else sys.stderr)
26
+ if isinstance(error, CliError):
27
+ message = error.message
28
+ suggestion = error.suggestion
29
+ exit_code = error.exit_code
30
+ elif isinstance(error, FeaturesNotFoundError):
31
+ message = error.message
32
+ suggestion = error.suggestion
33
+ exit_code = 1
34
+ else:
35
+ message = str(error)
36
+ suggestion = None
37
+ exit_code = 1
38
+
39
+ if as_json:
40
+ payload = {
41
+ "ok": False,
42
+ "error": message,
43
+ "exit_code": exit_code,
44
+ }
45
+ if suggestion:
46
+ payload["suggestion"] = suggestion
47
+ json.dump(payload, stream, indent=2)
48
+ stream.write("\n")
49
+ else:
50
+ stream.write(f"Error: {message}\n")
51
+ if suggestion:
52
+ stream.write(f"Suggestion: {suggestion}\n")
53
+ return exit_code
54
+