echomodel 0.2.1__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.
- aicfg/__init__.py +0 -0
- aicfg/cli/__init__.py +24 -0
- aicfg/cli/commands.py +185 -0
- aicfg/cli/context.py +183 -0
- aicfg/cli/servers.py +143 -0
- aicfg/cli/sessions.py +54 -0
- aicfg/cli/settings.py +144 -0
- aicfg/cli/skills.py +294 -0
- aicfg/mcp/__init__.py +0 -0
- aicfg/mcp/server.py +340 -0
- aicfg/sdk/__init__.py +0 -0
- aicfg/sdk/commands.py +171 -0
- aicfg/sdk/config.py +91 -0
- aicfg/sdk/context.py +475 -0
- aicfg/sdk/mcp_setup.py +305 -0
- aicfg/sdk/sessions.py +263 -0
- aicfg/sdk/settings.py +175 -0
- aicfg/sdk/skills.py +1210 -0
- aicfg/sdk/utils.py +79 -0
- echomodel-0.2.1.dist-info/METADATA +12 -0
- echomodel-0.2.1.dist-info/RECORD +24 -0
- echomodel-0.2.1.dist-info/WHEEL +5 -0
- echomodel-0.2.1.dist-info/entry_points.txt +5 -0
- echomodel-0.2.1.dist-info/top_level.txt +1 -0
aicfg/__init__.py
ADDED
|
File without changes
|
aicfg/cli/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from aicfg.cli.commands import cmds
|
|
3
|
+
from aicfg.cli.context import context_cli
|
|
4
|
+
from aicfg.cli.settings import paths, settings, allowed_tools
|
|
5
|
+
from aicfg.cli.servers import mcp_servers
|
|
6
|
+
from aicfg.cli.sessions import claude_cli
|
|
7
|
+
from aicfg.cli.skills import skills
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def cli():
|
|
11
|
+
"""AI Config Manager (aicfg)"""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
cli.add_command(cmds)
|
|
15
|
+
cli.add_command(context_cli)
|
|
16
|
+
cli.add_command(paths)
|
|
17
|
+
cli.add_command(settings)
|
|
18
|
+
cli.add_command(allowed_tools)
|
|
19
|
+
cli.add_command(mcp_servers)
|
|
20
|
+
cli.add_command(claude_cli)
|
|
21
|
+
cli.add_command(skills)
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
cli()
|
aicfg/cli/commands.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import toml
|
|
3
|
+
import json
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich import print as rprint
|
|
7
|
+
from aicfg.sdk import commands as sdk
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def cmds():
|
|
13
|
+
"""Manage Gemini slash commands (TOML files)."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@cmds.command()
|
|
17
|
+
@click.argument("name")
|
|
18
|
+
@click.argument("prompt", required=False)
|
|
19
|
+
@click.option("--desc", "-d", help="Description of the command")
|
|
20
|
+
@click.option("--scope", type=click.Choice(["user", "project"]),
|
|
21
|
+
default="user", help="Where to create the command (default: user)")
|
|
22
|
+
@click.option("--namespace", "-ns", help="Optional namespace (subdirectory) for the command")
|
|
23
|
+
def add(name, prompt, desc, scope, namespace):
|
|
24
|
+
"""Add a new command."""
|
|
25
|
+
if not prompt:
|
|
26
|
+
description = desc or "My custom command"
|
|
27
|
+
default_content = (
|
|
28
|
+
f'description = "{description}"\n'
|
|
29
|
+
'prompt = """\n'
|
|
30
|
+
'Write your prompt here...\n'
|
|
31
|
+
'"""'
|
|
32
|
+
)
|
|
33
|
+
edited = click.edit(default_content, extension=".toml")
|
|
34
|
+
if not edited:
|
|
35
|
+
rprint("[red]Aborted.[/red] No content provided.")
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
data = toml.loads(edited)
|
|
39
|
+
prompt = data.get("prompt")
|
|
40
|
+
desc = data.get("description", desc)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
rprint(f"[red]Invalid TOML:[/red] {e}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
path = sdk.add_command(name, prompt, desc, scope=scope, namespace=namespace)
|
|
46
|
+
rprint(f"[green]Created[/green] {path} (Scope: [bold]{scope.upper()}[/bold])")
|
|
47
|
+
|
|
48
|
+
@cmds.command("list")
|
|
49
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
50
|
+
@click.option("--format", type=click.Choice(["text", "json"]),
|
|
51
|
+
default="text", help="Output format.")
|
|
52
|
+
@click.option("--filter", "filter_pattern", help="Filter by name (supports wildcards e.g. 'commit*')")
|
|
53
|
+
@click.option("--scope", "scopes", multiple=True, type=click.Choice(["user", "registry", "project"]),
|
|
54
|
+
help="Filter by scope (can specify multiple)")
|
|
55
|
+
def list_cmds(as_json, format, filter_pattern, scopes):
|
|
56
|
+
"""List all commands. Optional filters for name and scope."""
|
|
57
|
+
|
|
58
|
+
# Convert tuple to list for SDK, or None if empty (implies all)
|
|
59
|
+
scope_list = list(scopes) if scopes else None
|
|
60
|
+
|
|
61
|
+
results = sdk.list_commands(filter_pattern=filter_pattern, scopes=scope_list)
|
|
62
|
+
results = sorted(results, key=lambda x: x["name"])
|
|
63
|
+
|
|
64
|
+
if as_json or format == "json":
|
|
65
|
+
console.print_json(data=results)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
table = Table(title="Custom Slash Commands")
|
|
69
|
+
table.add_column("Command", style="cyan")
|
|
70
|
+
|
|
71
|
+
# Determine which columns to show
|
|
72
|
+
show_user = not scope_list or "user" in scope_list
|
|
73
|
+
show_reg = not scope_list or "registry" in scope_list
|
|
74
|
+
show_proj = not scope_list or "project" in scope_list
|
|
75
|
+
|
|
76
|
+
if show_user: table.add_column("User", justify="center")
|
|
77
|
+
if show_reg: table.add_column("Registry", justify="center")
|
|
78
|
+
if show_proj: table.add_column("Project", justify="center")
|
|
79
|
+
|
|
80
|
+
for item in results:
|
|
81
|
+
# Define icons
|
|
82
|
+
# Green check if identical (synced), Yellow not-equal sign if diff
|
|
83
|
+
synced = item["synced"]
|
|
84
|
+
icon = "[green]✅[/green]" if synced else "[yellow] ≠ [/yellow]"
|
|
85
|
+
|
|
86
|
+
row = [item["name"]]
|
|
87
|
+
|
|
88
|
+
if show_user:
|
|
89
|
+
row.append(icon if item["user"]["exists"] else "[dim]- [/dim]")
|
|
90
|
+
if show_reg:
|
|
91
|
+
row.append(icon if item["registry"]["exists"] else "[dim]- [/dim]")
|
|
92
|
+
if show_proj:
|
|
93
|
+
row.append(icon if item["project"]["exists"] else "[dim]- [/dim]")
|
|
94
|
+
|
|
95
|
+
table.add_row(*row)
|
|
96
|
+
|
|
97
|
+
console.print(table)
|
|
98
|
+
|
|
99
|
+
@cmds.command()
|
|
100
|
+
@click.argument("name")
|
|
101
|
+
@click.option("--update", is_flag=True, help="Overwrite if command exists in registry and content differs.")
|
|
102
|
+
@click.option("--source-scope", type=click.Choice(["user", "project"]), help="Explicitly choose source scope for registration.")
|
|
103
|
+
def register(name, update, source_scope):
|
|
104
|
+
"""Register a command from user/project scope to the registry."""
|
|
105
|
+
try:
|
|
106
|
+
path = sdk.register_command(name, update=update, source_scope=source_scope)
|
|
107
|
+
rprint(f"[green]Registered[/green] '{name}' to {path}")
|
|
108
|
+
rprint(f"[blue]Note:[/blue] Changes to the registry repo ({path.parent}) will need to be committed and pushed to GitHub.")
|
|
109
|
+
except (ValueError, FileNotFoundError, FileExistsError) as e:
|
|
110
|
+
rprint(f"[red]Error:[/red] {e}")
|
|
111
|
+
exit(1)
|
|
112
|
+
|
|
113
|
+
@cmds.command()
|
|
114
|
+
@click.argument("name")
|
|
115
|
+
def show(name):
|
|
116
|
+
"""Show details of a command."""
|
|
117
|
+
data = sdk.get_command(name)
|
|
118
|
+
if not data:
|
|
119
|
+
rprint(f"[red]Error:[/red] Command '{name}' not found.")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
rprint(f"[bold]Description:[/bold] {data.get('description')}")
|
|
123
|
+
rprint("[bold]Prompt:[/bold]")
|
|
124
|
+
rprint(data.get("prompt"))
|
|
125
|
+
|
|
126
|
+
@cmds.command()
|
|
127
|
+
@click.argument("name")
|
|
128
|
+
@click.option("--scope", type=click.Choice(["user", "project", "registry"]),
|
|
129
|
+
default="user", help="Scope to remove from (default: user)")
|
|
130
|
+
def remove(name, scope):
|
|
131
|
+
"""Remove a command from local, project, or registry scope."""
|
|
132
|
+
success = sdk.delete_command(name, scope=scope)
|
|
133
|
+
if success:
|
|
134
|
+
rprint(f"[green]Removed[/green] '{name}' from {scope} scope.")
|
|
135
|
+
else:
|
|
136
|
+
rprint(f"[red]Error:[/red] Command '{name}' not found in {scope} scope.")
|
|
137
|
+
|
|
138
|
+
@cmds.command()
|
|
139
|
+
@click.argument("name")
|
|
140
|
+
def publish(name):
|
|
141
|
+
"""Publish a command from User scope to the Registry."""
|
|
142
|
+
try:
|
|
143
|
+
path = sdk.publish_command(name)
|
|
144
|
+
rprint(f"[green]Published[/green] '{name}' to Registry ({path})")
|
|
145
|
+
except FileNotFoundError as e:
|
|
146
|
+
rprint(f"[red]Error:[/red] {e}")
|
|
147
|
+
|
|
148
|
+
@cmds.command()
|
|
149
|
+
@click.argument("name")
|
|
150
|
+
def install(name):
|
|
151
|
+
"""Install a command from Registry to User scope."""
|
|
152
|
+
try:
|
|
153
|
+
path = sdk.install_command(name)
|
|
154
|
+
rprint(f"[green]Installed[/green] '{name}' to User scope ({path})")
|
|
155
|
+
except FileNotFoundError as e:
|
|
156
|
+
rprint(f"[red]Error:[/red] {e}")
|
|
157
|
+
|
|
158
|
+
@cmds.command()
|
|
159
|
+
@click.argument("name")
|
|
160
|
+
def diff(name):
|
|
161
|
+
"""Show differences between User and Registry versions."""
|
|
162
|
+
result = sdk.get_diff(name)
|
|
163
|
+
if not result:
|
|
164
|
+
rprint(f"Command '{name}' cannot be diffed (must exist in both User and Registry).")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
repo_lines, xdg_lines = result
|
|
168
|
+
import difflib
|
|
169
|
+
|
|
170
|
+
diff = difflib.unified_diff(
|
|
171
|
+
repo_lines, xdg_lines,
|
|
172
|
+
fromfile=f"Registry ({name})",
|
|
173
|
+
tofile=f"User ({name})",
|
|
174
|
+
lineterm=""
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
for line in diff:
|
|
178
|
+
if line.startswith("+"):
|
|
179
|
+
console.print(line, style="green", end="")
|
|
180
|
+
elif line.startswith("-"):
|
|
181
|
+
console.print(line, style="red", end="")
|
|
182
|
+
elif line.startswith("@"):
|
|
183
|
+
console.print(line, style="cyan", end="")
|
|
184
|
+
else:
|
|
185
|
+
print(line, end="")
|
aicfg/cli/context.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""CLI commands for managing AI assistant context files."""
|
|
2
|
+
import json
|
|
3
|
+
import click
|
|
4
|
+
from rich import print as rprint
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from aicfg.sdk import context as context_sdk
|
|
7
|
+
from aicfg.sdk import settings as settings_sdk
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group(name="context")
|
|
11
|
+
def context_cli():
|
|
12
|
+
"""Manage AI assistant context files."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@context_cli.command("status")
|
|
17
|
+
@click.option("--scope", type=click.Choice(["user", "project"]), default=None,
|
|
18
|
+
help="Filter to specific scope (default: show all)")
|
|
19
|
+
@click.option("--format", "output_format", type=click.Choice(["table", "json"]), default="table")
|
|
20
|
+
def status(scope, output_format):
|
|
21
|
+
"""Show the current state of context files for both user and project scopes."""
|
|
22
|
+
result = context_sdk.get_context_status(scope)
|
|
23
|
+
|
|
24
|
+
if output_format == "json":
|
|
25
|
+
rprint(json.dumps(result, indent=2))
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
rprint(f"[dim]Working directory:[/dim] {result['working_directory']}")
|
|
29
|
+
if result.get("git_root"):
|
|
30
|
+
rprint(f"[dim]Git root:[/dim] {result['git_root']}")
|
|
31
|
+
rprint()
|
|
32
|
+
|
|
33
|
+
for scope_name, scope_data in result["scopes"].items():
|
|
34
|
+
table = Table(title=f"{scope_name.title()} Scope")
|
|
35
|
+
table.add_column("File", style="cyan")
|
|
36
|
+
table.add_column("Status", style="green")
|
|
37
|
+
table.add_column("Details", style="dim")
|
|
38
|
+
|
|
39
|
+
for file_name, info in scope_data["files"].items():
|
|
40
|
+
if not info["exists"]:
|
|
41
|
+
file_status = "[yellow]missing[/yellow]"
|
|
42
|
+
details = info["path"]
|
|
43
|
+
elif info["is_symlink"]:
|
|
44
|
+
if info.get("points_to_unified"):
|
|
45
|
+
file_status = "[green]symlink (unified)[/green]"
|
|
46
|
+
else:
|
|
47
|
+
file_status = "[red]symlink (other)[/red]"
|
|
48
|
+
details = f"-> {info['symlink_target']}"
|
|
49
|
+
else:
|
|
50
|
+
file_status = "present"
|
|
51
|
+
details = info["path"]
|
|
52
|
+
|
|
53
|
+
table.add_row(file_name, file_status, details)
|
|
54
|
+
|
|
55
|
+
rprint(table)
|
|
56
|
+
rprint(f"[dim]State: {scope_data['state']}[/dim]\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@context_cli.command("unify")
|
|
60
|
+
@click.option("--scope", type=click.Choice(["user", "project"]), default="user",
|
|
61
|
+
help="Scope to unify (default: user)")
|
|
62
|
+
def unify(scope):
|
|
63
|
+
"""
|
|
64
|
+
Unify CLAUDE.md and GEMINI.md into a single shared CONTEXT.md.
|
|
65
|
+
|
|
66
|
+
Combines context files into a unified location, then creates symlinks
|
|
67
|
+
so both tools read from the same file.
|
|
68
|
+
|
|
69
|
+
This operation is idempotent - running it multiple times is safe.
|
|
70
|
+
"""
|
|
71
|
+
result = context_sdk.unify_context(scope)
|
|
72
|
+
|
|
73
|
+
if result["success"]:
|
|
74
|
+
rprint(f"[green]Success![/green]\n")
|
|
75
|
+
rprint(result["message"])
|
|
76
|
+
|
|
77
|
+
if result.get("backups"):
|
|
78
|
+
rprint(f"\n[dim]Backups created:[/dim]")
|
|
79
|
+
for backup in result["backups"]:
|
|
80
|
+
rprint(f" [dim]{backup}[/dim]")
|
|
81
|
+
|
|
82
|
+
if result.get("symlinks_created"):
|
|
83
|
+
rprint(f"\n[dim]Symlinks created:[/dim]")
|
|
84
|
+
for symlink in result["symlinks_created"]:
|
|
85
|
+
rprint(f" [dim]{symlink}[/dim]")
|
|
86
|
+
else:
|
|
87
|
+
rprint(f"[red]Failed![/red]\n")
|
|
88
|
+
rprint(result.get("message", result.get("error", "Unknown error")))
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@context_cli.command("analyze")
|
|
93
|
+
@click.argument("scope", type=click.Choice(["user", "project", "all"]))
|
|
94
|
+
@click.argument("prompt")
|
|
95
|
+
@click.option("--model", default=None, help="Gemini model override")
|
|
96
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text")
|
|
97
|
+
def analyze(scope, prompt, model, output_format):
|
|
98
|
+
"""
|
|
99
|
+
Analyze context files using Gemini.
|
|
100
|
+
|
|
101
|
+
SCOPE: 'user', 'project', or 'all' to analyze both scopes together.
|
|
102
|
+
|
|
103
|
+
PROMPT: Your question or analysis request about the context files.
|
|
104
|
+
"""
|
|
105
|
+
result = context_sdk.analyze_context(scope, prompt, model)
|
|
106
|
+
|
|
107
|
+
if output_format == "json":
|
|
108
|
+
rprint(json.dumps(result, indent=2))
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if result.get("success"):
|
|
112
|
+
rprint(result["response"])
|
|
113
|
+
else:
|
|
114
|
+
rprint(f"[red]Error:[/red] {result.get('error', 'Unknown error')}")
|
|
115
|
+
raise SystemExit(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@context_cli.command("revise")
|
|
119
|
+
@click.argument("scope", type=click.Choice(["user", "project"]))
|
|
120
|
+
@click.argument("prompt")
|
|
121
|
+
@click.option("--model", default=None, help="Gemini model override")
|
|
122
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text")
|
|
123
|
+
def revise(scope, prompt, model, output_format):
|
|
124
|
+
"""
|
|
125
|
+
Revise context file using Gemini.
|
|
126
|
+
|
|
127
|
+
SCOPE: 'user' or 'project' (cannot revise both at once).
|
|
128
|
+
|
|
129
|
+
PROMPT: Instructions for how to modify the context file.
|
|
130
|
+
"""
|
|
131
|
+
result = context_sdk.revise_context(scope, prompt, model)
|
|
132
|
+
|
|
133
|
+
if output_format == "json":
|
|
134
|
+
rprint(json.dumps(result, indent=2))
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if result.get("success"):
|
|
138
|
+
rprint(f"[green]Success![/green] {result['message']}")
|
|
139
|
+
rprint(f"[dim]Backup: {result['backup']}[/dim]")
|
|
140
|
+
else:
|
|
141
|
+
rprint(f"[red]Error:[/red] {result.get('error', 'Unknown error')}")
|
|
142
|
+
raise SystemExit(1)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Subgroup for file-names management (Gemini-specific setting)
|
|
146
|
+
@context_cli.group(name="file-names")
|
|
147
|
+
def file_names():
|
|
148
|
+
"""Manage context.fileName setting (Gemini-specific)."""
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@file_names.command("list")
|
|
153
|
+
def list_file_names():
|
|
154
|
+
"""List configured context file names."""
|
|
155
|
+
path, files = settings_sdk.get_context_files()
|
|
156
|
+
rprint(f"[bold]Config:[/bold] {path}")
|
|
157
|
+
if not files:
|
|
158
|
+
rprint("[yellow]No context files configured.[/yellow]")
|
|
159
|
+
return
|
|
160
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
161
|
+
table.add_column("File Name")
|
|
162
|
+
for f in sorted(files):
|
|
163
|
+
table.add_row(f)
|
|
164
|
+
rprint(table)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@file_names.command("add")
|
|
168
|
+
@click.argument("filename")
|
|
169
|
+
def add_file_name(filename):
|
|
170
|
+
"""Add a context file name."""
|
|
171
|
+
path = settings_sdk.add_context_file(filename)
|
|
172
|
+
rprint(f"[green]Added[/green] '{filename}' to {path}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@file_names.command("remove")
|
|
176
|
+
@click.argument("filename")
|
|
177
|
+
def remove_file_name(filename):
|
|
178
|
+
"""Remove a context file name."""
|
|
179
|
+
path, removed = settings_sdk.remove_context_file(filename)
|
|
180
|
+
if removed:
|
|
181
|
+
rprint(f"[green]Removed[/green] '{filename}' from {path}")
|
|
182
|
+
else:
|
|
183
|
+
rprint(f"[red]File '{filename}' not found in {path}[/red]")
|
aicfg/cli/servers.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from rich import print as rprint
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from aicfg.sdk import mcp_setup
|
|
5
|
+
|
|
6
|
+
@click.group(name="mcp")
|
|
7
|
+
def mcp_servers():
|
|
8
|
+
"""Manage MCP servers."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@mcp_servers.command("add")
|
|
12
|
+
@click.option("--name", help="Name for the server")
|
|
13
|
+
@click.option("--path", help="Local repository path")
|
|
14
|
+
@click.option("--command", help="Existing command name")
|
|
15
|
+
@click.option("--url", help="Server URL for remote servers")
|
|
16
|
+
@click.option("--self", "is_self", is_flag=True, help="Register aicfg itself")
|
|
17
|
+
@click.option("--scope", type=click.Choice(["user", "project"]), default="user", help="Where to save")
|
|
18
|
+
@click.option("--args", help="CLI arguments for the server")
|
|
19
|
+
def add_mcp(name, path, command, url, is_self, scope, args):
|
|
20
|
+
"""Register a new MCP server."""
|
|
21
|
+
try:
|
|
22
|
+
result = mcp_setup.register_mcp(
|
|
23
|
+
name=name, path=path, command=command, url=url,
|
|
24
|
+
is_self=is_self, scope=scope, args=args
|
|
25
|
+
)
|
|
26
|
+
rprint(f"[green]Registered[/green] '{result['name']}' in {result['path']}")
|
|
27
|
+
except Exception as e:
|
|
28
|
+
rprint(f"[red]Error:[/red] {e}")
|
|
29
|
+
|
|
30
|
+
@mcp_servers.command("remove")
|
|
31
|
+
@click.argument("name")
|
|
32
|
+
@click.option("--scope", type=click.Choice(["user", "project"]), default="user")
|
|
33
|
+
def remove_mcp(name, scope):
|
|
34
|
+
"""Remove an MCP server."""
|
|
35
|
+
try:
|
|
36
|
+
path, success = mcp_setup.remove_mcp_server(name, scope)
|
|
37
|
+
rprint(f"[green]Removed[/green] '{name}' from {path}")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
rprint(f"[red]Error:[/red] {e}")
|
|
40
|
+
|
|
41
|
+
@mcp_servers.command("list")
|
|
42
|
+
@click.option("--scope", type=click.Choice(["user", "project"]), default=None,
|
|
43
|
+
help="Filter by scope (default: show all)")
|
|
44
|
+
@click.option("--filter", "filter_pattern", default=None,
|
|
45
|
+
help="Wildcard pattern to filter by any column (e.g., '*food*')")
|
|
46
|
+
@click.option("--format", "output_format", type=click.Choice(["table", "json"]), default="table",
|
|
47
|
+
help="Output format (default: table)")
|
|
48
|
+
def list_mcp(scope, filter_pattern, output_format):
|
|
49
|
+
"""List registered MCP servers from project and/or user settings."""
|
|
50
|
+
import json
|
|
51
|
+
result = mcp_setup.list_mcp_servers(scope=scope, filter_pattern=filter_pattern)
|
|
52
|
+
|
|
53
|
+
if output_format == "json":
|
|
54
|
+
click.echo(json.dumps(result, indent=2))
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Build title with scope indicator
|
|
58
|
+
title = "MCP Servers"
|
|
59
|
+
if result["scope"] != "all":
|
|
60
|
+
title += f" ({result['scope']} scope)"
|
|
61
|
+
|
|
62
|
+
table = Table(title=title)
|
|
63
|
+
table.add_column("Scope", style="magenta")
|
|
64
|
+
table.add_column("Name", style="cyan")
|
|
65
|
+
table.add_column("Command/URL", style="green")
|
|
66
|
+
|
|
67
|
+
for entry in result["servers"]:
|
|
68
|
+
cfg = entry["config"]
|
|
69
|
+
cmd_url = cfg.get("url") or cfg.get("command")
|
|
70
|
+
table.add_row(entry["scope"], entry["name"], cmd_url)
|
|
71
|
+
|
|
72
|
+
rprint(table)
|
|
73
|
+
|
|
74
|
+
# Build footer from summary
|
|
75
|
+
summary = result["summary"]
|
|
76
|
+
if "filter" in summary:
|
|
77
|
+
footer = f"Filter: {summary['filter']} ({summary['shown']} of {summary['total']})"
|
|
78
|
+
else:
|
|
79
|
+
footer = f"{summary['total']} total"
|
|
80
|
+
rprint(f"[dim]{footer}[/dim]")
|
|
81
|
+
|
|
82
|
+
@mcp_servers.command("show")
|
|
83
|
+
@click.argument("name")
|
|
84
|
+
@click.option("--scope", type=click.Choice(["user", "project"]), default=None,
|
|
85
|
+
help="Scope to search (default: search both, project first)")
|
|
86
|
+
@click.option("--format", "output_format", type=click.Choice(["table", "json"]), default="table")
|
|
87
|
+
def show_mcp(name, scope, output_format):
|
|
88
|
+
"""
|
|
89
|
+
Show details for a registered MCP server, including health check.
|
|
90
|
+
|
|
91
|
+
Looks up the server by NAME and runs a startup validation to check health.
|
|
92
|
+
"""
|
|
93
|
+
import json
|
|
94
|
+
import sys
|
|
95
|
+
|
|
96
|
+
result = mcp_setup.get_mcp_server(name, scope=scope)
|
|
97
|
+
|
|
98
|
+
if output_format == "json":
|
|
99
|
+
click.echo(json.dumps(result, indent=2))
|
|
100
|
+
if not result["found"]:
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
if not result["found"]:
|
|
105
|
+
rprint(f"[red]Error:[/red] {result['error']}")
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
cfg = result["config"]
|
|
109
|
+
health = result["health"]
|
|
110
|
+
status = health["status"]
|
|
111
|
+
|
|
112
|
+
# Health badge - big and obvious
|
|
113
|
+
if status == "ok":
|
|
114
|
+
badge = "[bold white on green] ✓ HEALTHY [/bold white on green]"
|
|
115
|
+
version_info = ""
|
|
116
|
+
if health.get("server_name"):
|
|
117
|
+
version_info = f" [dim]{health['server_name']} v{health.get('server_version', '?')}[/dim]"
|
|
118
|
+
rprint(f"{badge}{version_info}")
|
|
119
|
+
elif status == "skip":
|
|
120
|
+
rprint(f"[bold black on yellow] ⊘ SKIPPED [/bold black on yellow] [dim]{health.get('reason', '')}[/dim]")
|
|
121
|
+
else:
|
|
122
|
+
rprint(f"[bold white on red] ✗ FAILED [/bold white on red] [red]{health.get('error', 'Unknown')}[/red]")
|
|
123
|
+
|
|
124
|
+
rprint()
|
|
125
|
+
|
|
126
|
+
# Details in a simple table
|
|
127
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
128
|
+
table.add_column(style="dim")
|
|
129
|
+
table.add_column()
|
|
130
|
+
|
|
131
|
+
table.add_row("Name", result["name"])
|
|
132
|
+
table.add_row("Scope", result["scope"])
|
|
133
|
+
table.add_row("Type", result["type"])
|
|
134
|
+
|
|
135
|
+
if result["type"] == "url":
|
|
136
|
+
table.add_row("URL", cfg.get("url"))
|
|
137
|
+
else:
|
|
138
|
+
table.add_row("Command", cfg.get("command"))
|
|
139
|
+
args = cfg.get("args", [])
|
|
140
|
+
if args:
|
|
141
|
+
table.add_row("Args", " ".join(args))
|
|
142
|
+
|
|
143
|
+
rprint(table)
|
aicfg/cli/sessions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""CLI commands for Claude Code session search."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from aicfg.sdk.sessions import (
|
|
10
|
+
collect_recent_session_files,
|
|
11
|
+
find_sessions,
|
|
12
|
+
format_results,
|
|
13
|
+
DEFAULT_MOST_RECENT,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group(name="claude")
|
|
18
|
+
def claude_cli():
|
|
19
|
+
"""Claude Code utilities."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@claude_cli.command(name="find-session")
|
|
24
|
+
@click.argument("patterns", nargs=-1, required=True)
|
|
25
|
+
@click.option("--all", "match_all", is_flag=True, help="Require ALL patterns to match (default: any)")
|
|
26
|
+
@click.option("--most-recent", type=int, default=DEFAULT_MOST_RECENT, help=f"Sessions to search (default: {DEFAULT_MOST_RECENT})")
|
|
27
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
28
|
+
@click.option("--max-snippets", type=int, default=3, help="Max snippets per session (default: 3)")
|
|
29
|
+
def find_session(patterns, match_all, most_recent, as_json, max_snippets):
|
|
30
|
+
"""Search recent Claude Code sessions for keywords or patterns.
|
|
31
|
+
|
|
32
|
+
Multiple PATTERNS are OR by default. Use --all for AND.
|
|
33
|
+
"""
|
|
34
|
+
for p in patterns:
|
|
35
|
+
try:
|
|
36
|
+
re.compile(p)
|
|
37
|
+
except re.error as e:
|
|
38
|
+
click.echo(f"Invalid pattern '{p}': {e}", err=True)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
matches = find_sessions(
|
|
42
|
+
patterns=list(patterns),
|
|
43
|
+
match_all=match_all,
|
|
44
|
+
most_recent=most_recent,
|
|
45
|
+
max_snippets=max_snippets,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if as_json:
|
|
49
|
+
click.echo(json.dumps(matches, indent=2))
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
searched = len(collect_recent_session_files(most_recent))
|
|
53
|
+
output = format_results(matches, list(patterns), match_all, most_recent, searched)
|
|
54
|
+
click.echo(output)
|