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,3 @@
1
+ from feature_map._version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from feature_map.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,189 @@
1
+ """Phase 2 repo bootstrap: deploy skill, config, AGENTS.md, and optional shim."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from feature_map.paths import skill_dir as bundled_skill_dir
9
+
10
+ AGENTS_MARKER = "<!-- feature-map:start -->"
11
+ AGENTS_END = "<!-- feature-map:end -->"
12
+ LEGACY_AGENTS_MARKERS = (
13
+ ("<!-- featuremap:start -->", "<!-- featuremap:end -->"),
14
+ )
15
+
16
+ AGENTS_BLOCK = f"""{AGENTS_MARKER}
17
+ ## Feature Map
18
+
19
+ **ALWAYS** use Feature Map before feature work, debugging, PRDs, or plans.
20
+ Do not implement from a cold grep when a map exists.
21
+
22
+ ```bash
23
+ ./bin/feature-map list
24
+ ./bin/feature-map search <keyword>
25
+ ./bin/feature-map find <path-fragment>
26
+ ./bin/feature-map <feature-name>
27
+ ```
28
+
29
+ Maps in `.features/*.yaml` are the authoritative cross-app architecture source.
30
+ Keep them dense: fields, not essays.
31
+
32
+ If `list` is empty or search/find miss the area you are about to change,
33
+ **scour the existing code and author maps first** (skill:
34
+ `feature-map` → `references/existing-repos.md`). Cluster by user-visible
35
+ capability, not by file. Prefer `entry_points` that exist on disk, then
36
+ run `feature-map validate` and `feature-map check`.
37
+ {AGENTS_END}
38
+ """
39
+
40
+ SHIM_SCRIPT = """#!/usr/bin/env bash
41
+ set -euo pipefail
42
+ SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
43
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
44
+
45
+ if [ -x "$ROOT/.venv/bin/feature-map" ]; then
46
+ exec "$ROOT/.venv/bin/feature-map" "$@"
47
+ fi
48
+
49
+ # Global install, but not this shim (bin/ may be on PATH).
50
+ if command -v feature-map >/dev/null 2>&1; then
51
+ CAND="$(command -v feature-map)"
52
+ CAND_ABS="$(cd "$(dirname "$CAND")" && pwd)/$(basename "$CAND")"
53
+ if [ "$CAND_ABS" != "$SELF" ]; then
54
+ exec "$CAND" "$@"
55
+ fi
56
+ fi
57
+
58
+ if python3 -c "import feature_map" >/dev/null 2>&1; then
59
+ exec python3 -m feature_map "$@"
60
+ fi
61
+
62
+ echo "feature-map is not installed. Try: pip install feature-map-cli" >&2
63
+ exit 1
64
+ """
65
+
66
+ DEFAULT_CONFIG = """# Feature Map CLI configuration
67
+ features_dir: .features
68
+ apps: []
69
+ required_sections:
70
+ - purpose
71
+ - entry_points
72
+ min_cli_version: "1.0.0"
73
+ """
74
+
75
+
76
+ def detect_skill_parent(repo_root: Path) -> Path:
77
+ agents = repo_root / ".agents" / "skills"
78
+ grok = repo_root / ".grok" / "skills"
79
+ if grok.exists() and not agents.exists():
80
+ return grok
81
+ return agents
82
+
83
+
84
+ def copy_skill(dest_parent: Path, force: bool = False) -> Path:
85
+ source = bundled_skill_dir()
86
+ dest = dest_parent / "feature-map"
87
+ dest.mkdir(parents=True, exist_ok=True)
88
+
89
+ if not source.is_dir():
90
+ raise FileNotFoundError(f"Bundled skill not found at {source}")
91
+
92
+ for path in source.rglob("*"):
93
+ if not path.is_file():
94
+ continue
95
+ relative = path.relative_to(source)
96
+ target = dest / relative
97
+ if target.exists() and not force:
98
+ continue
99
+ target.parent.mkdir(parents=True, exist_ok=True)
100
+ shutil.copy2(path, target)
101
+ return dest
102
+
103
+
104
+ def write_config(repo_root: Path, force: bool = False) -> Path:
105
+ target = repo_root / ".feature-map.yaml"
106
+ if target.exists() and not force:
107
+ return target
108
+ target.write_text(DEFAULT_CONFIG, encoding="utf-8")
109
+ return target
110
+
111
+
112
+ def write_shim(repo_root: Path, force: bool = False) -> Path:
113
+ bin_dir = repo_root / "bin"
114
+ bin_dir.mkdir(parents=True, exist_ok=True)
115
+ target = bin_dir / "feature-map"
116
+ if target.exists() and not force:
117
+ return target
118
+ target.write_text(SHIM_SCRIPT, encoding="utf-8")
119
+ target.chmod(target.stat().st_mode | 0o111)
120
+ return target
121
+
122
+
123
+ def _replace_marked_block(text: str, block: str):
124
+ pairs = ((AGENTS_MARKER, AGENTS_END),) + LEGACY_AGENTS_MARKERS
125
+ for start_tok, end_tok in pairs:
126
+ if start_tok in text and end_tok in text:
127
+ start = text.index(start_tok)
128
+ end = text.index(end_tok) + len(end_tok)
129
+ new_text = text[:start] + block.rstrip() + text[end:]
130
+ if not new_text.endswith("\n"):
131
+ new_text += "\n"
132
+ return new_text
133
+ if start_tok in text:
134
+ return text
135
+ return None
136
+
137
+
138
+ def append_agents_snippet(repo_root: Path) -> Path:
139
+ target = repo_root / "AGENTS.md"
140
+ block = AGENTS_BLOCK.strip() + "\n"
141
+ if target.exists():
142
+ text = target.read_text(encoding="utf-8")
143
+ replaced = _replace_marked_block(text, block)
144
+ if replaced is not None:
145
+ if replaced != text:
146
+ target.write_text(replaced, encoding="utf-8")
147
+ return target
148
+ if not text.endswith("\n"):
149
+ text += "\n"
150
+ target.write_text(text + "\n" + block, encoding="utf-8")
151
+ else:
152
+ target.write_text("# Agent instructions\n\n" + block, encoding="utf-8")
153
+ return target
154
+
155
+
156
+ def ensure_features_dir(repo_root: Path, features_dir_name: str = ".features") -> Path:
157
+ features_dir = repo_root / features_dir_name
158
+ features_dir.mkdir(parents=True, exist_ok=True)
159
+ gitkeep = features_dir / ".gitkeep"
160
+ if not any(features_dir.glob("*.yaml")) and not gitkeep.exists():
161
+ gitkeep.write_text("", encoding="utf-8")
162
+ return features_dir
163
+
164
+
165
+ def bootstrap_repo(
166
+ repo_root: Path,
167
+ *,
168
+ upgrade_skill: bool = False,
169
+ agents: bool = True,
170
+ shim: bool = True,
171
+ force: bool = False,
172
+ ) -> dict:
173
+ features_dir = ensure_features_dir(repo_root)
174
+ skill_parent = detect_skill_parent(repo_root)
175
+ skill_path = copy_skill(skill_parent, force=upgrade_skill or force)
176
+ config_path = write_config(repo_root, force=False)
177
+ shim_path = write_shim(repo_root, force=force) if shim else None
178
+ agents_path = append_agents_snippet(repo_root) if agents else None
179
+
180
+ return {
181
+ "ok": True,
182
+ "repo_root": str(repo_root),
183
+ "features_dir": str(features_dir),
184
+ "skill": str(skill_path),
185
+ "config": str(config_path),
186
+ "shim": str(shim_path) if shim_path else None,
187
+ "agents": str(agents_path) if agents_path else None,
188
+ "upgraded_skill": bool(upgrade_skill or force),
189
+ }
feature_map/cli.py ADDED
@@ -0,0 +1,273 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from feature_map._version import __version__
6
+ from feature_map.commands.check_cmd import run_check
7
+ from feature_map.commands.find_cmd import run_find
8
+ from feature_map.commands.graph_cmd import run_graph
9
+ from feature_map.commands.impact_cmd import run_impact
10
+ from feature_map.commands.init_cmd import run_bootstrap, run_init_map
11
+ from feature_map.commands.install_cmd import run_install
12
+ from feature_map.commands.list_cmd import run_list
13
+ from feature_map.commands.search_cmd import run_search
14
+ from feature_map.commands.show_cmd import run_show
15
+ from feature_map.commands.stats_cmd import run_stats
16
+ from feature_map.commands.validate_cmd import run_validate
17
+ from feature_map.config import load_config
18
+ from feature_map.discover import find_features_dir, find_repo_root
19
+ from feature_map.errors import CliError, FeaturesNotFoundError
20
+ from feature_map.output import emit, emit_error
21
+
22
+ COMMANDS = {
23
+ "list",
24
+ "show",
25
+ "search",
26
+ "find",
27
+ "graph",
28
+ "validate",
29
+ "check",
30
+ "impact",
31
+ "stats",
32
+ "init",
33
+ "install",
34
+ }
35
+
36
+ OPTIONAL_FEATURES_COMMANDS = {"init", "install"}
37
+
38
+
39
+ def build_parser():
40
+ parser = argparse.ArgumentParser(
41
+ prog="feature-map",
42
+ description="Feature Map CLI — cross-app architecture research tool",
43
+ )
44
+ parser.add_argument("--json", action="store_true", help="Machine-readable JSON output")
45
+ parser.add_argument("--version", action="version", version=__version__)
46
+
47
+ subparsers = parser.add_subparsers(dest="command")
48
+
49
+ subparsers.add_parser("list", help="List all feature slugs")
50
+ subparsers.add_parser("install", help="Verify install and repo setup")
51
+
52
+ show_parser = subparsers.add_parser("show", help="Show a feature map")
53
+ show_parser.add_argument("name", help="Feature slug")
54
+ show_parser.add_argument("--section", help="Show a single top-level section")
55
+
56
+ search_parser = subparsers.add_parser("search", help="Full-text search across maps")
57
+ search_parser.add_argument("query", help="Search query")
58
+
59
+ find_parser = subparsers.add_parser("find", help="Find maps referencing a path fragment")
60
+ find_parser.add_argument("fragment", help="Path or file fragment")
61
+
62
+ graph_parser = subparsers.add_parser("graph", help="Related-features graph")
63
+ graph_parser.add_argument("name", nargs="?", help="Optional root feature for subgraph")
64
+ graph_parser.add_argument(
65
+ "--format",
66
+ choices=["mermaid", "json", "dot"],
67
+ default="mermaid",
68
+ help="Output format",
69
+ )
70
+
71
+ validate_parser = subparsers.add_parser("validate", help="Validate feature maps")
72
+ validate_parser.add_argument(
73
+ "--strict",
74
+ action="store_true",
75
+ help="Treat warnings as failures (exit 2)",
76
+ )
77
+
78
+ subparsers.add_parser("check", help="Check entry-point path staleness")
79
+
80
+ impact_parser = subparsers.add_parser("impact", help="Features referencing a file")
81
+ impact_parser.add_argument("file", help="File path fragment")
82
+ impact_parser.add_argument(
83
+ "--transitive",
84
+ action="store_true",
85
+ help="Include transitive related_features",
86
+ )
87
+
88
+ subparsers.add_parser("stats", help="Coverage statistics")
89
+
90
+ init_parser = subparsers.add_parser(
91
+ "init",
92
+ help="Bootstrap this repo, or scaffold a feature map when <name> is given",
93
+ )
94
+ init_parser.add_argument(
95
+ "name",
96
+ nargs="?",
97
+ help="Feature slug to scaffold (omit to bootstrap the repo)",
98
+ )
99
+ init_parser.add_argument("--force", action="store_true", help="Overwrite existing files")
100
+ init_parser.add_argument(
101
+ "--upgrade-skill",
102
+ action="store_true",
103
+ help="Refresh the agent skill from this package version",
104
+ )
105
+ init_parser.add_argument(
106
+ "--no-agents",
107
+ dest="agents",
108
+ action="store_false",
109
+ help="Do not write or update AGENTS.md",
110
+ )
111
+ init_parser.add_argument(
112
+ "--no-shim",
113
+ dest="shim",
114
+ action="store_false",
115
+ help="Do not write bin/feature-map",
116
+ )
117
+ init_parser.set_defaults(agents=True, shim=True)
118
+
119
+ parser.add_argument(
120
+ "bare_name",
121
+ nargs="?",
122
+ help="Feature name (show alias when no subcommand)",
123
+ )
124
+
125
+ return parser
126
+
127
+
128
+ def resolve_context(optional=False):
129
+ start = Path.cwd()
130
+ repo_root = find_repo_root(start)
131
+ config = load_config(repo_root)
132
+ try:
133
+ features_dir = find_features_dir(start)
134
+ except FeaturesNotFoundError:
135
+ if optional:
136
+ features_dir = repo_root / config.get("features_dir", ".features")
137
+ else:
138
+ raise
139
+ return features_dir, repo_root, config
140
+
141
+
142
+ def dispatch(args):
143
+ command = args.command
144
+ as_json = args.json
145
+ optional = command in OPTIONAL_FEATURES_COMMANDS
146
+ features_dir, repo_root, config = resolve_context(optional=optional)
147
+
148
+ if command is None and args.bare_name:
149
+ if args.bare_name in COMMANDS:
150
+ parser = build_parser()
151
+ parser.error(f'command "{args.bare_name}" requires explicit subcommand syntax')
152
+ command = "show"
153
+ args.name = args.bare_name
154
+
155
+ if command == "list":
156
+ return 0, run_list(features_dir, as_json=as_json)
157
+
158
+ if command == "install":
159
+ return 0, run_install(repo_root, as_json=as_json)
160
+
161
+ if command == "show":
162
+ return 0, run_show(features_dir, args.name, section=args.section, as_json=as_json)
163
+
164
+ if command == "search":
165
+ return 0, run_search(features_dir, args.query, as_json=as_json)
166
+
167
+ if command == "find":
168
+ return 0, run_find(features_dir, args.fragment, as_json=as_json)
169
+
170
+ if command == "graph":
171
+ result = run_graph(
172
+ features_dir,
173
+ name=getattr(args, "name", None),
174
+ fmt=getattr(args, "format", "mermaid"),
175
+ as_json=as_json,
176
+ )
177
+ return 0, result
178
+
179
+ if command == "validate":
180
+ result, exit_code = run_validate(features_dir, strict=args.strict, as_json=as_json)
181
+ return exit_code, result
182
+
183
+ if command == "check":
184
+ return 0, run_check(features_dir, repo_root, config.get("apps", []), as_json=as_json)
185
+
186
+ if command == "impact":
187
+ return 0, run_impact(
188
+ features_dir,
189
+ args.file,
190
+ transitive=args.transitive,
191
+ as_json=as_json,
192
+ )
193
+
194
+ if command == "stats":
195
+ return 0, run_stats(features_dir, as_json=as_json)
196
+
197
+ if command == "init":
198
+ if getattr(args, "name", None):
199
+ return 0, run_init_map(
200
+ features_dir,
201
+ args.name,
202
+ force=args.force,
203
+ as_json=as_json,
204
+ )
205
+ return 0, run_bootstrap(
206
+ repo_root,
207
+ upgrade_skill=args.upgrade_skill,
208
+ agents=args.agents,
209
+ shim=args.shim,
210
+ force=args.force,
211
+ as_json=as_json,
212
+ )
213
+
214
+ parser = build_parser()
215
+ parser.print_help()
216
+ return 1, None
217
+
218
+
219
+ def preprocess_argv(argv):
220
+ argv = list(argv)
221
+ if not argv:
222
+ return argv
223
+
224
+ json_flag = False
225
+ cleaned = []
226
+ for arg in argv:
227
+ if arg == "--json":
228
+ json_flag = True
229
+ else:
230
+ cleaned.append(arg)
231
+ if json_flag:
232
+ cleaned.insert(0, "--json")
233
+ argv = cleaned
234
+
235
+ idx = 0
236
+ while idx < len(argv) and argv[idx].startswith("-"):
237
+ idx += 1
238
+ if idx < len(argv) and argv[idx] not in COMMANDS:
239
+ argv.insert(idx, "show")
240
+ return argv
241
+
242
+
243
+ def main(argv=None):
244
+ argv = preprocess_argv(argv or sys.argv[1:])
245
+ parser = build_parser()
246
+ args = parser.parse_args(argv)
247
+ as_json = args.json
248
+
249
+ if args.command is None and not args.bare_name:
250
+ parser.print_help()
251
+ return 1
252
+
253
+ try:
254
+ exit_code, result = dispatch(args)
255
+ if result is not None:
256
+ graph_json = (
257
+ args.command == "graph" and getattr(args, "format", None) == "json"
258
+ )
259
+ if as_json or graph_json:
260
+ emit(result, as_json=True)
261
+ elif isinstance(result, str):
262
+ sys.stdout.write(result)
263
+ if not result.endswith("\n"):
264
+ sys.stdout.write("\n")
265
+ elif args.command == "list":
266
+ emit(result, as_json=False)
267
+ return exit_code
268
+ except (CliError, FeaturesNotFoundError) as exc:
269
+ return emit_error(exc, as_json=as_json)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ sys.exit(main())
File without changes
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from feature_map.path_extract import check_paths
4
+
5
+
6
+ def run_check(features_dir: Path, repo_root: Path, apps, as_json: bool = False):
7
+ issues = check_paths(features_dir, repo_root, apps)
8
+ payload = {
9
+ "ok": True,
10
+ "issue_count": len(issues),
11
+ "issues": issues,
12
+ }
13
+
14
+ if as_json:
15
+ return payload
16
+
17
+ if not issues:
18
+ print("No stale paths detected.")
19
+ return payload
20
+
21
+ for issue in issues:
22
+ print(f"{issue['feature']}: missing {issue['path']}")
23
+ return payload
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+ from feature_map.loader import list_map_files
4
+
5
+
6
+ def run_find(features_dir: Path, fragment: str, as_json: bool = False):
7
+ results = []
8
+ fragment_lower = fragment.lower()
9
+
10
+ for path in list_map_files(features_dir):
11
+ matches = []
12
+ for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
13
+ if fragment_lower in line.lower():
14
+ matches.append({"line": line_no, "text": line.strip()})
15
+ if matches:
16
+ results.append({"feature": path.stem, "matches": matches})
17
+
18
+ payload = {
19
+ "ok": True,
20
+ "fragment": fragment,
21
+ "count": len(results),
22
+ "results": results,
23
+ }
24
+
25
+ if as_json:
26
+ return payload
27
+
28
+ if not results:
29
+ print(f"No features reference '{fragment}'.")
30
+ return payload
31
+
32
+ for item in results:
33
+ print(item["feature"])
34
+ for match in item["matches"][:5]:
35
+ print(f" L{match['line']}: {match['text']}")
36
+ return payload
@@ -0,0 +1,18 @@
1
+ from feature_map.graph import format_dot, format_mermaid, graph_data
2
+
3
+
4
+ def run_graph(features_dir, name=None, fmt="mermaid", as_json=False):
5
+ data = graph_data(features_dir, root=name)
6
+
7
+ if fmt == "json" or as_json:
8
+ return {"ok": True, **data}
9
+
10
+ if fmt == "dot":
11
+ return format_dot(data)
12
+
13
+ if fmt == "mermaid":
14
+ return format_mermaid(data)
15
+
16
+ from feature_map.errors import CliError
17
+
18
+ raise CliError(f'Unknown graph format "{fmt}".', suggestion="Use mermaid, json, or dot.")
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+
3
+ from feature_map.graph import build_graph
4
+ from feature_map.loader import list_map_files
5
+
6
+
7
+ def run_impact(
8
+ features_dir: Path,
9
+ file_fragment: str,
10
+ transitive: bool = False,
11
+ as_json: bool = False,
12
+ ):
13
+ fragment_lower = file_fragment.lower()
14
+ direct = []
15
+
16
+ for path in list_map_files(features_dir):
17
+ text = path.read_text(encoding="utf-8")
18
+ if fragment_lower in text.lower():
19
+ direct.append(path.stem)
20
+
21
+ affected = set(direct)
22
+ if transitive:
23
+ graph = build_graph(features_dir)
24
+ reverse = {}
25
+ for source, targets in graph.items():
26
+ for target in targets:
27
+ reverse.setdefault(target, []).append(source)
28
+
29
+ stack = list(direct)
30
+ while stack:
31
+ node = stack.pop()
32
+ for parent in reverse.get(node, []):
33
+ if parent not in affected:
34
+ affected.add(parent)
35
+ stack.append(parent)
36
+
37
+ results = sorted(affected)
38
+ payload = {
39
+ "ok": True,
40
+ "file": file_fragment,
41
+ "transitive": transitive,
42
+ "count": len(results),
43
+ "features": results,
44
+ }
45
+
46
+ if as_json:
47
+ return payload
48
+
49
+ if not results:
50
+ print(f"No features reference '{file_fragment}'.")
51
+ return payload
52
+
53
+ for feature in results:
54
+ print(feature)
55
+ return payload
@@ -0,0 +1,72 @@
1
+ from pathlib import Path
2
+
3
+ from feature_map.bootstrap import bootstrap_repo, ensure_features_dir
4
+ from feature_map.errors import CliError
5
+ from feature_map.loader import normalize_slug
6
+ from feature_map.paths import template_path
7
+
8
+
9
+ def run_init_map(features_dir: Path, name: str, force: bool = False, as_json: bool = False):
10
+ slug = normalize_slug(name)
11
+ if not slug:
12
+ raise CliError(f'Invalid feature name "{name}".')
13
+
14
+ features_dir.mkdir(parents=True, exist_ok=True)
15
+ target = features_dir / f"{slug}.yaml"
16
+ if target.exists() and not force:
17
+ raise CliError(
18
+ f"Feature map already exists: {target.name}",
19
+ suggestion="Use --force to overwrite.",
20
+ )
21
+
22
+ tpl = template_path()
23
+ if not tpl.is_file():
24
+ raise CliError("Template not found in the feature-map package.")
25
+
26
+ content = tpl.read_text(encoding="utf-8")
27
+ content = content.replace("{{feature_name}}", slug)
28
+ content = content.replace("{{FEATURE_TITLE}}", name.replace("_", " ").title())
29
+
30
+ target.write_text(content, encoding="utf-8")
31
+ payload = {"ok": True, "feature": slug, "path": str(target)}
32
+
33
+ if as_json:
34
+ return payload
35
+
36
+ print(f"Created {target}")
37
+ return payload
38
+
39
+
40
+ def run_bootstrap(
41
+ repo_root: Path,
42
+ *,
43
+ upgrade_skill: bool = False,
44
+ agents: bool = True,
45
+ shim: bool = True,
46
+ force: bool = False,
47
+ as_json: bool = False,
48
+ ):
49
+ payload = bootstrap_repo(
50
+ repo_root,
51
+ upgrade_skill=upgrade_skill,
52
+ agents=agents,
53
+ shim=shim,
54
+ force=force,
55
+ )
56
+ if as_json:
57
+ return payload
58
+
59
+ print("Initialized Feature Map in this repository:")
60
+ print(f" .features/: {payload['features_dir']}")
61
+ print(f" skill: {payload['skill']}")
62
+ print(f" config: {payload['config']}")
63
+ if payload.get("shim"):
64
+ print(f" shim: {payload['shim']}")
65
+ if payload.get("agents"):
66
+ print(f" AGENTS.md: {payload['agents']}")
67
+ return payload
68
+
69
+
70
+ # Backward-compatible alias used by older call sites / tests
71
+ def run_init(features_dir: Path, name: str, force: bool = False, as_json: bool = False):
72
+ return run_init_map(features_dir, name, force=force, as_json=as_json)