agentweld 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.
- agentweld/__init__.py +3 -0
- agentweld/cli/__init__.py +0 -0
- agentweld/cli/add.py +142 -0
- agentweld/cli/generate.py +164 -0
- agentweld/cli/init.py +197 -0
- agentweld/cli/inspect.py +171 -0
- agentweld/cli/main.py +22 -0
- agentweld/cli/preview.py +122 -0
- agentweld/composition/__init__.py +1 -0
- agentweld/composition/composer.py +85 -0
- agentweld/config/__init__.py +6 -0
- agentweld/config/loader.py +117 -0
- agentweld/config/writer.py +235 -0
- agentweld/curation/__init__.py +1 -0
- agentweld/curation/engine.py +28 -0
- agentweld/curation/quality.py +77 -0
- agentweld/curation/rules.py +67 -0
- agentweld/generators/__init__.py +13 -0
- agentweld/generators/agent_card.py +76 -0
- agentweld/generators/base.py +27 -0
- agentweld/generators/readme.py +70 -0
- agentweld/generators/runner.py +87 -0
- agentweld/generators/system_prompt.py +81 -0
- agentweld/generators/templates/readme.md.j2 +18 -0
- agentweld/generators/templates/system_prompt.md.j2 +16 -0
- agentweld/generators/tool_manifest.py +63 -0
- agentweld/models/__init__.py +0 -0
- agentweld/models/artifacts.py +61 -0
- agentweld/models/composed.py +32 -0
- agentweld/models/config.py +162 -0
- agentweld/models/tool.py +99 -0
- agentweld/plugins/__init__.py +5 -0
- agentweld/plugins/loader.py +55 -0
- agentweld/sources/__init__.py +15 -0
- agentweld/sources/base.py +49 -0
- agentweld/sources/mcp_http.py +129 -0
- agentweld/sources/mcp_stdio.py +156 -0
- agentweld/sources/registry.py +120 -0
- agentweld/utils/__init__.py +0 -0
- agentweld/utils/console.py +105 -0
- agentweld/utils/errors.py +33 -0
- agentweld-0.1.0.dist-info/METADATA +330 -0
- agentweld-0.1.0.dist-info/RECORD +46 -0
- agentweld-0.1.0.dist-info/WHEEL +4 -0
- agentweld-0.1.0.dist-info/entry_points.txt +2 -0
- agentweld-0.1.0.dist-info/licenses/LICENSE +21 -0
agentweld/__init__.py
ADDED
|
File without changes
|
agentweld/cli/add.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""agentweld add — add a new MCP source to an existing project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from agentweld.config.loader import load_config
|
|
13
|
+
from agentweld.config.writer import add_source
|
|
14
|
+
from agentweld.models.config import SourceConfig
|
|
15
|
+
from agentweld.sources.registry import get_adapter
|
|
16
|
+
from agentweld.utils.console import console, make_tools_table
|
|
17
|
+
from agentweld.utils.errors import AgentweldError, ConfigNotFoundError, SourceConnectionError
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
help="Add a new MCP source to an existing agentweld project.",
|
|
21
|
+
invoke_without_command=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def add(
|
|
27
|
+
source: str = typer.Argument(..., help="MCP server command (stdio) or URL (http)"),
|
|
28
|
+
from_: str = typer.Option("mcp", "--from", help="Source type"),
|
|
29
|
+
trust: bool = typer.Option(False, "--trust", help="Trust and execute the stdio command"),
|
|
30
|
+
config_path: Path | None = typer.Option(None, "--config", "-c", help="Path to agentweld.yaml"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Load existing agentweld.yaml, introspect new source, and append it."""
|
|
33
|
+
|
|
34
|
+
# Determine transport
|
|
35
|
+
transport: Literal["stdio", "streamable-http"] = (
|
|
36
|
+
"streamable-http" if source.startswith("http") else "stdio"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Safety gate for stdio
|
|
40
|
+
if transport == "stdio":
|
|
41
|
+
if not trust:
|
|
42
|
+
console.print(
|
|
43
|
+
"[bold red]ERROR:[/] Spawning a stdio MCP server executes arbitrary code. "
|
|
44
|
+
"Re-run with [bold]--trust[/] to confirm.",
|
|
45
|
+
highlight=False,
|
|
46
|
+
)
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
console.print(
|
|
49
|
+
f"[bold yellow]WARNING:[/] --trust flag set. Spawning subprocess: [bold]{source}[/]",
|
|
50
|
+
highlight=False,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Load existing config to find the yaml path
|
|
54
|
+
try:
|
|
55
|
+
cfg = load_config(config_path)
|
|
56
|
+
except ConfigNotFoundError as e:
|
|
57
|
+
console.print(f"[red]Config not found:[/] {e}")
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
# Resolve the actual yaml file path
|
|
61
|
+
yaml_path = _resolve_yaml_path(config_path)
|
|
62
|
+
|
|
63
|
+
# Check if source already exists
|
|
64
|
+
source_id = _derive_source_id(source)
|
|
65
|
+
existing_ids = {s.id for s in cfg.sources}
|
|
66
|
+
if source_id in existing_ids:
|
|
67
|
+
console.print(
|
|
68
|
+
f"[red]ERROR:[/] Source '[bold]{source_id}[/]' already exists in {yaml_path}. "
|
|
69
|
+
"Use a different --name or edit the file manually.",
|
|
70
|
+
highlight=False,
|
|
71
|
+
)
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
|
|
74
|
+
# Build SourceConfig
|
|
75
|
+
source_config = SourceConfig(
|
|
76
|
+
id=source_id,
|
|
77
|
+
type="mcp_server",
|
|
78
|
+
transport=transport,
|
|
79
|
+
command=source if transport == "stdio" else None,
|
|
80
|
+
url=source if transport != "stdio" else None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Introspect
|
|
84
|
+
console.print(f"[cyan]Connecting to[/] {source}...")
|
|
85
|
+
adapter = get_adapter(transport)
|
|
86
|
+
try:
|
|
87
|
+
tools = anyio.run(adapter.introspect, source_config)
|
|
88
|
+
except SourceConnectionError as e:
|
|
89
|
+
console.print(f"[red]Connection failed:[/] {e}")
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
except AgentweldError as e:
|
|
92
|
+
console.print(f"[red]Error:[/] {e}")
|
|
93
|
+
raise typer.Exit(code=1)
|
|
94
|
+
|
|
95
|
+
console.print(f"[green]Discovered {len(tools)} tools.[/]")
|
|
96
|
+
tool_rows = [
|
|
97
|
+
{
|
|
98
|
+
"name": t.name,
|
|
99
|
+
"source_id": t.source_id,
|
|
100
|
+
"description": t.description_curated,
|
|
101
|
+
"quality_score": t.quality_score,
|
|
102
|
+
}
|
|
103
|
+
for t in tools
|
|
104
|
+
]
|
|
105
|
+
console.print(make_tools_table(tool_rows))
|
|
106
|
+
|
|
107
|
+
# Append to yaml
|
|
108
|
+
try:
|
|
109
|
+
add_source(source_config, yaml_path)
|
|
110
|
+
except ValueError as e:
|
|
111
|
+
console.print(f"[red]Failed to add source:[/] {e}")
|
|
112
|
+
raise typer.Exit(code=1)
|
|
113
|
+
|
|
114
|
+
console.print(f"[green]Added source '[bold]{source_id}[/]' to[/] {yaml_path}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _derive_source_id(source: str) -> str:
|
|
118
|
+
"""Derive a short source ID from a command or URL."""
|
|
119
|
+
parts = source.replace("https://", "").replace("http://", "").split()
|
|
120
|
+
token = parts[-1] if parts else source
|
|
121
|
+
slug = re.sub(r"[^a-z0-9]", "-", token.lower())
|
|
122
|
+
slug = re.sub(r"-+", "-", slug).strip("-")
|
|
123
|
+
segments = [s for s in slug.split("-") if len(s) > 2]
|
|
124
|
+
return segments[-1] if segments else slug[:20]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_yaml_path(config_path: Path | None) -> Path:
|
|
128
|
+
"""Resolve path to agentweld.yaml, searching from cwd if not specified."""
|
|
129
|
+
if config_path is not None:
|
|
130
|
+
return Path(config_path)
|
|
131
|
+
|
|
132
|
+
current = Path.cwd()
|
|
133
|
+
while True:
|
|
134
|
+
candidate = current / "agentweld.yaml"
|
|
135
|
+
if candidate.exists():
|
|
136
|
+
return candidate
|
|
137
|
+
parent = current.parent
|
|
138
|
+
if parent == current:
|
|
139
|
+
break
|
|
140
|
+
current = parent
|
|
141
|
+
|
|
142
|
+
return Path.cwd() / "agentweld.yaml"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""agentweld generate — run the full pipeline and emit artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from agentweld.composition.composer import ComposedToolSet, Composer
|
|
11
|
+
from agentweld.config.loader import load_config
|
|
12
|
+
from agentweld.curation.engine import CurationEngine
|
|
13
|
+
from agentweld.generators.runner import run_generators
|
|
14
|
+
from agentweld.models.config import AgentweldConfig, SourceConfig
|
|
15
|
+
from agentweld.models.tool import ToolDefinition
|
|
16
|
+
from agentweld.sources.registry import get_adapter
|
|
17
|
+
from agentweld.utils.console import console
|
|
18
|
+
from agentweld.utils.errors import (
|
|
19
|
+
AgentweldError,
|
|
20
|
+
ConfigNotFoundError,
|
|
21
|
+
QualityGateError,
|
|
22
|
+
SourceConnectionError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Run the full pipeline and generate artifacts.", invoke_without_command=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def generate(
|
|
30
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing artifacts"),
|
|
31
|
+
only: list[str] = typer.Option([], "--only", help="Only generate specific artifacts"),
|
|
32
|
+
config_path: Path | None = typer.Option(None, "--config", "-c", help="Path to agentweld.yaml"),
|
|
33
|
+
output_dir: Path | None = typer.Option(
|
|
34
|
+
None, "--output-dir", "-o", help="Override output directory"
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Full pipeline: load config → introspect → curate → compose → generate artifacts."""
|
|
38
|
+
|
|
39
|
+
# 1. Load config
|
|
40
|
+
try:
|
|
41
|
+
cfg = load_config(config_path)
|
|
42
|
+
except ConfigNotFoundError as e:
|
|
43
|
+
console.print(f"[red]Config not found:[/] {e}")
|
|
44
|
+
console.print("[muted]Run [bold]agentweld init[/] to create an agentweld.yaml first.[/]")
|
|
45
|
+
raise typer.Exit(code=1)
|
|
46
|
+
|
|
47
|
+
if not cfg.sources:
|
|
48
|
+
console.print("[yellow]No sources configured. Nothing to generate.[/]")
|
|
49
|
+
raise typer.Exit(code=0)
|
|
50
|
+
|
|
51
|
+
# 2. Introspect all sources concurrently
|
|
52
|
+
console.print("[cyan]Introspecting sources...[/]")
|
|
53
|
+
all_tools: list[ToolDefinition] = []
|
|
54
|
+
errors: dict[str, str] = {}
|
|
55
|
+
|
|
56
|
+
async def _introspect_all() -> None:
|
|
57
|
+
async def _introspect_one(src_cfg: SourceConfig) -> None:
|
|
58
|
+
transport = src_cfg.transport or "stdio"
|
|
59
|
+
try:
|
|
60
|
+
adapter = get_adapter(transport)
|
|
61
|
+
tools = await adapter.introspect(src_cfg)
|
|
62
|
+
all_tools.extend(tools)
|
|
63
|
+
console.print(f" [green]✓[/] {src_cfg.id}: {len(tools)} tool(s)")
|
|
64
|
+
except SourceConnectionError as e:
|
|
65
|
+
errors[src_cfg.id] = str(e)
|
|
66
|
+
console.print(f" [red]✗[/] {src_cfg.id}: {e}")
|
|
67
|
+
|
|
68
|
+
async with anyio.create_task_group() as tg:
|
|
69
|
+
for src_cfg in cfg.sources:
|
|
70
|
+
tg.start_soon(_introspect_one, src_cfg)
|
|
71
|
+
|
|
72
|
+
anyio.run(_introspect_all)
|
|
73
|
+
|
|
74
|
+
if errors and not force:
|
|
75
|
+
console.print(
|
|
76
|
+
f"[red]ERROR:[/] {len(errors)} source(s) failed to connect. "
|
|
77
|
+
"Use [bold]--force[/] to skip failed sources.",
|
|
78
|
+
highlight=False,
|
|
79
|
+
)
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
|
|
82
|
+
if not all_tools:
|
|
83
|
+
console.print("[yellow]No tools discovered. Nothing to generate.[/]")
|
|
84
|
+
raise typer.Exit(code=0)
|
|
85
|
+
|
|
86
|
+
console.print(f"[cyan]Total tools discovered:[/] {len(all_tools)}")
|
|
87
|
+
|
|
88
|
+
# 3. Curate
|
|
89
|
+
console.print("[cyan]Running curation engine...[/]")
|
|
90
|
+
try:
|
|
91
|
+
engine = CurationEngine(cfg)
|
|
92
|
+
curated_tools = engine.run(all_tools)
|
|
93
|
+
except AgentweldError as e:
|
|
94
|
+
console.print(f"[red]Curation error:[/] {e}")
|
|
95
|
+
raise typer.Exit(code=1)
|
|
96
|
+
|
|
97
|
+
# 4. Quality gate
|
|
98
|
+
if not force:
|
|
99
|
+
try:
|
|
100
|
+
_check_quality_gate(curated_tools, cfg)
|
|
101
|
+
except QualityGateError as e:
|
|
102
|
+
console.print(f"[red]Quality gate failed:[/] {e}")
|
|
103
|
+
raise typer.Exit(code=1)
|
|
104
|
+
|
|
105
|
+
# 5. Compose
|
|
106
|
+
console.print("[cyan]Composing tool namespace...[/]")
|
|
107
|
+
composed: ComposedToolSet | None = None
|
|
108
|
+
try:
|
|
109
|
+
composer = Composer(cfg)
|
|
110
|
+
composed = composer.compose(curated_tools)
|
|
111
|
+
except AgentweldError as e:
|
|
112
|
+
console.print(f"[red]Composition error:[/] {e}")
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
|
|
115
|
+
# 6. Generate artifacts
|
|
116
|
+
out_dir = output_dir or Path(cfg.generate.output_dir)
|
|
117
|
+
if not force and out_dir.exists() and any(out_dir.iterdir()):
|
|
118
|
+
console.print(
|
|
119
|
+
f"[red]ERROR:[/] Output directory [bold]{out_dir}[/] already exists and is non-empty. "
|
|
120
|
+
"Use [bold]--force[/] to overwrite.",
|
|
121
|
+
highlight=False,
|
|
122
|
+
)
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
|
|
125
|
+
console.print(f"[cyan]Generating artifacts in[/] {out_dir}...")
|
|
126
|
+
try:
|
|
127
|
+
artifacts = run_generators(
|
|
128
|
+
cfg=cfg,
|
|
129
|
+
tools=curated_tools,
|
|
130
|
+
composed=composed,
|
|
131
|
+
output_dir=out_dir,
|
|
132
|
+
only=only or None,
|
|
133
|
+
force=force,
|
|
134
|
+
)
|
|
135
|
+
_print_artifact_summary(artifacts, out_dir)
|
|
136
|
+
except AgentweldError as e:
|
|
137
|
+
console.print(f"[red]Generator error:[/] {e}")
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_quality_gate(tools: list[ToolDefinition], cfg: AgentweldConfig) -> None:
|
|
142
|
+
"""Raise QualityGateError if any tool is below the block_below threshold."""
|
|
143
|
+
block_below = cfg.quality.block_below
|
|
144
|
+
blocking = [t for t in tools if t.quality_score is not None and t.quality_score < block_below]
|
|
145
|
+
if blocking:
|
|
146
|
+
names = ", ".join(t.name for t in blocking[:5])
|
|
147
|
+
if len(blocking) > 5:
|
|
148
|
+
names += f" ... and {len(blocking) - 5} more"
|
|
149
|
+
raise QualityGateError(
|
|
150
|
+
f"{len(blocking)} tool(s) below quality threshold "
|
|
151
|
+
f"({block_below:.2f}): {names}. "
|
|
152
|
+
"Fix the tools or run with --force to bypass."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _print_artifact_summary(artifacts: list[Path], output_dir: Path) -> None:
|
|
157
|
+
"""Print a summary of generated artifact files."""
|
|
158
|
+
console.print(f"\n[green]Generated {len(artifacts)} artifact(s) in[/] {output_dir}:")
|
|
159
|
+
for path in sorted(artifacts):
|
|
160
|
+
try:
|
|
161
|
+
rel = path.relative_to(output_dir)
|
|
162
|
+
except ValueError:
|
|
163
|
+
rel = path
|
|
164
|
+
console.print(f" [muted]•[/] {rel}")
|
agentweld/cli/init.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""agentweld init — scaffold a new project from an MCP source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from agentweld.config.writer import write_new
|
|
14
|
+
from agentweld.models.config import (
|
|
15
|
+
AgentConfig,
|
|
16
|
+
AgentweldConfig,
|
|
17
|
+
CompositionConfig,
|
|
18
|
+
EnrichmentConfig,
|
|
19
|
+
GenerateConfig,
|
|
20
|
+
MetaConfig,
|
|
21
|
+
QualityConfig,
|
|
22
|
+
SourceConfig,
|
|
23
|
+
ToolsConfig,
|
|
24
|
+
)
|
|
25
|
+
from agentweld.sources.registry import get_adapter
|
|
26
|
+
from agentweld.utils.console import console, make_tools_table
|
|
27
|
+
from agentweld.utils.errors import AgentweldError, SourceConnectionError
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
help="Initialize a new agentweld project from an MCP source.",
|
|
31
|
+
invoke_without_command=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def init(
|
|
37
|
+
source: str = typer.Argument(..., help="MCP server command (stdio) or URL (http)"),
|
|
38
|
+
from_: str = typer.Option("mcp", "--from", help="Source type"),
|
|
39
|
+
trust: bool = typer.Option(False, "--trust", help="Trust and execute the stdio command"),
|
|
40
|
+
output: Path = typer.Option(Path("."), "--output", "-o", help="Output directory"),
|
|
41
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Agent name"),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Connect to MCP server, introspect tools, scaffold agentweld.yaml."""
|
|
44
|
+
|
|
45
|
+
# Determine transport
|
|
46
|
+
transport: Literal["stdio", "streamable-http"] = (
|
|
47
|
+
"streamable-http" if source.startswith("http") else "stdio"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Safety gate for stdio
|
|
51
|
+
if transport == "stdio":
|
|
52
|
+
if not trust:
|
|
53
|
+
console.print(
|
|
54
|
+
"[bold red]ERROR:[/] Spawning a stdio MCP server executes arbitrary code. "
|
|
55
|
+
"Re-run with [bold]--trust[/] to confirm.",
|
|
56
|
+
highlight=False,
|
|
57
|
+
)
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
console.print(
|
|
60
|
+
f"[bold yellow]WARNING:[/] --trust flag set. Spawning subprocess: [bold]{source}[/]",
|
|
61
|
+
highlight=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Build a minimal SourceConfig
|
|
65
|
+
source_id = _derive_source_id(source)
|
|
66
|
+
source_config = SourceConfig(
|
|
67
|
+
id=source_id,
|
|
68
|
+
type="mcp_server",
|
|
69
|
+
transport=transport,
|
|
70
|
+
command=source if transport == "stdio" else None,
|
|
71
|
+
url=source if transport != "stdio" else None,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Introspect
|
|
75
|
+
console.print(f"[cyan]Connecting to[/] {source}...")
|
|
76
|
+
adapter = get_adapter(transport)
|
|
77
|
+
try:
|
|
78
|
+
tools = anyio.run(adapter.introspect, source_config)
|
|
79
|
+
except SourceConnectionError as e:
|
|
80
|
+
console.print(f"[red]Connection failed:[/] {e}")
|
|
81
|
+
raise typer.Exit(code=1)
|
|
82
|
+
except AgentweldError as e:
|
|
83
|
+
console.print(f"[red]Error:[/] {e}")
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
|
|
86
|
+
console.print(f"[green]Discovered {len(tools)} tools.[/]")
|
|
87
|
+
tool_rows = [
|
|
88
|
+
{
|
|
89
|
+
"name": t.name,
|
|
90
|
+
"source_id": t.source_id,
|
|
91
|
+
"description": t.description_curated,
|
|
92
|
+
"quality_score": t.quality_score,
|
|
93
|
+
}
|
|
94
|
+
for t in tools
|
|
95
|
+
]
|
|
96
|
+
console.print(make_tools_table(tool_rows))
|
|
97
|
+
|
|
98
|
+
# Build config
|
|
99
|
+
agent_name = name or _derive_agent_name(source)
|
|
100
|
+
config = _build_initial_config(agent_name, source_config)
|
|
101
|
+
|
|
102
|
+
# Write agentweld.yaml
|
|
103
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
yaml_path = output / "agentweld.yaml"
|
|
105
|
+
write_new(config, yaml_path)
|
|
106
|
+
console.print(f"[green]Created[/] {yaml_path}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _derive_source_id(source: str) -> str:
|
|
110
|
+
"""Derive a short source ID from a command or URL.
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
"npx @modelcontextprotocol/server-github" -> "github"
|
|
114
|
+
"docker run -i --rm -e TOKEN ghcr.io/github/github-mcp-server" -> "github"
|
|
115
|
+
"https://api.example.com/mcp" -> "example"
|
|
116
|
+
"""
|
|
117
|
+
parts = source.replace("https://", "").replace("http://", "").split()
|
|
118
|
+
# For docker commands, find the image argument (last non-flag token after "run")
|
|
119
|
+
if parts and parts[0] == "docker":
|
|
120
|
+
image = _extract_docker_image(parts)
|
|
121
|
+
token = image.split("/")[-1] if image else parts[-1]
|
|
122
|
+
else:
|
|
123
|
+
token = parts[-1] if parts else source
|
|
124
|
+
slug = re.sub(r"[^a-z0-9]", "-", token.lower())
|
|
125
|
+
slug = re.sub(r"-+", "-", slug).strip("-")
|
|
126
|
+
segments = [s for s in slug.split("-") if len(s) > 2]
|
|
127
|
+
# For docker images and similar "name-type-suffix" patterns, prefer the first
|
|
128
|
+
# meaningful segment (e.g. "github-mcp-server" -> "github").
|
|
129
|
+
# For npx packages like "server-github", prefer the last (-> "github").
|
|
130
|
+
if parts and parts[0] == "docker":
|
|
131
|
+
return segments[0] if segments else slug[:20]
|
|
132
|
+
return segments[-1] if segments else slug[:20]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _extract_docker_image(parts: list[str]) -> str:
|
|
136
|
+
"""Return the image name from a 'docker run ...' token list.
|
|
137
|
+
|
|
138
|
+
Skips flags (starting with '-') and their values for known value-taking flags.
|
|
139
|
+
"""
|
|
140
|
+
_VALUE_FLAGS = {
|
|
141
|
+
"-e",
|
|
142
|
+
"--env",
|
|
143
|
+
"-p",
|
|
144
|
+
"--publish",
|
|
145
|
+
"--name",
|
|
146
|
+
"-v",
|
|
147
|
+
"--volume",
|
|
148
|
+
"--network",
|
|
149
|
+
"-u",
|
|
150
|
+
"--user",
|
|
151
|
+
"--entrypoint",
|
|
152
|
+
"-w",
|
|
153
|
+
"--workdir",
|
|
154
|
+
}
|
|
155
|
+
skip_next = False
|
|
156
|
+
past_run = False
|
|
157
|
+
for token in parts:
|
|
158
|
+
if token == "run":
|
|
159
|
+
past_run = True
|
|
160
|
+
continue
|
|
161
|
+
if not past_run:
|
|
162
|
+
continue
|
|
163
|
+
if skip_next:
|
|
164
|
+
skip_next = False
|
|
165
|
+
continue
|
|
166
|
+
if token in _VALUE_FLAGS:
|
|
167
|
+
skip_next = True
|
|
168
|
+
continue
|
|
169
|
+
if token.startswith("-"):
|
|
170
|
+
continue
|
|
171
|
+
# First non-flag token after 'run' is the image
|
|
172
|
+
return token
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _derive_agent_name(source: str) -> str:
|
|
177
|
+
sid = _derive_source_id(source)
|
|
178
|
+
return f"{sid.title()} Agent"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _build_initial_config(agent_name: str, source: SourceConfig) -> AgentweldConfig:
|
|
182
|
+
now = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0)
|
|
183
|
+
return AgentweldConfig(
|
|
184
|
+
meta=MetaConfig(created_at=now, updated_at=now),
|
|
185
|
+
agent=AgentConfig(
|
|
186
|
+
name=agent_name,
|
|
187
|
+
description=f"Agent powered by {source.id}",
|
|
188
|
+
version="0.1.0",
|
|
189
|
+
),
|
|
190
|
+
sources=[source],
|
|
191
|
+
tools=ToolsConfig(),
|
|
192
|
+
quality=QualityConfig(),
|
|
193
|
+
enrichment=EnrichmentConfig(),
|
|
194
|
+
composition=CompositionConfig(),
|
|
195
|
+
a2a=None,
|
|
196
|
+
generate=GenerateConfig(),
|
|
197
|
+
)
|
agentweld/cli/inspect.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""agentweld inspect — inspect tools from configured MCP sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from agentweld.config.loader import load_config
|
|
12
|
+
from agentweld.models.config import AgentweldConfig, SourceConfig
|
|
13
|
+
from agentweld.models.tool import ToolDefinition
|
|
14
|
+
from agentweld.sources.registry import get_adapter
|
|
15
|
+
from agentweld.utils.console import console, make_sources_table, make_tools_table
|
|
16
|
+
from agentweld.utils.errors import ConfigNotFoundError, SourceConnectionError
|
|
17
|
+
|
|
18
|
+
# STUB: replace with real import after phase-4/5 merge
|
|
19
|
+
try:
|
|
20
|
+
from agentweld.composition.composer import ComposedToolSet, Composer
|
|
21
|
+
from agentweld.curation.engine import CurationEngine
|
|
22
|
+
except ImportError:
|
|
23
|
+
CurationEngine = None # type: ignore[assignment,misc]
|
|
24
|
+
Composer = None # type: ignore[assignment,misc]
|
|
25
|
+
ComposedToolSet = None # type: ignore[assignment,misc]
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(help="Inspect tools from configured MCP sources.", invoke_without_command=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def inspect(
|
|
32
|
+
source: bool = typer.Option(False, "--source", help="Show raw tools from each source"),
|
|
33
|
+
final: bool = typer.Option(False, "--final", help="Show post-curation tools"),
|
|
34
|
+
conflicts: bool = typer.Option(False, "--conflicts", help="Show naming conflicts"),
|
|
35
|
+
config_path: Path | None = typer.Option(None, "--config", "-c", help="Path to agentweld.yaml"),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Inspect and display tools from all configured MCP sources."""
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
cfg = load_config(config_path)
|
|
41
|
+
except ConfigNotFoundError as e:
|
|
42
|
+
console.print(f"[red]Config not found:[/] {e}")
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
|
|
45
|
+
if not cfg.sources:
|
|
46
|
+
console.print("[yellow]No sources configured in agentweld.yaml.[/]")
|
|
47
|
+
raise typer.Exit(code=0)
|
|
48
|
+
|
|
49
|
+
# Introspect all sources concurrently
|
|
50
|
+
all_tools: list[ToolDefinition] = []
|
|
51
|
+
tools_by_source: dict[str, list[ToolDefinition]] = {}
|
|
52
|
+
errors: dict[str, str] = {}
|
|
53
|
+
|
|
54
|
+
async def _introspect_all() -> None:
|
|
55
|
+
async def _introspect_one(src_cfg: SourceConfig) -> None:
|
|
56
|
+
transport = src_cfg.transport or "stdio"
|
|
57
|
+
try:
|
|
58
|
+
adapter = get_adapter(transport)
|
|
59
|
+
tools = await adapter.introspect(src_cfg)
|
|
60
|
+
tools_by_source[src_cfg.id] = tools
|
|
61
|
+
all_tools.extend(tools)
|
|
62
|
+
except SourceConnectionError as e:
|
|
63
|
+
errors[src_cfg.id] = str(e)
|
|
64
|
+
tools_by_source[src_cfg.id] = []
|
|
65
|
+
|
|
66
|
+
async with anyio.create_task_group() as tg:
|
|
67
|
+
for src_cfg in cfg.sources:
|
|
68
|
+
tg.start_soon(_introspect_one, src_cfg)
|
|
69
|
+
|
|
70
|
+
anyio.run(_introspect_all)
|
|
71
|
+
|
|
72
|
+
# Report connection errors
|
|
73
|
+
for src_id, err in errors.items():
|
|
74
|
+
console.print(f"[red]ERROR[/] [{src_id}]: {err}")
|
|
75
|
+
|
|
76
|
+
if not source and not final and not conflicts:
|
|
77
|
+
# Default: show summary table
|
|
78
|
+
_show_summary(tools_by_source)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if source:
|
|
82
|
+
_show_raw_tools(tools_by_source)
|
|
83
|
+
|
|
84
|
+
if conflicts:
|
|
85
|
+
_show_conflicts(all_tools)
|
|
86
|
+
|
|
87
|
+
if final:
|
|
88
|
+
_show_final_tools(all_tools, cfg)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _show_summary(tools_by_source: dict[str, list[ToolDefinition]]) -> None:
|
|
92
|
+
"""Display a summary table: one row per source."""
|
|
93
|
+
rows = []
|
|
94
|
+
for src_id, tools in tools_by_source.items():
|
|
95
|
+
scored = [t for t in tools if t.quality_score is not None]
|
|
96
|
+
scores = [t.quality_score for t in scored if t.quality_score is not None]
|
|
97
|
+
avg_quality: float | None = sum(scores) / len(scores) if scores else None
|
|
98
|
+
rows.append({"source": src_id, "tools": len(tools), "avg_quality": avg_quality})
|
|
99
|
+
console.print(make_sources_table(rows))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _show_raw_tools(tools_by_source: dict[str, list[ToolDefinition]]) -> None:
|
|
103
|
+
"""Display raw (pre-curation) tools per source."""
|
|
104
|
+
for src_id, tools in tools_by_source.items():
|
|
105
|
+
console.print(f"\n[bold cyan]Source: {src_id}[/] ({len(tools)} tools)")
|
|
106
|
+
if tools:
|
|
107
|
+
tool_rows = [
|
|
108
|
+
{
|
|
109
|
+
"name": t.name,
|
|
110
|
+
"source_id": t.source_id,
|
|
111
|
+
"description": t.description_original,
|
|
112
|
+
"quality_score": t.quality_score,
|
|
113
|
+
}
|
|
114
|
+
for t in tools
|
|
115
|
+
]
|
|
116
|
+
console.print(make_tools_table(tool_rows, show_quality=True))
|
|
117
|
+
else:
|
|
118
|
+
console.print("[muted] (no tools)[/]")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _show_conflicts(all_tools: list[ToolDefinition]) -> None:
|
|
122
|
+
"""Detect and display tools with duplicate names across sources."""
|
|
123
|
+
name_to_tools: dict[str, list[ToolDefinition]] = defaultdict(list)
|
|
124
|
+
for t in all_tools:
|
|
125
|
+
name_to_tools[t.name].append(t)
|
|
126
|
+
|
|
127
|
+
conflicts_found = {name: tools for name, tools in name_to_tools.items() if len(tools) > 1}
|
|
128
|
+
|
|
129
|
+
if not conflicts_found:
|
|
130
|
+
console.print("[green]No naming conflicts detected.[/]")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
console.print(f"[bold yellow]Found {len(conflicts_found)} naming conflict(s):[/]")
|
|
134
|
+
for name, tools in conflicts_found.items():
|
|
135
|
+
sources_list = ", ".join(t.source_id for t in tools)
|
|
136
|
+
console.print(f" [bold]{name}[/] — appears in: {sources_list}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _show_final_tools(all_tools: list[ToolDefinition], cfg: AgentweldConfig) -> None:
|
|
140
|
+
"""Display post-curation tools (requires Phase 4 CurationEngine)."""
|
|
141
|
+
if CurationEngine is None:
|
|
142
|
+
console.print(
|
|
143
|
+
"[yellow]WARNING:[/] CurationEngine not available (Phase 4 not merged). "
|
|
144
|
+
"Showing raw tools instead.",
|
|
145
|
+
highlight=False,
|
|
146
|
+
)
|
|
147
|
+
tool_rows = [
|
|
148
|
+
{
|
|
149
|
+
"name": t.name,
|
|
150
|
+
"source_id": t.source_id,
|
|
151
|
+
"description": t.description_curated,
|
|
152
|
+
"quality_score": t.quality_score,
|
|
153
|
+
}
|
|
154
|
+
for t in all_tools
|
|
155
|
+
]
|
|
156
|
+
console.print(make_tools_table(tool_rows, show_quality=True))
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
engine = CurationEngine(cfg)
|
|
160
|
+
curated = engine.run(all_tools)
|
|
161
|
+
tool_rows = [
|
|
162
|
+
{
|
|
163
|
+
"name": t.name,
|
|
164
|
+
"source_id": t.source_id,
|
|
165
|
+
"description": t.description_curated,
|
|
166
|
+
"quality_score": t.quality_score,
|
|
167
|
+
}
|
|
168
|
+
for t in curated
|
|
169
|
+
]
|
|
170
|
+
console.print(f"\n[bold cyan]Post-curation tools[/] ({len(curated)} tools)")
|
|
171
|
+
console.print(make_tools_table(tool_rows, show_quality=True))
|