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 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)