Skip to content

Commit 5b542ef

Browse files
committed
feat: support git-backed structure sources
1 parent c4420fa commit 5b542ef

6 files changed

Lines changed: 298 additions & 17 deletions

File tree

docs/cli-reference.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ The following environment variables can be used to configure default values for
2828
- `STRUCTKIT_LOG_LEVEL`: Set the default logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Overridden by the `--log` flag.
2929
- `STRUCTKIT_STRUCTURES_PATH`: Set the default path to structure definitions. This is used as the default value for the `--structures-path` flag when not explicitly provided. When set, the CLI will log an info message indicating that this environment variable is being used.
3030
- `STRUCTKIT_SOURCES_CONFIG`: Override the user-level named sources config file (default: `$XDG_CONFIG_HOME/structkit/sources.yaml` or `~/.config/structkit/sources.yaml`).
31+
- `STRUCTKIT_SOURCES_CACHE`: Override the local cache directory used for git-backed sources (default: `$XDG_CACHE_HOME/structkit/sources` or `~/.cache/structkit/sources`).
3132

3233
**Precedence:**
3334

@@ -246,7 +247,7 @@ structkit list [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH]
246247

247248
### `sources`
248249

249-
Manage named custom structure sources. Sources currently support local filesystem directories. Remote sources are reserved for future support.
250+
Manage named custom structure sources. Sources support local filesystem directories, GitHub repositories, and git-backed repositories.
250251

251252
**Usage:**
252253

@@ -263,16 +264,20 @@ structkit sources list
263264

264265
- `--config-path CONFIG_PATH`: Override the sources config file for this command.
265266
- `NAME`: Source name.
266-
- `PATH_OR_URL`: Local directory to use as a structure source.
267+
- `PATH_OR_URL`: Local directory, GitHub repository shorthand (`owner/repo`), `github://owner/repo`, or git URL to use as a structure source. GitHub sources may include an optional ref and subdirectory, for example `github://owner/repo@v1/structures`.
267268

268269
**Examples:**
269270

270271
```sh
271272
structkit sources add company ./templates
273+
structkit sources add platform httpdss/platform-structures
274+
structkit sources add versioned github://httpdss/platform-structures@v1/structures
272275
structkit list --source company
273276
structkit generate company/project/python ./app
274277
```
275278

279+
Git-backed sources are cloned into the StructKit sources cache and refreshed with `git fetch` when resolved or validated.
280+
276281
Resolution precedence is `--structures-path`/`STRUCTKIT_STRUCTURES_PATH`, then `--source` or `<source>/<structure>`, then bundled structures.
277282

278283
### `generate-schema`

docs/custom-structures.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ structkit generate -s ~/path/to/custom-structures/structures file://.struct.yaml
3838

3939
## Named custom sources
4040

41-
StructKit can store named local structure sources in a user-level config file. This is useful when you reuse a shared template directory and do not want to pass `--structures-path` or set `STRUCTKIT_STRUCTURES_PATH` every time.
41+
StructKit can store named structure sources in a user-level config file. This is useful when you reuse a shared template directory or GitHub repository and do not want to pass `--structures-path` or set `STRUCTKIT_STRUCTURES_PATH` every time.
4242

4343
```bash
4444
structkit sources add company ./templates
45+
structkit sources add platform httpdss/platform-structures
46+
structkit sources add versioned github://httpdss/platform-structures@v1/structures
4547
structkit sources list
4648
structkit sources show company
4749
structkit sources validate company
@@ -50,7 +52,14 @@ structkit sources remove company
5052

5153
By default, sources are written to `$XDG_CONFIG_HOME/structkit/sources.yaml` or `~/.config/structkit/sources.yaml`. Set `STRUCTKIT_SOURCES_CONFIG` to use a different file, or pass `structkit sources --config-path <file>`.
5254

53-
Named sources currently support local filesystem directories. Remote URLs are reserved for future support and are rejected by validation.
55+
Named sources support:
56+
57+
- Local filesystem directories, such as `./templates` or `~/platform/structures`
58+
- GitHub shorthand, such as `httpdss/platform-structures`
59+
- GitHub URLs, such as `github://httpdss/platform-structures` or `github://httpdss/platform-structures@v1/structures`
60+
- Git URLs, including HTTPS, SSH, and `file://` repositories
61+
62+
Git-backed sources are cloned into `$XDG_CACHE_HOME/structkit/sources` or `~/.cache/structkit/sources` by default. Set `STRUCTKIT_SOURCES_CACHE` to use a different cache directory. StructKit runs `git fetch` when a git-backed source is resolved or validated, so a named source can track the latest content from its configured repository or ref.
5463

5564
Use a source explicitly with `--source`:
5665

