mnemo-dev 0.1.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.
mnemo/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Mnemo – Persistent memory and repo map for Amazon Q chats."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,108 @@
1
+ """Roslyn analyzer bridge — uses .NET SDK when available for richer C# analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ _ANALYZER_DIR = Path(__file__).parent.parent.parent / "analyzers" / "roslyn"
12
+
13
+
14
+ def dotnet_available() -> bool:
15
+ """Check if .NET SDK is installed."""
16
+ return shutil.which("dotnet") is not None
17
+
18
+
19
+ def _find_solution_or_project(repo_root: Path) -> Path | None:
20
+ """Find .sln or .csproj in the repo."""
21
+ slns = list(repo_root.glob("*.sln"))
22
+ if slns:
23
+ return slns[0]
24
+ csprojs = list(repo_root.rglob("*.csproj"))
25
+ if csprojs:
26
+ return repo_root
27
+ return None
28
+
29
+
30
+ def roslyn_available(repo_root: Path) -> bool:
31
+ """Check if Roslyn analysis is possible for this repo."""
32
+ if not dotnet_available():
33
+ return False
34
+ return _find_solution_or_project(repo_root) is not None
35
+
36
+
37
+ def run_roslyn_analyzer(repo_root: Path) -> list[dict[str, Any]] | None:
38
+ """Run the Roslyn analyzer and return parsed results."""
39
+ if not dotnet_available():
40
+ return None
41
+
42
+ target = _find_solution_or_project(repo_root)
43
+ if target is None:
44
+ return None
45
+
46
+ try:
47
+ result = subprocess.run(
48
+ ["dotnet", "run", "--project", str(_ANALYZER_DIR), "--", str(target)],
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=120,
52
+ cwd=str(repo_root),
53
+ )
54
+ if result.returncode != 0:
55
+ return None
56
+ # stdout may have warnings before JSON — find the JSON array
57
+ stdout = result.stdout.strip()
58
+ json_start = stdout.find("[")
59
+ if json_start == -1:
60
+ return None
61
+ return json.loads(stdout[json_start:])
62
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
63
+ return None
64
+
65
+
66
+ def roslyn_to_mnemo_format(roslyn_results: list[dict[str, Any]], repo_root: Path) -> dict[str, dict[str, Any]]:
67
+ """Convert Roslyn JSON output to the same format tree-sitter extractors return.
68
+
69
+ Returns: {relative_file_path: {"imports": [...], "classes": [...], "functions": [...]}}
70
+ """
71
+ output: dict[str, dict[str, Any]] = {}
72
+
73
+ for file_entry in roslyn_results:
74
+ filepath = file_entry.get("file", "")
75
+ if not filepath:
76
+ continue
77
+
78
+ # Make path relative to repo root
79
+ try:
80
+ rel = str(Path(filepath).relative_to(repo_root))
81
+ except ValueError:
82
+ rel = filepath
83
+
84
+ info: dict[str, Any] = {}
85
+
86
+ imports = file_entry.get("imports", [])
87
+ if imports:
88
+ info["imports"] = imports
89
+
90
+ classes = file_entry.get("classes", [])
91
+ if classes:
92
+ info["classes"] = []
93
+ for cls in classes:
94
+ entry: dict[str, Any] = {"name": cls["name"]}
95
+ if cls.get("implements"):
96
+ entry["implements"] = cls["implements"]
97
+ if cls.get("methods"):
98
+ entry["methods"] = cls["methods"]
99
+ info["classes"].append(entry)
100
+
101
+ functions = file_entry.get("functions", [])
102
+ if functions:
103
+ info["functions"] = functions
104
+
105
+ if info:
106
+ output[rel] = info
107
+
108
+ return output
@@ -0,0 +1,248 @@
1
+ """API Discovery — parse OpenAPI/Swagger specs and service endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..chunking import api_endpoint_chunk
10
+ from ..config import IGNORE_DIRS, mnemo_path
11
+ from ..retrieval import index_chunks, semantic_query
12
+
13
+
14
+ def _should_ignore(path: Path) -> bool:
15
+ return any(part in IGNORE_DIRS for part in path.parts)
16
+
17
+
18
+ def _find_openapi_specs(repo_root: Path) -> list[Path]:
19
+ """Find OpenAPI/Swagger spec files."""
20
+ specs = []
21
+ patterns = ["swagger.json", "openapi.json", "swagger.yaml", "openapi.yaml"]
22
+
23
+ for pattern in patterns:
24
+ for f in repo_root.rglob(pattern):
25
+ if not _should_ignore(f):
26
+ specs.append(f)
27
+
28
+ # Also check for specs in common locations
29
+ for candidate in [
30
+ repo_root / "docs" / "api",
31
+ repo_root / "api",
32
+ repo_root / "specs",
33
+ ]:
34
+ if candidate.exists():
35
+ for f in candidate.rglob("*.json"):
36
+ try:
37
+ data = json.loads(f.read_text())
38
+ if "openapi" in data or "swagger" in data:
39
+ specs.append(f)
40
+ except (json.JSONDecodeError, OSError):
41
+ pass
42
+
43
+ return specs
44
+
45
+
46
+ def _parse_openapi(spec_path: Path) -> dict[str, Any] | None:
47
+ """Parse an OpenAPI spec into a compact summary."""
48
+ try:
49
+ content = spec_path.read_text()
50
+ if spec_path.suffix in (".yaml", ".yml"):
51
+ try:
52
+ import yaml
53
+ data = yaml.safe_load(content)
54
+ except ImportError:
55
+ return None
56
+ else:
57
+ data = json.loads(content)
58
+ except (json.JSONDecodeError, OSError):
59
+ return None
60
+
61
+ if not data or not isinstance(data, dict):
62
+ return None
63
+
64
+ info = data.get("info", {})
65
+ paths = data.get("paths", {})
66
+
67
+ endpoints = []
68
+ for path, methods in paths.items():
69
+ for method, details in methods.items():
70
+ if method in ("get", "post", "put", "delete", "patch"):
71
+ endpoint = {
72
+ "method": method.upper(),
73
+ "path": path,
74
+ "summary": details.get("summary", details.get("operationId", "")),
75
+ }
76
+ # Get request body schema name
77
+ req_body = details.get("requestBody", {})
78
+ if req_body:
79
+ content_types = req_body.get("content", {})
80
+ for ct, schema_info in content_types.items():
81
+ ref = schema_info.get("schema", {}).get("$ref", "")
82
+ if ref:
83
+ endpoint["request_schema"] = ref.split("/")[-1]
84
+ break
85
+
86
+ # Get response schema
87
+ responses = details.get("responses", {})
88
+ for code, resp in responses.items():
89
+ if code.startswith("2"):
90
+ resp_content = resp.get("content", {})
91
+ for ct, schema_info in resp_content.items():
92
+ ref = schema_info.get("schema", {}).get("$ref", "")
93
+ if ref:
94
+ endpoint["response_schema"] = ref.split("/")[-1]
95
+ break
96
+ break
97
+
98
+ endpoints.append(endpoint)
99
+
100
+ return {
101
+ "title": info.get("title", spec_path.stem),
102
+ "version": info.get("version", ""),
103
+ "base_url": (data.get("servers", [{}])[0].get("url", "") if data.get("servers") else ""),
104
+ "endpoints": endpoints,
105
+ }
106
+
107
+
108
+ def _detect_endpoints_from_controllers(repo_root: Path) -> list[dict[str, Any]]:
109
+ """Detect API endpoints from controller attributes in .NET code."""
110
+ import re
111
+ endpoints = []
112
+
113
+ for cs_file in repo_root.rglob("*Controller.cs"):
114
+ if _should_ignore(cs_file):
115
+ continue
116
+ try:
117
+ content = cs_file.read_text(errors="replace")
118
+ except (OSError, PermissionError):
119
+ continue
120
+
121
+ # Skip test files
122
+ if "Tests" in str(cs_file):
123
+ continue
124
+
125
+ service = cs_file.relative_to(repo_root).parts[0]
126
+
127
+ # Get route prefix from class
128
+ class_route = ""
129
+ route_match = re.search(r'\[Route\("([^"]+)"\)\]', content)
130
+ if route_match:
131
+ class_route = route_match.group(1)
132
+
133
+ # Find HTTP method attributes (flexible whitespace matching)
134
+ for match in re.finditer(
135
+ r'\[(Http(Get|Post|Put|Delete|Patch))(?:\("([^"]*)"\))?\]\s*(?:\[.*?\]\s*)*public\s+(?:async\s+)?\S+\s+(\w+)',
136
+ content, re.DOTALL
137
+ ):
138
+ method = match.group(2).upper()
139
+ route = match.group(3) or ""
140
+ func_name = match.group(4)
141
+ full_path = f"/{class_route}/{route}".replace("//", "/").rstrip("/")
142
+ full_path = re.sub(r'\[controller\]', cs_file.stem.replace('Controller', '').lower(), full_path)
143
+ endpoints.append({
144
+ "service": service,
145
+ "method": method,
146
+ "path": full_path,
147
+ "handler": func_name,
148
+ })
149
+
150
+ return endpoints
151
+
152
+
153
+ def discover_apis(repo_root: Path) -> str:
154
+ """Discover all APIs in the repo and return as markdown."""
155
+ lines = ["# API Discovery\n"]
156
+
157
+ endpoint_chunks = []
158
+
159
+ # Try OpenAPI specs first
160
+ specs = _find_openapi_specs(repo_root)
161
+ if specs:
162
+ lines.append("## OpenAPI Specifications")
163
+ for spec_path in specs:
164
+ parsed = _parse_openapi(spec_path)
165
+ if parsed:
166
+ lines.append(f"\n### {parsed['title']} (v{parsed['version']})")
167
+ if parsed['base_url']:
168
+ lines.append(f"Base URL: `{parsed['base_url']}`")
169
+ lines.append("")
170
+ for ep in parsed["endpoints"]:
171
+ req = f" ← {ep['request_schema']}" if ep.get("request_schema") else ""
172
+ resp = f" → {ep['response_schema']}" if ep.get("response_schema") else ""
173
+ lines.append(f"- `{ep['method']} {ep['path']}` {ep['summary']}{req}{resp}")
174
+ endpoint_chunks.append(
175
+ api_endpoint_chunk(
176
+ path=str(spec_path.relative_to(repo_root)),
177
+ method=ep["method"],
178
+ endpoint=ep["path"],
179
+ summary=ep.get("summary", ""),
180
+ )
181
+ )
182
+ lines.append("")
183
+
184
+ # Detect from controllers
185
+ controller_endpoints = _detect_endpoints_from_controllers(repo_root)
186
+ if controller_endpoints:
187
+ lines.append("## Controller Endpoints")
188
+ # Group by service
189
+ by_service: dict[str, list] = {}
190
+ for ep in controller_endpoints:
191
+ svc = ep["service"]
192
+ if svc not in by_service:
193
+ by_service[svc] = []
194
+ by_service[svc].append(ep)
195
+
196
+ for svc in sorted(by_service.keys()):
197
+ lines.append(f"\n### {svc}")
198
+ for ep in by_service[svc]:
199
+ lines.append(f"- `{ep['method']} {ep['path']}` → {ep['handler']}()")
200
+ endpoint_chunks.append(
201
+ api_endpoint_chunk(
202
+ path=f"{svc}/controller",
203
+ method=ep["method"],
204
+ endpoint=ep["path"],
205
+ summary=f"Handler: {ep['handler']}",
206
+ service=svc,
207
+ )
208
+ )
209
+ lines.append("")
210
+
211
+ if endpoint_chunks:
212
+ index_chunks(repo_root, "api", endpoint_chunks)
213
+
214
+ if len(lines) <= 2:
215
+ return "No APIs discovered. Add OpenAPI specs or check controller annotations."
216
+
217
+ return "\n".join(lines)
218
+
219
+
220
+ def search_api(repo_root: Path, query: str) -> str:
221
+ """Search for a specific API endpoint or schema."""
222
+ full_report = discover_apis(repo_root)
223
+ semantic_results = semantic_query(repo_root, "api", query, limit=8)
224
+ if semantic_results:
225
+ lines = [f"# API Search: '{query}'\n", "## Semantic Matches"]
226
+ for result in semantic_results:
227
+ meta = result.get("metadata", {})
228
+ lines.append(f"- `{meta.get('symbol', '')}` ({meta.get('path', '')})")
229
+ lines.append(f" {result.get('content', '')[:240]}")
230
+ return "\n".join(lines)
231
+ query_lower = query.lower()
232
+
233
+ # Filter lines that match
234
+ lines = full_report.splitlines()
235
+ results = []
236
+ current_section = ""
237
+ for line in lines:
238
+ if line.startswith("#"):
239
+ current_section = line
240
+ if query_lower in line.lower():
241
+ if current_section and current_section not in results:
242
+ results.append(current_section)
243
+ results.append(line)
244
+
245
+ if not results:
246
+ return f"No API endpoints matching '{query}'. Run full discovery with mnemo_discover_apis."
247
+
248
+ return "\n".join(results)
mnemo/chunking.py ADDED
@@ -0,0 +1,136 @@
1
+ """Chunk schema and builders for semantic indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Chunk:
13
+ """Canonical chunk used for local semantic retrieval."""
14
+
15
+ id: str
16
+ chunk_type: str
17
+ path: str
18
+ language: str
19
+ symbol: str
20
+ content: str
21
+ hash: str
22
+ metadata: dict[str, Any]
23
+
24
+
25
+ def _hash(text: str) -> str:
26
+ return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:16]
27
+
28
+
29
+ def _chunk_id(chunk_type: str, path: str, symbol: str) -> str:
30
+ raw = f"{chunk_type}:{path}:{symbol}"
31
+ return hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:24]
32
+
33
+
34
+ def make_code_chunks(path: str, language: str, info: dict[str, Any]) -> list[Chunk]:
35
+ """Build code chunks from parsed file info."""
36
+ chunks: list[Chunk] = []
37
+
38
+ for cls in info.get("classes", []):
39
+ name = str(cls.get("name", "UnknownClass"))
40
+ methods = cls.get("methods", [])
41
+ methods_text = "\n".join(f"- {method}" for method in methods)
42
+ content = f"class {name}\n{methods_text}".strip()
43
+ chunks.append(
44
+ Chunk(
45
+ id=_chunk_id("code", path, f"class:{name}"),
46
+ chunk_type="code",
47
+ path=path,
48
+ language=language,
49
+ symbol=name,
50
+ content=content,
51
+ hash=_hash(content),
52
+ metadata={"kind": "class", "method_count": len(methods)},
53
+ )
54
+ )
55
+
56
+ seen_ids: set[str] = set()
57
+ for fn in info.get("functions", []):
58
+ text = str(fn)
59
+ symbol = text.split("(")[0].replace("def ", "").strip()
60
+ chunk_id = _chunk_id("code", path, f"function:{symbol}:{_hash(text)}")
61
+ if chunk_id in seen_ids:
62
+ continue
63
+ seen_ids.add(chunk_id)
64
+ chunks.append(
65
+ Chunk(
66
+ id=chunk_id,
67
+ chunk_type="code",
68
+ path=path,
69
+ language=language,
70
+ symbol=symbol or "function",
71
+ content=text,
72
+ hash=_hash(text),
73
+ metadata={"kind": "function"},
74
+ )
75
+ )
76
+ return chunks
77
+
78
+
79
+ def markdown_heading_chunks(base_dir: Path, file_path: Path) -> list[Chunk]:
80
+ """Split a markdown file into heading-based chunks."""
81
+ try:
82
+ content = file_path.read_text(encoding="utf-8", errors="replace")
83
+ except OSError:
84
+ return []
85
+
86
+ rel_path = str(file_path.relative_to(base_dir))
87
+ lines = content.splitlines()
88
+ sections: list[tuple[str, list[str]]] = []
89
+ current_heading = "Document"
90
+ current_lines: list[str] = []
91
+
92
+ for line in lines:
93
+ if line.startswith("#"):
94
+ if current_lines:
95
+ sections.append((current_heading, current_lines))
96
+ current_heading = line.lstrip("#").strip() or "Section"
97
+ current_lines = [line]
98
+ else:
99
+ current_lines.append(line)
100
+ if current_lines:
101
+ sections.append((current_heading, current_lines))
102
+
103
+ chunks: list[Chunk] = []
104
+ for heading, body_lines in sections:
105
+ text = "\n".join(body_lines).strip()
106
+ if not text:
107
+ continue
108
+ chunks.append(
109
+ Chunk(
110
+ id=_chunk_id("knowledge", rel_path, heading),
111
+ chunk_type="knowledge",
112
+ path=rel_path,
113
+ language="markdown",
114
+ symbol=heading,
115
+ content=text,
116
+ hash=_hash(text),
117
+ metadata={"kind": "heading"},
118
+ )
119
+ )
120
+ return chunks
121
+
122
+
123
+ def api_endpoint_chunk(path: str, method: str, endpoint: str, summary: str, service: str = "") -> Chunk:
124
+ """Build a chunk from an API endpoint."""
125
+ symbol = f"{method.upper()} {endpoint}"
126
+ content = f"{symbol}\n{summary}".strip()
127
+ return Chunk(
128
+ id=_chunk_id("api", path, symbol),
129
+ chunk_type="api",
130
+ path=path,
131
+ language="http",
132
+ symbol=symbol,
133
+ content=content,
134
+ hash=_hash(content),
135
+ metadata={"kind": "endpoint", "service": service},
136
+ )
mnemo/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """Mnemo CLI - persistent memory for AI coding chats."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from .clients import CLIENT_CHOICES, DEFAULT_CLIENT
8
+
9
+
10
+ @click.group()
11
+ def cli():
12
+ """Mnemo - persistent memory and repo map for AI coding assistants."""
13
+ pass
14
+
15
+
16
+ @cli.command()
17
+ @click.option(
18
+ "--client",
19
+ "-c",
20
+ default=DEFAULT_CLIENT,
21
+ type=click.Choice(CLIENT_CHOICES),
22
+ show_default=True,
23
+ help="AI client to configure.",
24
+ )
25
+ @click.argument("path", default=".", type=click.Path(exists=True))
26
+ def init(path: str, client: str):
27
+ """Initialize .mnemo/ in the current repo."""
28
+ from .init import init as do_init
29
+
30
+ result = do_init(Path(path).resolve(), client=client)
31
+ click.echo(result)
32
+
33
+
34
+ @cli.command()
35
+ @click.argument("path", default=".", type=click.Path(exists=True))
36
+ def map(path: str):
37
+ """Regenerate the repo map."""
38
+ from .config import mnemo_path
39
+ from .repo_map import save_repo_map
40
+
41
+ repo_root = Path(path).resolve()
42
+ if not mnemo_path(repo_root).exists():
43
+ click.echo("Not initialized. Run `mnemo init` first.")
44
+ return
45
+ save_repo_map(repo_root)
46
+ click.echo("Repo map updated.")
47
+
48
+
49
+ @cli.command()
50
+ @click.argument("content")
51
+ @click.option("--category", "-c", default="general")
52
+ @click.argument("path", default=".", type=click.Path(exists=True))
53
+ def remember(content: str, category: str, path: str):
54
+ """Store a memory entry. Example: mnemo remember 'uses FastAPI'"""
55
+ from .memory import add_memory
56
+
57
+ entry = add_memory(Path(path).resolve(), content, category)
58
+ click.echo(f"Remembered #{entry['id']}: {content}")
59
+
60
+
61
+ @cli.command()
62
+ @click.argument("path", default=".", type=click.Path(exists=True))
63
+ def recall(path: str):
64
+ """Show all stored memory."""
65
+ from .memory import recall as do_recall
66
+
67
+ data = do_recall(Path(path).resolve())
68
+ if not data:
69
+ click.echo("No memory found. Run `mnemo init` first.")
70
+ return
71
+ click.echo(data)
72
+
73
+
74
+ @cli.command()
75
+ @click.option(
76
+ "--client",
77
+ "-c",
78
+ default="all",
79
+ type=click.Choice(CLIENT_CHOICES),
80
+ show_default=True,
81
+ help="AI client setup to inspect.",
82
+ )
83
+ @click.argument("path", default=".", type=click.Path(exists=True))
84
+ def doctor(path: str, client: str):
85
+ """Diagnose Mnemo installation and client setup."""
86
+ from .doctor import doctor as run_doctor
87
+
88
+ click.echo(run_doctor(Path(path).resolve(), client=client))
89
+
90
+
91
+ @cli.command()
92
+ @click.argument("path", default=".", type=click.Path(exists=True))
93
+ @click.confirmation_option(prompt="This will delete all Mnemo memory. Are you sure?")
94
+ def reset(path: str):
95
+ """Wipe all Mnemo data and start fresh. Run: mnemo reset"""
96
+ import shutil
97
+
98
+ from .config import mnemo_path
99
+ from .clients import CLIENTS, context_path
100
+
101
+ repo_root = Path(path).resolve()
102
+ base = mnemo_path(repo_root)
103
+
104
+ # Remove .mnemo/ data
105
+ if base.exists():
106
+ shutil.rmtree(base)
107
+ click.echo(".mnemo/ deleted.")
108
+ else:
109
+ click.echo("Nothing to reset - .mnemo/ not found.")
110
+ return
111
+
112
+ # Remove client context files
113
+ for target in CLIENTS.values():
114
+ ctx = context_path(repo_root, target)
115
+ if ctx and ctx.exists():
116
+ ctx.unlink()
117
+ click.echo(f"Removed {ctx.relative_to(repo_root)}")
118
+
119
+ click.echo("Run `mnemo init` to start fresh.")
120
+
121
+
122
+ @cli.command()
123
+ @click.argument("path", default=".", type=click.Path(exists=True))
124
+ def status(path: str):
125
+ """Quick check: is Mnemo initialized and MCP server responding?"""
126
+ from .config import mnemo_path
127
+ from .clients import find_mnemo_mcp_command
128
+ from .doctor import _check_mcp_alive
129
+
130
+ repo_root = Path(path).resolve()
131
+ base = mnemo_path(repo_root)
132
+
133
+ if not base.exists():
134
+ click.echo("❌ Not initialized. Run: mnemo init")
135
+ return
136
+
137
+ command = find_mnemo_mcp_command()
138
+ alive = _check_mcp_alive(command)
139
+
140
+ if alive:
141
+ click.echo("✅ Mnemo active — MCP server responding")
142
+ else:
143
+ click.echo("⚠️ Mnemo initialized but MCP server not responding — restart your IDE")
144
+
145
+
146
+ @cli.command()
147
+ @click.argument("target", required=False)
148
+ @click.option("--discover", "-d", type=click.Path(exists=True), help="Auto-discover all repos under a directory.")
149
+ @click.option("--init", "auto_init", is_flag=True, help="Auto-initialize discovered repos that haven't been set up.")
150
+ @click.argument("path", default=".", type=click.Path(exists=True))
151
+ def link(target: str | None, discover: str | None, auto_init: bool, path: str):
152
+ """Link a sibling repo for cross-repo queries. Use --discover to auto-find repos."""
153
+ from .workspace import link_repo, discover_repos
154
+
155
+ repo_root = Path(path).resolve()
156
+ if discover:
157
+ result = discover_repos(repo_root, Path(discover), auto_init=auto_init)
158
+ elif target:
159
+ result = link_repo(repo_root, Path(target))
160
+ else:
161
+ click.echo("Provide a repo path or use --discover <dir>")
162
+ return
163
+ click.echo(result)
164
+
165
+
166
+ @cli.command()
167
+ @click.argument("name")
168
+ @click.argument("path", default=".", type=click.Path(exists=True))
169
+ def unlink(name: str, path: str):
170
+ """Remove a linked repo."""
171
+ from .workspace import unlink_repo
172
+
173
+ click.echo(unlink_repo(Path(path).resolve(), name))
174
+
175
+
176
+ @cli.command()
177
+ @click.argument("path", default=".", type=click.Path(exists=True))
178
+ def links(path: str):
179
+ """Show all linked repos and their status."""
180
+ from .workspace import format_links
181
+
182
+ click.echo(format_links(Path(path).resolve()))
183
+
184
+
185
+ if __name__ == "__main__":
186
+ cli()