Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- **🎯 Dry Run Mode** - Preview changes before applying them
- **✅ Validation & Schema** - Built-in YAML validation and IDE support
- **🤖 MCP Integration** - Model Context Protocol support for AI-assisted development workflows
- **📚 Named Sources** - Save, inspect, validate, and reuse custom structure directories

## 🤔 Why structkit?

Expand Down Expand Up @@ -79,6 +80,11 @@ structkit generate terraform-module ./my-terraform-module
# List available structures
structkit list

# Save and use a custom structures source
structkit sources add company ~/templates
structkit list --source company
structkit generate company/project/python ./app

# Validate a configuration
structkit validate my-config.yaml

Expand Down
52 changes: 52 additions & 0 deletions docs/custom-structures.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,55 @@ 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 Sources

You can save frequently used custom structure directories as named sources with the `structkit sources` command group. Sources are stored in a user-level StructKit config file at `~/.config/structkit/sources.json` by default. Automation and tests can override that location with `STRUCTKIT_SOURCES_CONFIG`.

```sh
structkit sources add company ~/path/to/custom-structures/structures
structkit sources list
structkit sources show company
structkit sources validate company
structkit sources remove company
```

Named sources currently support local filesystem directories. Remote URLs are reserved for future support.

After a source is configured, select it explicitly with `--source`:

```sh
structkit list --source company
structkit search --source company python
structkit info --source company project/python
structkit generate --source company project/python ./output
```

For `generate` and `info`, you can also prefix the structure name with a configured source name:

```sh
structkit generate company/project/python ./output
```

### Precedence and compatibility

Existing custom-structure behavior remains compatible:

1. `--structures-path` and `STRUCTKIT_STRUCTURES_PATH` point commands at a custom directory.
2. If no structures path is set, `--source <name>` selects a named source.
3. If no structures path or `--source` is set, `generate` and `info` can resolve source-prefixed names such as `company/project/python`.
4. If none of the above are used, StructKit uses its bundled contrib structures.

When a custom path or named source is selected, StructKit still falls back to bundled contrib structures for `list`, `search`, and `generate` in the same way as the existing `STRUCTKIT_STRUCTURES_PATH` workflow.

### MCP tools

The MCP server exposes the same source-management workflow for AI clients:

- `list_sources`
- `add_source`
- `remove_source`
- `show_source`
- `validate_source`

The structure tools also accept named sources. Pass `source` to `list_structures`, `get_structure_info`, or `generate_structure`, or use a source-prefixed structure name such as `company/project/python` with `get_structure_info` and `generate_structure`.
9 changes: 9 additions & 0 deletions structkit/commands/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 SourceConfigError, resolve_structures_path

import subprocess

Expand All @@ -24,6 +25,7 @@ def __init__(self, parser):
help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)',
default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None)
)
parser.add_argument('--source', type=str, help='Named source containing the structure definition')
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')
Expand Down Expand Up @@ -161,6 +163,13 @@ def execute(self, args):

# Load config to check for hooks
config = None
try:
effective_structures_path, structure_definition = resolve_structures_path(args, args.structure_definition)
except SourceConfigError as e:
self.logger.error(f"❗ {e}")
return
args.structures_path = effective_structures_path
args.structure_definition = structure_definition
config = self._load_yaml_config(args.structure_definition, args.structures_path)
if config is None:
return
Expand Down
13 changes: 10 additions & 3 deletions structkit/commands/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio

from structkit.commands import Command
from structkit.sources import SourceConfigError, resolve_structures_path


# Info command class for exposing information about the structure
Expand All @@ -16,6 +17,7 @@ def __init__(self, parser):
'-s', '--structures-path', type=str, help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)',
default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None)
)
parser.add_argument('--source', type=str, help='Named source containing the structure definition')
parser.add_argument('--mcp', action='store_true', help='Enable MCP (Model Context Protocol) integration')

parser.set_defaults(func=self.execute)
Expand All @@ -33,11 +35,16 @@ def _get_info(self, args):
with open(args.structure_definition[7:], 'r') as f:
config = yaml.safe_load(f)
else:
if args.structures_path is None:
try:
effective_structures_path, structure_definition = resolve_structures_path(args, args.structure_definition)
except SourceConfigError as e:
self.logger.error(f"❗ {e}")
return
if effective_structures_path is None:
this_file = os.path.dirname(os.path.realpath(__file__))
file_path = os.path.join(this_file, "..", "contribs", f"{args.structure_definition}.yaml")
file_path = os.path.join(this_file, "..", "contribs", f"{structure_definition}.yaml")
else:
file_path = os.path.join(args.structures_path, f"{args.structure_definition}.yaml")
file_path = os.path.join(effective_structures_path, f"{structure_definition}.yaml")
# show error if file is not found
if not os.path.exists(file_path):
self.logger.error(f"❗ File not found: {file_path}")
Expand Down
12 changes: 10 additions & 2 deletions structkit/commands/list.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from structkit.commands import Command
from structkit.sources import SourceConfigError, resolve_structures_path
import os
import asyncio