docs/mcp-integration.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ Generate a project structure using specified definition and options.
5656
"project_name": "MyProject",
5757
"author": "John Doe"
5858
},
59-
"structures_path": "/path/to/custom/structures" // optional
59+
"structures_path": "/path/to/custom/structures", // optional
60+
"source": "company" // optional named source
6061
}
6162
}
6263
```
@@ -68,6 +69,7 @@ Generate a project structure using specified definition and options.
6869
- `dry_run` (optional): Perform a dry run without creating actual files (default: false)
6970
- `mappings` (optional): Variable mappings for template substitution
7071
- `structures_path` (optional): Custom path to structure definitions
72+
- `source` (optional): Named source configured with `manage_sources`. The structure definition can also use a `<source>/<structure>` prefix.
7173

7274
### 4. get_structure_vars
7375
Inspect variables declared by a specific structure without generating files.
@@ -146,6 +148,26 @@ Visualize structure dependencies from `folders[].struct` references as text, JSO
146148
- `graph_all` (optional): Graph all available structures (default: false).
147149
- `output` (optional): Output format - "text", "json", or "mermaid" (default: "text").
148150

151+
### 8. manage_sources
152+
Manage named structure sources. Sources can point at local directories, GitHub repositories, or git URLs. Git-backed sources are cloned into the StructKit sources cache and refreshed when resolved or validated.
153+
154+
```json
155+
{
156+
"name": "manage_sources",
157+
"arguments": {
158+
"action": "add",
159+
"name": "platform",
160+
"path_or_url": "github://httpdss/platform-structures@v1/structures"
161+
}
162+
}
163+
```
164+
165+
**Parameters:**
166+
- `action` (required): One of `list`, `add`, `remove`, `show`, or `validate`.
167+
- `name` (required except for `list`): Source name.
168+
- `path_or_url` (required for `add`): Local directory, GitHub shorthand (`owner/repo`), `github://owner/repo`, or git URL.
169+
- `config_path` (optional): Override the sources config file for this request.
170+
149171
## Usage
150172

151173
### Starting the MCP Server (FastMCP stdio / http / sse)

structkit/commands/sources.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@ class SourcesCommand(Command):
1515
def __init__(self, parser):
1616
super().__init__(parser)
1717
parser.description = "Manage named custom structure sources"
18-
parser.add_argument('--config-path', type=str, help='Override sources config path (env: STRUCTKIT_SOURCES_CONFIG)')
18+
parser.add_argument(
19+
'--config-path',
20+
type=str,
21+
help='Override sources config path (env: STRUCTKIT_SOURCES_CONFIG)',
22+
)
1923
subparsers = parser.add_subparsers(dest='sources_command')
2024

2125
subparsers.add_parser('list', help='List configured sources').set_defaults(sources_func=self.list_sources)
2226

23-
add_parser = subparsers.add_parser('add', help='Add or update a local source')
27+
add_parser = subparsers.add_parser('add', help='Add or update a local, GitHub, or git-backed source')
2428
add_parser.add_argument('name')
25-
add_parser.add_argument('path_or_url')
29+
add_parser.add_argument('path_or_url', help='Local directory, owner/repo, github://owner/repo, or git URL')
2630
add_parser.set_defaults(sources_func=self.add_source)
2731

2832
remove_parser = subparsers.add_parser('remove', help='Remove a configured source')

structkit/sources.py

Lines changed: 181 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from __future__ import annotations
44

5+
import hashlib
56
import os
7+
import re
8+
import shutil
9+
import subprocess
10+
from dataclasses import dataclass
611
from pathlib import Path
712
from typing import Dict, Optional, Tuple
813
from urllib.parse import urlparse
@@ -12,12 +17,23 @@
1217

1318
CONFIG_ENV_VAR = "STRUCTKIT_SOURCES_CONFIG"
1419
CONFIG_DIR_ENV_VAR = "XDG_CONFIG_HOME"
20+
CACHE_ENV_VAR = "STRUCTKIT_SOURCES_CACHE"
21+
CACHE_DIR_ENV_VAR = "XDG_CACHE_HOME"
1522

1623

1724
class SourceError(ValueError):
1825
"""Raised when source configuration is invalid or cannot be resolved."""
1926

2027

28+
@dataclass(frozen=True)
29+
class RemoteSource:
30+
"""A git-backed source normalized for local cache resolution."""
31+
32+
git_url: str
33+
ref: Optional[str] = None
34+
subdir: str = ""
35+
36+
2137
def get_sources_config_path() -> Path:
2238
"""Return the user-level StructKit sources config path."""
2339
override = os.getenv(CONFIG_ENV_VAR)
@@ -31,6 +47,19 @@ def get_sources_config_path() -> Path:
3147
return Path.home() / ".config" / "structkit" / "sources.yaml"
3248

3349

