diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4567045..cf1b7f6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -9,7 +9,7 @@ The `struct` CLI allows you to generate project structures from YAML configurati **Basic Usage:** ```sh -structkit {info,validate,generate,explain,vars,graph,list,generate-schema,mcp,completion,init} ... +structkit {info,validate,generate,explain,vars,graph,list,sources,generate-schema,mcp,completion,init} ... ``` ## Global Options @@ -27,6 +27,7 @@ The following environment variables can be used to configure default values for - `STRUCTKIT_LOG_LEVEL`: Set the default logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Overridden by the `--log` flag. - `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. +- `STRUCTKIT_SOURCES_CONFIG`: Override the user-level named sources config file (default: `$XDG_CONFIG_HOME/structkit/sources.yaml` or `~/.config/structkit/sources.yaml`). **Precedence:** @@ -135,7 +136,8 @@ structkit generate - `structure_definition` (optional): Path to the YAML configuration file (default: `.struct.yaml`). - `base_path` (optional): Base path where the structure will be created (default: `.`). -- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. Can be set via the `STRUCTKIT_STRUCTURES_PATH` environment variable. When using the environment variable (and no explicit CLI flag), an info-level log message will be emitted indicating which path is being used. +- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. Can be set via the `STRUCTKIT_STRUCTURES_PATH` environment variable. When using the environment variable (and no explicit CLI flag), an info-level log message will be emitted indicating which path is being used. Takes precedence over named sources. +- `--source SOURCE`: Named source to use when resolving structure definitions. You can also use `/` as the structure definition. - `-n INPUT_STORE, --input-store INPUT_STORE`: Path to the input store. - `-d, --dry-run`: Perform a dry run without creating any files or directories. - `--diff`: Show unified diffs for files that would be created/modified (works with `--dry-run` and in `-o console` mode). @@ -234,12 +236,44 @@ List available structures. **Usage:** ```sh -structkit list [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] +structkit list [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [--source SOURCE] ``` **Arguments:** -- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. +- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. Takes precedence over named sources. +- `--source SOURCE`: Named source to list. + +### `sources` + +Manage named custom structure sources. Sources currently support local filesystem directories. Remote sources are reserved for future support. + +**Usage:** + +```sh +structkit sources [--config-path CONFIG_PATH] {list,add,remove,show,validate} ... +structkit sources add NAME PATH_OR_URL +structkit sources remove NAME +structkit sources show NAME +structkit sources validate NAME +structkit sources list +``` + +**Arguments:** + +- `--config-path CONFIG_PATH`: Override the sources config file for this command. +- `NAME`: Source name. +- `PATH_OR_URL`: Local directory to use as a structure source. + +**Examples:** + +```sh +structkit sources add company ./templates +structkit list --source company +structkit generate company/project/python ./app +``` + +Resolution precedence is `--structures-path`/`STRUCTKIT_STRUCTURES_PATH`, then `--source` or `/`, then bundled structures. ### `generate-schema` diff --git a/docs/custom-structures.md b/docs/custom-structures.md index e9a3ecd..7ec8ba1 100644 --- a/docs/custom-structures.md +++ b/docs/custom-structures.md @@ -35,3 +35,40 @@ For this to work, you will need to set the path to the custom structures reposit ```sh structkit generate -s ~/path/to/custom-structures/structures file://.struct.yaml ./output ``` + +## Named custom sources + +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. + +```bash +structkit sources add company ./templates +structkit sources list +structkit sources show company +structkit sources validate company +structkit sources remove company +``` + +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 `. + +Named sources currently support local filesystem directories. Remote URLs are reserved for future support and are rejected by validation. + +Use a source explicitly with `--source`: + +```bash +structkit list --source company +structkit generate --source company project/python ./app +``` + +You can also prefix a structure definition with the source name: + +```bash +structkit generate company/project/python ./app +``` + +Source resolution precedence is: + +1. `--structures-path` (or `STRUCTKIT_STRUCTURES_PATH`, because it populates the same CLI option) +2. `--source` or a `/` prefix +3. Built-in StructKit structures + +This preserves existing `STRUCTKIT_STRUCTURES_PATH` behavior and leaves `generate` and `list` unchanged unless a named source is selected. diff --git a/structkit/commands/generate.py b/structkit/commands/generate.py index 37bee42..2bae3bc 100644 --- a/structkit/commands/generate.py +++ b/structkit/commands/generate.py @@ -5,6 +5,7 @@ from structkit.file_item import FileItem from structkit.completers import file_strategy_completer, structures_completer from structkit.template_renderer import TemplateRenderer +from structkit.sources import SourceError, resolve_structures_path import subprocess @@ -21,9 +22,10 @@ def __init__(self, parser): '-s', '--structures-path', type=str, - help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)', + help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH). Takes precedence over --source.', default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None) ) + parser.add_argument('--source', type=str, help='Named source to use when resolving structure definitions') parser.add_argument('-n', '--input-store', type=str, help='Path to the input store (env: STRUCTKIT_INPUT_STORE)', default=os.getenv('STRUCTKIT_INPUT_STORE', '/tmp/structkit/input.json')) parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories') parser.add_argument('--diff', action='store_true', help='Show unified diffs for files that would change during dry-run or console output') @@ -126,6 +128,16 @@ def _load_yaml_config(self, structure_definition, structures_path): return yaml.safe_load(f) def execute(self, args): + try: + args.structures_path, args.structure_definition = resolve_structures_path( + args.structures_path, + getattr(args, 'source', None), + args.structure_definition, + ) + except SourceError as exc: + self.logger.error(f"❗ {exc}") + raise SystemExit(1) from exc + # Log when using STRUCTKIT_STRUCTURES_PATH environment variable if args.structures_path and args.structures_path == os.getenv('STRUCTKIT_STRUCTURES_PATH'): self.logger.info(f"Using STRUCTKIT_STRUCTURES_PATH: {args.structures_path}") diff --git a/structkit/commands/list.py b/structkit/commands/list.py index 41130dc..2fe681a 100644 --- a/structkit/commands/list.py +++ b/structkit/commands/list.py @@ -1,6 +1,7 @@ from structkit.commands import Command import os import asyncio +from structkit.sources import SourceError, resolve_structures_path # List command class @@ -9,14 +10,20 @@ def __init__(self, parser): super().__init__(parser) parser.description = "List available structures" parser.add_argument( - '-s', '--structures-path', type=str, help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)', + '-s', '--structures-path', type=str, help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH). Takes precedence over --source.', default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None) ) + parser.add_argument('--source', type=str, help='Named source to list') parser.add_argument('--names-only', action='store_true', help='Print only structure names, one per line (for shell completion)') parser.add_argument('--mcp', action='store_true', help='Enable MCP (Model Context Protocol) integration') parser.set_defaults(func=self.execute) def execute(self, args): + try: + args.structures_path, _ = resolve_structures_path(args.structures_path, getattr(args, 'source', None)) + except SourceError as exc: + self.logger.error(f"❗ {exc}") + raise SystemExit(1) from exc self.logger.info("Listing available structures") if args.mcp: self._list_structures_mcp(args) diff --git a/structkit/commands/sources.py b/structkit/commands/sources.py new file mode 100644 index 0000000..12802c9 --- /dev/null +++ b/structkit/commands/sources.py @@ -0,0 +1,84 @@ +from structkit.commands import Command +from structkit.sources import ( + SourceError, + add_source, + get_sources_config_path, + read_sources, + remove_source, + validate_source_path, +) + + +class SourcesCommand(Command): + """Manage named custom structure sources.""" + + def __init__(self, parser): + super().__init__(parser) + parser.description = "Manage named custom structure sources" + parser.add_argument('--config-path', type=str, help='Override sources config path (env: STRUCTKIT_SOURCES_CONFIG)') + subparsers = parser.add_subparsers(dest='sources_command') + + subparsers.add_parser('list', help='List configured sources').set_defaults(sources_func=self.list_sources) + + add_parser = subparsers.add_parser('add', help='Add or update a local source') + add_parser.add_argument('name') + add_parser.add_argument('path_or_url') + add_parser.set_defaults(sources_func=self.add_source) + + remove_parser = subparsers.add_parser('remove', help='Remove a configured source') + remove_parser.add_argument('name') + remove_parser.set_defaults(sources_func=self.remove_source) + + show_parser = subparsers.add_parser('show', help='Show a configured source') + show_parser.add_argument('name') + show_parser.set_defaults(sources_func=self.show_source) + + validate_parser = subparsers.add_parser('validate', help='Validate a configured source') + validate_parser.add_argument('name') + validate_parser.set_defaults(sources_func=self.validate_source) + + parser.set_defaults(func=self.execute) + + def execute(self, args): + if not hasattr(args, 'sources_func'): + self.parser.print_help() + return + try: + args.sources_func(args) + except SourceError as exc: + self.logger.error(f"❗ {exc}") + raise SystemExit(1) from exc + + def list_sources(self, args): + sources = read_sources(args.config_path) + print(f"Sources config: {args.config_path or get_sources_config_path()}") + if not sources: + print("No sources configured.") + return + for name, path in sorted(sources.items()): + print(f"{name}\t{path}") + + def add_source(self, args): + path = add_source(args.name, args.path_or_url, args.config_path) + print(f"Added source '{args.name}' -> {read_sources(args.config_path)[args.name]}") + print(f"Sources config: {path}") + + def remove_source(self, args): + path = remove_source(args.name, args.config_path) + print(f"Removed source '{args.name}'") + print(f"Sources config: {path}") + + def show_source(self, args): + sources = read_sources(args.config_path) + if args.name not in sources: + raise SourceError(f"source not found: {args.name}") + print(f"{args.name}\t{sources[args.name]}") + + def validate_source(self, args): + sources = read_sources(args.config_path) + if args.name not in sources: + raise SourceError(f"source not found: {args.name}") + ok, message = validate_source_path(sources[args.name]) + if not ok: + raise SourceError(message) + print(f"Source '{args.name}' is valid: {message}") diff --git a/structkit/main.py b/structkit/main.py index f272ff8..3493199 100644 --- a/structkit/main.py +++ b/structkit/main.py @@ -14,6 +14,7 @@ from structkit.commands.graph import GraphCommand from structkit.commands.generate_schema import GenerateSchemaCommand from structkit.commands.mcp import MCPCommand +from structkit.commands.sources import SourcesCommand from structkit.logging_config import configure_logging # Optional dependency: shtab for static shell completion generation @@ -46,6 +47,7 @@ def get_parser(): GraphCommand(subparsers.add_parser('graph', help='Visualize structure dependencies')) GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures')) MCPCommand(subparsers.add_parser('mcp', help='MCP (Model Context Protocol) support')) + SourcesCommand(subparsers.add_parser('sources', help='Manage named custom structure sources')) # init to create a basic .struct.yaml from structkit.commands.init import InitCommand diff --git a/structkit/mcp_server.py b/structkit/mcp_server.py index 2f5c905..62e0240 100644 --- a/structkit/mcp_server.py +++ b/structkit/mcp_server.py @@ -27,6 +27,14 @@ from structkit.commands.explain import ExplainCommand from structkit.commands.graph import GraphCommand from structkit import __version__ +from structkit.sources import ( + SourceError, + add_source, + read_sources, + remove_source, + resolve_structures_path, + validate_source_path, +) class StructMCPServer: @@ -127,7 +135,13 @@ def _generate_structure_logic( dry_run: bool = False, mappings: Optional[Dict[str, str]] = None, structures_path: Optional[str] = None, + source: Optional[str] = None, ) -> str: + try: + structures_path, structure_definition = resolve_structures_path(structures_path, source, structure_definition) + except SourceError as exc: + return f"Error: {exc}" + class Args: pass args = Args() @@ -315,6 +329,50 @@ def _lint_structure_logic( finally: sys.stdout = old + def _manage_sources_logic( + self, + action: str, + name: Optional[str] = None, + path_or_url: Optional[str] = None, + config_path: Optional[str] = None, + ) -> str: + try: + if action == "list": + sources = read_sources(config_path) + if not sources: + return "No sources configured." + return "\n".join(f"{source_name}\t{source_path}" for source_name, source_path in sorted(sources.items())) + if action == "add": + if not name or not path_or_url: + return "Error: name and path_or_url are required for add" + add_source(name, path_or_url, config_path) + return f"Added source '{name}' -> {read_sources(config_path)[name]}" + if action == "remove": + if not name: + return "Error: name is required for remove" + remove_source(name, config_path) + return f"Removed source '{name}'" + if action == "show": + if not name: + return "Error: name is required for show" + sources = read_sources(config_path) + if name not in sources: + return f"Error: source not found: {name}" + return f"{name}\t{sources[name]}" + if action == "validate": + if not name: + return "Error: name is required for validate" + sources = read_sources(config_path) + if name not in sources: + return f"Error: source not found: {name}" + ok, message = validate_source_path(sources[name]) + if not ok: + return f"Error: {message}" + return f"Source '{name}' is valid: {message}" + return "Error: action must be one of list, add, remove, show, validate" + except SourceError as exc: + return f"Error: {exc}" + # ===================== # FastMCP tool registration (maps to logic above) # ===================== @@ -396,6 +454,7 @@ async def generate_structure( dry_run: bool = False, mappings: Optional[Dict[str, str]] = None, structures_path: Optional[str] = None, + source: Optional[str] = None, ) -> str: self.logger.debug( "MCP request: generate_structure args=%s", @@ -406,6 +465,7 @@ async def generate_structure( "dry_run": dry_run, "mappings": mappings, "structures_path": structures_path, + "source": source, }, ) result = self._generate_structure_logic( @@ -415,6 +475,7 @@ async def generate_structure( dry_run, mappings, structures_path, + source, ) preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]" self.logger.debug(f"MCP response: generate_structure len={len(result)} preview=\n{preview}") @@ -463,6 +524,27 @@ async def lint_structure( self.logger.debug(f"MCP response: lint_structure len={len(result)} preview=\n{preview}") return result + @self.app.tool(name="manage_sources", description="Manage named custom structure sources") + async def manage_sources( + action: str, + name: Optional[str] = None, + path_or_url: Optional[str] = None, + config_path: Optional[str] = None, + ) -> str: + self.logger.debug( + "MCP request: manage_sources args=%s", + { + "action": action, + "name": name, + "path_or_url": path_or_url, + "config_path": config_path, + }, + ) + result = self._manage_sources_logic(action, name, path_or_url, config_path) + preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]" + self.logger.debug(f"MCP response: manage_sources len={len(result)} preview=\n{preview}") + return result + @self.app.tool(name="validate_structure", description="Validate a structure configuration YAML file") async def validate_structure(yaml_file: str) -> str: self.logger.debug(f"MCP request: validate_structure args={{'yaml_file': {yaml_file!r}}}") @@ -631,6 +713,25 @@ def __init__(self, content): return MockResult([MockContent(result_text)]) + async def _handle_manage_sources(self, params: Dict[str, Any]): + """Compatibility method for tests that expect MCP-style responses.""" + result_text = self._manage_sources_logic( + params.get('action'), + params.get('name'), + params.get('path_or_url'), + params.get('config_path'), + ) + + class MockContent: + def __init__(self, text): + self.text = text + + class MockResult: + def __init__(self, content): + self.content = content + + return MockResult([MockContent(result_text)]) + async def main(): logging.basicConfig(level=logging.INFO) diff --git a/structkit/sources.py b/structkit/sources.py new file mode 100644 index 0000000..dfae527 --- /dev/null +++ b/structkit/sources.py @@ -0,0 +1,155 @@ +"""Utilities for managing named StructKit structure sources.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Dict, Optional, Tuple +from urllib.parse import urlparse + +import yaml + + +CONFIG_ENV_VAR = "STRUCTKIT_SOURCES_CONFIG" +CONFIG_DIR_ENV_VAR = "XDG_CONFIG_HOME" + + +class SourceError(ValueError): + """Raised when source configuration is invalid or cannot be resolved.""" + + +def get_sources_config_path() -> Path: + """Return the user-level StructKit sources config path.""" + override = os.getenv(CONFIG_ENV_VAR) + if override: + return Path(override).expanduser() + + config_home = os.getenv(CONFIG_DIR_ENV_VAR) + if config_home: + return Path(config_home).expanduser() / "structkit" / "sources.yaml" + + return Path.home() / ".config" / "structkit" / "sources.yaml" + + +def read_sources(config_path: Optional[str] = None) -> Dict[str, str]: + """Read configured sources from disk as a name-to-path mapping.""" + path = Path(config_path).expanduser() if config_path else get_sources_config_path() + if not path.exists(): + return {} + + with open(path, "r") as f: + data = yaml.safe_load(f) or {} + + sources = data.get("sources", data) if isinstance(data, dict) else {} + if not isinstance(sources, dict): + raise SourceError("sources config must contain a mapping of names to paths") + + result: Dict[str, str] = {} + for name, value in sources.items(): + if isinstance(value, dict): + value = value.get("path") + if not isinstance(name, str) or not name: + raise SourceError("source names must be non-empty strings") + if not isinstance(value, str) or not value: + raise SourceError(f"source '{name}' must have a non-empty path") + result[name] = value + return result + + +def write_sources(sources: Dict[str, str], config_path: Optional[str] = None) -> Path: + """Write source configuration and return the path used.""" + path = Path(config_path).expanduser() if config_path else get_sources_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"sources": {name: {"path": value} for name, value in sorted(sources.items())}} + with open(path, "w") as f: + yaml.safe_dump(payload, f, sort_keys=True) + return path + + +def is_remote_source(path_or_url: str) -> bool: + parsed = urlparse(path_or_url) + return parsed.scheme in {"http", "https", "git", "ssh"} + + +def normalize_source_path(path_or_url: str) -> str: + """Normalize a local source path, preserving remote URLs for future support.""" + if is_remote_source(path_or_url): + return path_or_url + return str(Path(path_or_url).expanduser().resolve()) + + +def validate_source_path(path_or_url: str) -> Tuple[bool, str]: + """Validate that a source points at a usable local directory.""" + if is_remote_source(path_or_url): + return False, "remote sources are not supported yet" + + path = Path(path_or_url).expanduser() + if not path.exists(): + return False, f"path does not exist: {path}" + if not path.is_dir(): + return False, f"path is not a directory: {path}" + return True, f"valid local source: {path.resolve()}" + + +def add_source(name: str, path_or_url: str, config_path: Optional[str] = None) -> Path: + sources = read_sources(config_path) + ok, message = validate_source_path(path_or_url) + if not ok: + raise SourceError(message) + sources[name] = normalize_source_path(path_or_url) + return write_sources(sources, config_path) + + +def remove_source(name: str, config_path: Optional[str] = None) -> Path: + sources = read_sources(config_path) + if name not in sources: + raise SourceError(f"source not found: {name}") + del sources[name] + return write_sources(sources, config_path) + + +def resolve_source_path(source: Optional[str], config_path: Optional[str] = None) -> Optional[str]: + if not source: + return None + sources = read_sources(config_path) + if source not in sources: + raise SourceError(f"source not found: {source}") + return sources[source] + + +def split_source_definition(structure_definition: str, config_path: Optional[str] = None) -> Tuple[Optional[str], str]: + """Split '/' when the first segment is a configured source.""" + if not structure_definition or structure_definition.startswith(("file://", "/")): + return None, structure_definition + first, sep, rest = structure_definition.partition("/") + if not sep or not rest: + return None, structure_definition + sources = read_sources(config_path) + if first in sources: + return first, rest + return None, structure_definition + + +def resolve_structures_path( + structures_path: Optional[str], + source: Optional[str], + structure_definition: Optional[str] = None, + config_path: Optional[str] = None, +) -> Tuple[Optional[str], Optional[str]]: + """Resolve effective structures path and possibly rewritten structure name. + + Precedence is explicit --structures-path first, then --source or a + '/' prefix, then existing STRUCTKIT_STRUCTURES_PATH/default + behavior handled by callers. + """ + if structures_path: + return structures_path, structure_definition + + selected_source = source + rewritten = structure_definition + if not selected_source and structure_definition: + selected_source, rewritten = split_source_definition(structure_definition, config_path) + + if selected_source: + return resolve_source_path(selected_source, config_path), rewritten + return structures_path, rewritten diff --git a/tests/test_sources_command.py b/tests/test_sources_command.py new file mode 100644 index 0000000..fa96f12 --- /dev/null +++ b/tests/test_sources_command.py @@ -0,0 +1,135 @@ +import argparse +import os +import tempfile +from pathlib import Path + +import pytest +import yaml + +from structkit.commands.generate import GenerateCommand +from structkit.commands.list import ListCommand +from structkit.commands.sources import SourcesCommand +from structkit.mcp_server import StructMCPServer +from structkit.sources import add_source, read_sources, resolve_structures_path + + +def test_sources_config_read_write_and_validate(monkeypatch, tmp_path): + config_path = tmp_path / "sources.yaml" + source_dir = tmp_path / "templates" + source_dir.mkdir() + + add_source("company", str(source_dir), str(config_path)) + + assert read_sources(str(config_path)) == {"company": str(source_dir.resolve())} + + +def test_sources_command_add_list_show_validate_remove(capsys, tmp_path): + parser = argparse.ArgumentParser() + command = SourcesCommand(parser) + config_path = tmp_path / "sources.yaml" + source_dir = tmp_path / "templates" + source_dir.mkdir() + + args = parser.parse_args(["--config-path", str(config_path), "add", "company", str(source_dir)]) + command.execute(args) + assert "Added source 'company'" in capsys.readouterr().out + + args = parser.parse_args(["--config-path", str(config_path), "list"]) + command.execute(args) + assert "company" in capsys.readouterr().out + + args = parser.parse_args(["--config-path", str(config_path), "show", "company"]) + command.execute(args) + assert str(source_dir.resolve()) in capsys.readouterr().out + + args = parser.parse_args(["--config-path", str(config_path), "validate", "company"]) + command.execute(args) + assert "is valid" in capsys.readouterr().out + + args = parser.parse_args(["--config-path", str(config_path), "remove", "company"]) + command.execute(args) + assert "Removed source 'company'" in capsys.readouterr().out + assert read_sources(str(config_path)) == {} + + +def test_sources_command_rejects_invalid_path(tmp_path): + parser = argparse.ArgumentParser() + command = SourcesCommand(parser) + args = parser.parse_args([ + "--config-path", + str(tmp_path / "sources.yaml"), + "add", + "missing", + str(tmp_path / "missing"), + ]) + + with pytest.raises(SystemExit): + command.execute(args) + + +def test_resolve_source_precedence(monkeypatch, tmp_path): + config_path = tmp_path / "sources.yaml" + source_dir = tmp_path / "templates" + source_dir.mkdir() + add_source("company", str(source_dir), str(config_path)) + monkeypatch.setenv("STRUCTKIT_SOURCES_CONFIG", str(config_path)) + + resolved, definition = resolve_structures_path(None, None, "company/project/python") + assert resolved == str(source_dir.resolve()) + assert definition == "project/python" + + resolved, definition = resolve_structures_path("/cli/path", "company", "company/project/python") + assert resolved == "/cli/path" + assert definition == "company/project/python" + + +def test_generate_uses_named_source_prefix(monkeypatch, tmp_path): + config_path = tmp_path / "sources.yaml" + source_dir = tmp_path / "templates" + source_dir.mkdir() + (source_dir / "demo.yaml").write_text("files: []\n") + add_source("company", str(source_dir), str(config_path)) + monkeypatch.setenv("STRUCTKIT_SOURCES_CONFIG", str(config_path)) + + parser = argparse.ArgumentParser() + command = GenerateCommand(parser) + args = parser.parse_args(["company/demo", str(tmp_path / "out")]) + command.execute(args) + + assert args.structure_definition == "demo" + assert args.structures_path == str(source_dir.resolve()) + + +def test_list_uses_named_source(monkeypatch, capsys, tmp_path): + config_path = tmp_path / "sources.yaml" + source_dir = tmp_path / "templates" + source_dir.mkdir() + (source_dir / "demo.yaml").write_text("files: []\n") + add_source("company", str(source_dir), str(config_path)) + monkeypatch.setenv("STRUCTKIT_SOURCES_CONFIG", str(config_path)) + + parser = argparse.ArgumentParser() + command = ListCommand(parser) + args = parser.parse_args(["--source", "company", "--names-only"]) + command.execute(args) + + assert "demo" in capsys.readouterr().out + + +def test_mcp_manage_sources(tmp_path): + server = StructMCPServer() + config_path = str(tmp_path / "sources.yaml") + source_dir = tmp_path / "templates" + source_dir.mkdir() + + added = server._manage_sources_logic("add", "company", str(source_dir), config_path) + assert "Added source 'company'" in added + + listed = server._manage_sources_logic("list", config_path=config_path) + assert "company" in listed + + validated = server._manage_sources_logic("validate", "company", config_path=config_path) + assert "is valid" in validated + + removed = server._manage_sources_logic("remove", "company", config_path=config_path) + assert "Removed source 'company'" in removed