Expand All @@ -12,6 +13,7 @@ def __init__(self, parser):
'-s', '--structures-path', type=str, help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)',
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)
Expand All @@ -27,8 +29,14 @@ def _list_structures(self, args):
this_file = os.path.dirname(os.path.realpath(__file__))
contribs_path = os.path.join(this_file, "..", "contribs")

if args.structures_path:
final_path = args.structures_path
try:
effective_structures_path, _ = resolve_structures_path(args)
except SourceConfigError as e:
self.logger.error(f"❗ {e}")
return

if effective_structures_path:
final_path = effective_structures_path
paths_to_list = [final_path, contribs_path]
else:
paths_to_list = [contribs_path]
Expand Down
5 changes: 5 additions & 0 deletions structkit/commands/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ def execute(self, args):
print(" - get_structure_info: Get detailed information about a structure")
print(" - generate_structure: Generate structures with various options")
print(" - validate_structure: Validate structure configuration files")
print(" - list_sources: List configured custom structure sources")
print(" - add_source: Add or replace a local custom structure source")
print(" - remove_source: Remove a configured custom structure source")
print(" - show_source: Show a configured custom structure source")
print(" - validate_source: Validate a configured custom structure source")
print("\nExamples:")
print(" structkit mcp --server --transport stdio --debug")
print(" structkit mcp --server --transport http --host 127.0.0.1 --port 9000 --path /mcp --uvicorn-log-level debug")
Expand Down
16 changes: 14 additions & 2 deletions structkit/commands/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import yaml

from structkit.commands import Command
from structkit.sources import SourceConfigError, resolve_structures_path


class SearchCommand(Command):
Expand All @@ -21,6 +22,11 @@ def __init__(self, parser):
help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)',
default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None),
)
parser.add_argument(
'--source',
type=str,
help='Named source to search',
)
parser.add_argument(
'--names-only',
action='store_true',
Expand All @@ -36,8 +42,14 @@ def _search_structures(self, args):
this_file = os.path.dirname(os.path.realpath(__file__))
contribs_path = os.path.join(this_file, '..', 'contribs')

if args.structures_path:
paths_to_search = [(args.structures_path, False), (contribs_path, True)]
try:
effective_structures_path, _ = resolve_structures_path(args)
except SourceConfigError as e:
self.logger.error(f"❗ {e}")
return

if effective_structures_path:
paths_to_search = [(effective_structures_path, False), (contribs_path, True)]
else:
paths_to_search = [(contribs_path, True)]

Expand Down
85 changes: 85 additions & 0 deletions structkit/commands/sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from structkit.commands import Command
from structkit.sources import (
SourceConfigError,
add_source,
get_source_path,
read_sources,
remove_source,
validate_source,
)


class SourcesCommand(Command):
"""Manage named custom structure sources."""

def __init__(self, parser):
super().__init__(parser)
parser.description = "Manage named custom structure sources"
subparsers = parser.add_subparsers(dest="sources_command")

list_parser = subparsers.add_parser("list", help="List configured sources")
list_parser.set_defaults(func=self.execute)

add_parser = subparsers.add_parser("add", help="Add or replace a local source")
add_parser.add_argument("name", type=str, help="Source name")
add_parser.add_argument("path_or_url", type=str, help="Local path to structure definitions")
add_parser.set_defaults(func=self.execute)

remove_parser = subparsers.add_parser("remove", help="Remove a configured source")
remove_parser.add_argument("name", type=str, help="Source name")
remove_parser.set_defaults(func=self.execute)

show_parser = subparsers.add_parser("show", help="Show one configured source")
show_parser.add_argument("name", type=str, help="Source name")
show_parser.set_defaults(func=self.execute)

validate_parser = subparsers.add_parser("validate", help="Validate one configured source")
validate_parser.add_argument("name", type=str, help="Source name")
validate_parser.set_defaults(func=self.execute)

parser.set_defaults(func=self.execute)

def execute(self, args):
command = getattr(args, "sources_command", None)
if command is None:
self.parser.print_help()
return

try:
if command == "list":
self._list_sources()
elif command == "add":
self._add_source(args.name, args.path_or_url)
elif command == "remove":
self._remove_source(args.name)
elif command == "show":
self._show_source(args.name)
elif command == "validate":
self._validate_source(args.name)
except SourceConfigError as e:
self.logger.error(f"❗ {e}")

def _list_sources(self):
sources = read_sources()
if not sources:
print("No sources configured")
return
print("📚 Configured structure sources\n")
for name in sorted(sources):
print(f" - {name}: {sources[name]}")

def _add_source(self, name, path_or_url):
source_path = add_source(name, path_or_url)
print(f"Added source '{name}': {source_path}")

def _remove_source(self, name):
source_path = remove_source(name)
print(f"Removed source '{name}': {source_path}")

def _show_source(self, name):
source_path = get_source_path(name)
print(f"{name}: {source_path}")

def _validate_source(self, name):
source_path = validate_source(name)
print(f"Source '{name}' is valid: {source_path}")
2 changes: 2 additions & 0 deletions structkit/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from structkit.commands.search import SearchCommand
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
Expand Down Expand Up @@ -38,6 +39,7 @@ def get_parser():
SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword'))
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
Expand Down
Loading
Loading