50+
def get_sources_cache_dir() -> Path:
51+
"""Return the cache directory used for git-backed sources."""
52+
override = os.getenv(CACHE_ENV_VAR)
53+
if override:
54+
return Path(override).expanduser()
55+
56+
cache_home = os.getenv(CACHE_DIR_ENV_VAR)
57+
if cache_home:
58+
return Path(cache_home).expanduser() / "structkit" / "sources"
59+
60+
return Path.home() / ".cache" / "structkit" / "sources"
61+
62+
3463
def read_sources(config_path: Optional[str] = None) -> Dict[str, str]:
3564
"""Read configured sources from disk as a name-to-path mapping."""
3665
path = Path(config_path).expanduser() if config_path else get_sources_config_path()
@@ -47,7 +76,7 @@ def read_sources(config_path: Optional[str] = None) -> Dict[str, str]:
4776
result: Dict[str, str] = {}
4877
for name, value in sources.items():
4978
if isinstance(value, dict):
50-
value = value.get("path")
79+
value = value.get("path") or value.get("url")
5180
if not isinstance(name, str) or not name:
5281
raise SourceError("source names must be non-empty strings")
5382
if not isinstance(value, str) or not value:
@@ -66,22 +95,166 @@ def write_sources(sources: Dict[str, str], config_path: Optional[str] = None) ->
6695
return path
6796

6897

98+
def is_github_shorthand(path_or_url: str) -> bool:
99+
"""Return true for owner/repo shorthand values that are not local paths."""
100+
return bool(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?(?:@[^/]+)?(?:/.+)?", path_or_url))
101+
102+
69103
def is_remote_source(path_or_url: str) -> bool:
104+
if path_or_url.startswith("git@"):
105+
return True
70106
parsed = urlparse(path_or_url)
71-
return parsed.scheme in {"http", "https", "git", "ssh"}
107+
if parsed.scheme in {"http", "https", "git", "ssh", "github", "file"}:
108+
return True
109+
# Preserve existing relative local-directory behavior: only treat owner/repo
110+
# shorthand as GitHub when that path is not present on disk.
111+
if is_github_shorthand(path_or_url) and not Path(path_or_url).expanduser().exists():
112+
return True
113+
return False
114+
115+
116+
def _split_ref_and_subdir(value: str) -> Tuple[str, Optional[str], str]:
117+
"""Split 'repo@ref/subdir' or 'repo/subdir' into repo, ref, subdir."""
118+
first, sep, rest = value.partition("/")
119+
repo_part = first
120+
subdir = rest if sep else ""
121+
if "@" in repo_part:
122+
repo, ref = repo_part.rsplit("@", 1)
123+
else:
124+
repo, ref = repo_part, None
125+
return repo, ref, subdir.strip("/")
126+
127+
128+
def parse_remote_source(path_or_url: str) -> Optional[RemoteSource]:
129+
"""Parse supported git/GitHub source forms.
130+
131+
Supported forms:
132+
- github://owner/repo
133+
- github://owner/repo@ref
134+
- github://owner/repo@ref/path/to/structures
135+
- owner/repo or owner/repo@ref shorthand
136+
- https://github.com/owner/repo(.git)
137+
- git@github.com:owner/repo.git
138+
- file:///path/to/repo for local git repositories used as remotes
139+
"""
140+
if not is_remote_source(path_or_url):
141+
return None
142+
143+
if is_github_shorthand(path_or_url):
144+
owner, rest = path_or_url.split("/", 1)
145+
repo, ref, subdir = _split_ref_and_subdir(rest)
146+
repo = repo[:-4] if repo.endswith(".git") else repo
147+
return RemoteSource(f"https://github.com/{owner}/{repo}.git", ref, subdir)
148+
149+
if path_or_url.startswith("git@"):
150+
return RemoteSource(path_or_url)
151+
152+
parsed = urlparse(path_or_url)
153+
if parsed.scheme == "github":
154+
owner = parsed.netloc
155+
repo_path = parsed.path.lstrip("/")
156+
if not owner or not repo_path:
157+
raise SourceError("github sources must use github://owner/repo")
158+
repo, ref, subdir = _split_ref_and_subdir(repo_path)
159+
repo = repo[:-4] if repo.endswith(".git") else repo
160+
return RemoteSource(f"https://github.com/{owner}/{repo}.git", ref, subdir)
161+
162+
if parsed.scheme in {"http", "https"} and parsed.netloc.lower() == "github.com":
163+
parts = [part for part in parsed.path.strip("/").split("/") if part]
164+
if len(parts) < 2:
165+
raise SourceError("GitHub HTTPS sources must use https://github.com/owner/repo")
166+
owner, repo = parts[0], parts[1]
167+
ref = None
168+
subdir = ""
169+
if len(parts) >= 4 and parts[2] in {"tree", "blob"}:
170+
ref = parts[3]
171+
subdir = "/".join(parts[4:])
172+
elif len(parts) > 2:
173+
subdir = "/".join(parts[2:])
174+
if repo.endswith(".git"):
175+
repo = repo[:-4]
176+
subdir = ""
177+
return RemoteSource(f"https://github.com/{owner}/{repo}.git", ref, subdir)
178+
179+
if parsed.scheme in {"http", "https", "git", "ssh", "file"}:
180+
return RemoteSource(path_or_url)
181+
182+
return None
72183

73184

74185
def normalize_source_path(path_or_url: str) -> str:
75-
"""Normalize a local source path, preserving remote URLs for future support."""
76-
if is_remote_source(path_or_url):
186+
"""Normalize a local source path while preserving supported remote source specs."""
187+
remote = parse_remote_source(path_or_url)
188+
if remote:
189+
if remote.git_url.startswith("https://github.com/"):
190+
owner_repo = remote.git_url.removeprefix("https://github.com/").removesuffix(".git")
191+
suffix = f"@{remote.ref}" if remote.ref else ""
192+
subdir = f"/{remote.subdir}" if remote.subdir else ""
193+
return f"github://{owner_repo}{suffix}{subdir}"
77194
return path_or_url
78195
return str(Path(path_or_url).expanduser().resolve())
79196

80197

198+
def _source_cache_path(remote: RemoteSource) -> Path:
199+
key = hashlib.sha256(remote.git_url.encode("utf-8")).hexdigest()[:16]
200+
safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", remote.git_url).strip("-")[-64:]
201+
return get_sources_cache_dir() / f"{safe}-{key}"
202+
203+
204+
def _run_git(args: list[str], cwd: Optional[Path] = None) -> None:
205+
try:
206+
subprocess.run(
207+
["git", *args],
208+
cwd=str(cwd) if cwd else None,
209+
text=True,
210+
stdout=subprocess.PIPE,
211+
stderr=subprocess.PIPE,
212+
check=True,
213+
)
214+
except FileNotFoundError:
215+
raise SourceError("git is required for remote sources but was not found on PATH") from None
216+
except subprocess.CalledProcessError as exc:
217+
detail = (exc.stderr or exc.stdout or "").strip()
218+
raise SourceError(f"git {' '.join(args)} failed: {detail}") from None
219+
220+
221+
def ensure_remote_source(path_or_url: str) -> str:
222+
"""Clone/fetch a remote source and return the local structures path."""
223+
remote = parse_remote_source(path_or_url)
224+
if not remote:
225+
return str(Path(path_or_url).expanduser().resolve())
226+
227+
cache_path = _source_cache_path(remote)
228+
cache_path.parent.mkdir(parents=True, exist_ok=True)
229+
230+
if (cache_path / ".git").exists():
231+
_run_git(["fetch", "--prune", "--tags", "origin"], cwd=cache_path)
232+
else:
233+
if cache_path.exists():
234+
shutil.rmtree(cache_path)
235+
_run_git(["clone", remote.git_url, str(cache_path)])
236+
237+
if remote.ref:
238+
_run_git(["checkout", remote.ref], cwd=cache_path)
239+
_run_git(["pull", "--ff-only"], cwd=cache_path)
240+
241+
structures_path = cache_path / remote.subdir if remote.subdir else cache_path
242+
if not structures_path.exists():
243+
raise SourceError(f"remote source subdirectory does not exist: {remote.subdir or '.'}")
244+
if not structures_path.is_dir():
245+
raise SourceError(f"remote source subdirectory is not a directory: {remote.subdir}")
246+
return str(structures_path)
247+
248+
81249
def validate_source_path(path_or_url: str) -> Tuple[bool, str]:
82-
"""Validate that a source points at a usable local directory."""
83-
if is_remote_source(path_or_url):
84-
return False, "remote sources are not supported yet"
250+
"""Validate that a source points at a usable local directory or git-backed source."""
251+
remote = parse_remote_source(path_or_url)
252+
if remote:
253+
try:
254+
local_path = ensure_remote_source(path_or_url)
255+
except SourceError as exc:
256+
return False, str(exc)
257+
return True, f"valid remote source: {path_or_url} -> {local_path}"
85258

86259
path = Path(path_or_url).expanduser()
87260
if not path.exists():
@@ -114,7 +287,7 @@ def resolve_source_path(source: Optional[str], config_path: Optional[str] = None
114287
sources = read_sources(config_path)
115288
if source not in sources:
116289
raise SourceError(f"source not found: {source}")
117-
return sources[source]
290+
return ensure_remote_source(sources[source]) if is_remote_source(sources[source]) else sources[source]
118291

119292

120293
def split_source_definition(structure_definition: str, config_path: Optional[str] = None) -> Tuple[Optional[str], str]:

0 commit comments

Comments
 (0)