fulfil-cli 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.
fulfil_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Fulfil CLI — primary interface for humans and AI agents."""
2
+
3
+ __version__ = "0.1.0"
fulfil_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m fulfil_cli`."""
2
+
3
+ from fulfil_cli.cli import main
4
+
5
+ main()
File without changes
@@ -0,0 +1,76 @@
1
+ """API key resolution — env > flag > keyring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from fulfil_cli.auth.keyring_store import get_api_key
8
+ from fulfil_cli.client.errors import AuthError
9
+
10
+
11
+ def resolve_api_key(
12
+ *,
13
+ token_flag: str | None = None,
14
+ workspace: str | None = None,
15
+ ) -> str:
16
+ """Resolve API key from: --token flag > FULFIL_API_KEY env > keyring.
17
+
18
+ Raises AuthError if no key can be found.
19
+ """
20
+ # 1. Explicit --token flag
21
+ if token_flag:
22
+ return token_flag
23
+
24
+ # 2. Environment variable
25
+ env_key = os.environ.get("FULFIL_API_KEY")
26
+ if env_key:
27
+ return env_key
28
+
29
+ # 3. Keyring (requires workspace)
30
+ if workspace:
31
+ stored = get_api_key(workspace)
32
+ if stored:
33
+ return stored
34
+
35
+ raise AuthError(
36
+ message="No API key found",
37
+ hint="Run 'fulfil auth login' or set FULFIL_API_KEY environment variable.",
38
+ )
39
+
40
+
41
+ def resolve_workspace(
42
+ *,
43
+ workspace_flag: str | None = None,
44
+ config_workspace: str | None = None,
45
+ ) -> str:
46
+ """Resolve workspace from: --workspace flag > FULFIL_WORKSPACE env > config.
47
+
48
+ The workspace is the full domain (e.g. 'acme.fulfil.io').
49
+ Raises AuthError if no workspace can be found.
50
+ """
51
+ if workspace_flag:
52
+ return _normalize_workspace(workspace_flag)
53
+
54
+ env_workspace = os.environ.get("FULFIL_WORKSPACE")
55
+ if env_workspace:
56
+ return _normalize_workspace(env_workspace)
57
+
58
+ if config_workspace:
59
+ return config_workspace
60
+
61
+ raise AuthError(
62
+ message="No workspace configured",
63
+ hint="Run 'fulfil auth login' or set FULFIL_WORKSPACE environment variable.",
64
+ )
65
+
66
+
67
+ def _normalize_workspace(value: str) -> str:
68
+ """Normalize workspace input — accept slug or full domain.
69
+
70
+ 'acme' → 'acme.fulfil.io'
71
+ 'acme.fulfil.io' → 'acme.fulfil.io'
72
+ 'acme.fulfil.app' → 'acme.fulfil.app'
73
+ """
74
+ if "." in value:
75
+ return value
76
+ return f"{value}.fulfil.io"
@@ -0,0 +1,25 @@
1
+ """Keyring-based storage for API keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+
7
+ import keyring
8
+
9
+ SERVICE_NAME = "fulfil-cli"
10
+
11
+
12
+ def store_api_key(workspace: str, api_key: str) -> None:
13
+ """Store an API key in the system keyring."""
14
+ keyring.set_password(SERVICE_NAME, workspace, api_key)
15
+
16
+
17
+ def get_api_key(workspace: str) -> str | None:
18
+ """Retrieve an API key from the system keyring."""
19
+ return keyring.get_password(SERVICE_NAME, workspace)
20
+
21
+
22
+ def delete_api_key(workspace: str) -> None:
23
+ """Delete an API key from the system keyring."""
24
+ with contextlib.suppress(keyring.errors.PasswordDeleteError):
25
+ keyring.delete_password(SERVICE_NAME, workspace)
@@ -0,0 +1,7 @@
1
+ """CLI entry point."""
2
+
3
+ from fulfil_cli.cli.app import app
4
+
5
+
6
+ def main() -> None:
7
+ app()
fulfil_cli/cli/app.py ADDED
@@ -0,0 +1,248 @@
1
+ """Root CLI application with dynamic model subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import typer
7
+ import typer.core
8
+ from rich.console import Console
9
+
10
+ from fulfil_cli import __version__
11
+ from fulfil_cli.cli.commands import auth, config
12
+ from fulfil_cli.cli.commands.api import api_cmd
13
+ from fulfil_cli.cli.commands.completion import completion_install
14
+ from fulfil_cli.cli.commands.model import create_model_group
15
+ from fulfil_cli.cli.commands.report import create_report_group
16
+ from fulfil_cli.cli.state import get_client, is_quiet, set_globals
17
+ from fulfil_cli.client.errors import FulfilError
18
+ from fulfil_cli.output.formatter import output
19
+
20
+ console = Console(stderr=True)
21
+
22
+
23
+ def _handle_error(exc: FulfilError) -> None:
24
+ """Print error and exit with appropriate code."""
25
+ console.print(f"[red]Error: {exc}[/red]")
26
+ if exc.hint and not is_quiet():
27
+ console.print(f"[dim]Hint: {exc.hint}[/dim]")
28
+ raise typer.Exit(code=exc.exit_code)
29
+
30
+
31
+ class ReportGroup(click.Group):
32
+ """Click group that resolves unknown subcommands as report names."""
33
+
34
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
35
+ rv = super().get_command(ctx, cmd_name)
36
+ if rv is not None:
37
+ return rv
38
+ return create_report_group(cmd_name)
39
+
40
+
41
+ class FulfilGroup(typer.core.TyperGroup):
42
+ """Custom Click group that resolves unknown subcommands as model or report names."""
43
+
44
+ # Dynamic commands that should appear in help alongside static ones
45
+ _dynamic_commands = ("models", "reports")
46
+
47
+ def list_commands(self, ctx: click.Context) -> list[str]:
48
+ commands = super().list_commands(ctx)
49
+ for name in self._dynamic_commands:
50
+ if name not in commands:
51
+ commands.append(name)
52
+ return commands
53
+
54
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
55
+ # Check static commands first
56
+ rv = super().get_command(ctx, cmd_name)
57
+ if rv is not None:
58
+ return rv
59
+ # `fulfil models` / `fulfil models list`
60
+ if cmd_name == "models":
61
+ return models_group
62
+ # `fulfil reports` / `fulfil reports list` / `fulfil reports <name> execute`
63
+ if cmd_name == "reports":
64
+ return reports_group
65
+ # Treat as model name → return model sub-group
66
+ return create_model_group(cmd_name)
67
+
68
+
69
+ app = typer.Typer(
70
+ cls=FulfilGroup,
71
+ name="fulfil",
72
+ help=(
73
+ "The Fulfil CLI — interact with the Fulfil ERP platform from the command line.\n\n"
74
+ "Any Fulfil model name (e.g. sales_order, product, customer_shipment) is a valid\n"
75
+ "subcommand. Each model supports: list, get, create, update, delete, count, call,\n"
76
+ "and describe.\n\n"
77
+ "Examples:\n\n"
78
+ " # List confirmed sales orders\n"
79
+ ' fulfil sales_order list --where \'{"state": "confirmed"}\'\n\n'
80
+ " # Get a specific product by ID\n"
81
+ " fulfil product get 42\n\n"
82
+ " # Create a new contact\n"
83
+ ' fulfil contact create --data \'{"name": "Acme Corp"}\'\n\n'
84
+ " # Count open shipments\n"
85
+ ' fulfil customer_shipment count --where \'{"state": "waiting"}\'\n\n'
86
+ " # Send a raw JSON-RPC call\n"
87
+ ' fulfil api \'{"method": "system.version", "params": {}}\'\n\n'
88
+ " # List available models\n"
89
+ " fulfil models"
90
+ ),
91
+ no_args_is_help=True,
92
+ add_completion=False,
93
+ context_settings={"help_option_names": ["-h", "--help"]},
94
+ )
95
+
96
+ # Static command groups
97
+ app.add_typer(auth.app, name="auth")
98
+ app.add_typer(config.app, name="config")
99
+
100
+
101
+ @app.callback()
102
+ def main_callback(
103
+ ctx: typer.Context,
104
+ token: str | None = typer.Option(None, "--token", envvar="FULFIL_API_KEY", help="API key"),
105
+ workspace: str | None = typer.Option(
106
+ None,
107
+ "--workspace",
108
+ envvar="FULFIL_WORKSPACE",
109
+ help="Workspace domain (e.g. acme.fulfil.io)",
110
+ ),
111
+ base_url: str | None = typer.Option(None, "--base-url", hidden=True, help="Override base URL"),
112
+ debug: bool = typer.Option(False, "--debug", help="Show debug output"),
113
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress hints and decorative output"),
114
+ ) -> None:
115
+ """Root callback — sets global auth state."""
116
+ set_globals(token=token, workspace=workspace, base_url=base_url, debug=debug, quiet=quiet)
117
+
118
+
119
+ @app.command()
120
+ def version(
121
+ json_flag: bool = typer.Option(False, "--json", help="Output as JSON"),
122
+ ) -> None:
123
+ """Show CLI version."""
124
+ if json_flag:
125
+ output({"version": __version__}, json_flag=True)
126
+ else:
127
+ typer.echo(f"fulfil-cli {__version__}")
128
+
129
+
130
+ @app.command()
131
+ def whoami(
132
+ json_flag: bool = typer.Option(False, "--json", help="Output as JSON"),
133
+ ) -> None:
134
+ """Show current user and workspace info."""
135
+ try:
136
+ client = get_client()
137
+ result = client.call("system.whoami")
138
+ except FulfilError as exc:
139
+ _handle_error(exc)
140
+
141
+ output(result, json_flag=json_flag)
142
+
143
+
144
+ def _flatten_model_row(row: dict) -> dict:
145
+ """Pick key columns and flatten access dict for table display."""
146
+ access = row.get("access", {})
147
+ return {
148
+ "model": row.get("model_name", ""),
149
+ "description": row.get("description", ""),
150
+ "category": row.get("category", ""),
151
+ "read": access.get("read", False),
152
+ "create": access.get("create", False),
153
+ "update": access.get("update", False),
154
+ "delete": access.get("delete", False),
155
+ }
156
+
157
+
158
+ def _list_models(json_flag: bool, search: str | None = None) -> None:
159
+ """Fetch and display all available models."""
160
+ try:
161
+ client = get_client()
162
+ result = client.call("system.list_models")
163
+ except FulfilError as exc:
164
+ _handle_error(exc)
165
+
166
+ if search and isinstance(result, list):
167
+ term = search.lower()
168
+ result = [
169
+ r
170
+ for r in result
171
+ if isinstance(r, dict)
172
+ and (
173
+ term in r.get("model_name", "").lower()
174
+ or term in r.get("description", "").lower()
175
+ or term in r.get("category", "").lower()
176
+ )
177
+ ]
178
+
179
+ if not json_flag and isinstance(result, list):
180
+ result = [_flatten_model_row(r) for r in result if isinstance(r, dict)]
181
+
182
+ output(result, json_flag=json_flag, title="Available Models")
183
+
184
+
185
+ @click.group(name="models", help="List available models.", invoke_without_command=True)
186
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
187
+ @click.option("--search", "-s", default=None, help="Filter by name, description, or category.")
188
+ @click.pass_context
189
+ def models_group(ctx: click.Context, json_flag: bool, search: str | None) -> None:
190
+ """List all available models."""
191
+ ctx.ensure_object(dict)
192
+ ctx.obj["json_flag"] = json_flag
193
+ ctx.obj["search"] = search
194
+ if ctx.invoked_subcommand is None:
195
+ _list_models(json_flag, search=search)
196
+
197
+
198
+ @models_group.command("list")
199
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
200
+ @click.option("--search", "-s", default=None, help="Filter by name, description, or category.")
201
+ @click.pass_context
202
+ def models_list_cmd(ctx: click.Context, json_flag: bool, search: str | None) -> None:
203
+ """List all available models."""
204
+ _list_models(json_flag, search=search)
205
+
206
+
207
+ def _list_reports(json_flag: bool) -> None:
208
+ """Fetch and display all available reports."""
209
+ try:
210
+ client = get_client()
211
+ result = client.call("system.list_reports")
212
+ except FulfilError as exc:
213
+ _handle_error(exc)
214
+
215
+ output(result, json_flag=json_flag, title="Available Reports")
216
+
217
+
218
+ @click.group(
219
+ name="reports",
220
+ cls=ReportGroup,
221
+ help="Interact with reports.",
222
+ invoke_without_command=True,
223
+ )
224
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
225
+ @click.pass_context
226
+ def reports_group(ctx: click.Context, json_flag: bool) -> None:
227
+ """List all available reports."""
228
+ ctx.ensure_object(dict)
229
+ ctx.obj["json_flag"] = json_flag
230
+ if ctx.invoked_subcommand is None:
231
+ _list_reports(json_flag)
232
+
233
+
234
+ @reports_group.command("list")
235
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
236
+ @click.pass_context
237
+ def reports_list_cmd(ctx: click.Context, json_flag: bool) -> None:
238
+ """List all available reports."""
239
+ _list_reports(json_flag)
240
+
241
+
242
+ # Register standalone commands
243
+ app.command(name="api")(api_cmd)
244
+ app.command(name="completion")(completion_install)
245
+
246
+
247
+ # Top-level aliases for frequently-used auth subcommands
248
+ app.command(name="workspaces")(auth.workspaces)
File without changes
@@ -0,0 +1,63 @@
1
+ """Raw JSON-RPC call command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from fulfil_cli.cli.state import get_client
12
+ from fulfil_cli.client.errors import FulfilError
13
+ from fulfil_cli.output.formatter import output
14
+
15
+ console = Console(stderr=True)
16
+
17
+
18
+ def api_cmd(
19
+ payload: str = typer.Argument(
20
+ ...,
21
+ help=(
22
+ "JSON-RPC request body (or '-' to read from stdin). "
23
+ 'Must contain a "method" key and optional "params" object. '
24
+ """Example: '{"method": "system.version", "params": {}}'"""
25
+ ),
26
+ ),
27
+ json_flag: bool = typer.Option(False, "--json", help="Output as JSON"),
28
+ ) -> None:
29
+ """Send a raw JSON-RPC request to the Fulfil API.
30
+
31
+ \b
32
+ Examples:
33
+ fulfil api '{"method": "system.version", "params": {}}'
34
+ fulfil api '{"method": "model.sale_order.find", "params": {"where": {"state": "confirmed"}}}'
35
+ echo '{"method": "system.version", "params": {}}' | fulfil api -
36
+ """
37
+ if payload == "-":
38
+ payload = sys.stdin.read()
39
+
40
+ try:
41
+ data = json.loads(payload)
42
+ except json.JSONDecodeError as exc:
43
+ console.print(f"[red]Invalid JSON: {exc}[/red]")
44
+ raise typer.Exit(code=7) from None
45
+
46
+ # Extract method and params from JSON-RPC envelope or shorthand
47
+ if "method" in data:
48
+ method = data["method"]
49
+ params = data.get("params", {})
50
+ else:
51
+ console.print("[red]JSON must contain a 'method' key.[/red]")
52
+ raise typer.Exit(code=2)
53
+
54
+ try:
55
+ client = get_client()
56
+ result = client.call(method, **params)
57
+ except FulfilError as exc:
58
+ console.print(f"[red]Error: {exc}[/red]")
59
+ if exc.hint:
60
+ console.print(f"[dim]Hint: {exc.hint}[/dim]")
61
+ raise typer.Exit(code=exc.exit_code) from None
62
+
63
+ output(result, json_flag=json_flag)
@@ -0,0 +1,167 @@
1
+ """Auth commands: login, logout, status, token, workspaces, use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from fulfil_cli.auth.api_key import resolve_api_key, resolve_workspace
12
+ from fulfil_cli.auth.keyring_store import delete_api_key, get_api_key, store_api_key
13
+ from fulfil_cli.client.errors import AuthError, FulfilError
14
+ from fulfil_cli.client.http import FulfilClient
15
+ from fulfil_cli.config.manager import ConfigManager
16
+
17
+ app = typer.Typer(help="Manage authentication.")
18
+ console = Console()
19
+
20
+
21
+ @app.command()
22
+ def login(
23
+ workspace: str | None = typer.Option(None, help="Workspace domain (e.g. 'acme.fulfil.io')"),
24
+ api_key: str | None = typer.Option(None, "--api-key", help="API key"),
25
+ ) -> None:
26
+ """Authenticate with a Fulfil workspace."""
27
+ config = ConfigManager()
28
+
29
+ if not workspace:
30
+ workspace = typer.prompt("Workspace domain (e.g. acme.fulfil.io)")
31
+ if not api_key:
32
+ api_key = typer.prompt("API key", hide_input=True)
33
+
34
+ # Normalize workspace — accept slug or full domain
35
+ if "." not in workspace:
36
+ workspace = f"{workspace}.fulfil.io"
37
+
38
+ # Validate the credentials
39
+ console.print(f"[dim]Validating credentials for {workspace}...[/dim]")
40
+ try:
41
+ client = FulfilClient(workspace=workspace, api_key=api_key)
42
+ client.call("system.version")
43
+ except FulfilError as exc:
44
+ console.print(f"[red]Authentication failed: {exc}[/red]")
45
+ raise typer.Exit(code=exc.exit_code) from None
46
+ except Exception:
47
+ # If the v3 endpoint doesn't exist yet, store anyway with a warning
48
+ console.print(
49
+ "[yellow]Warning: Could not validate credentials "
50
+ "(v3 API may not be deployed yet).[/yellow]"
51
+ )
52
+
53
+ # Store credentials
54
+ store_api_key(workspace, api_key)
55
+ config.add_workspace(workspace)
56
+ config.workspace = workspace
57
+ console.print(f"[green]Logged in to workspace '{workspace}'.[/green]")
58
+
59
+
60
+ @app.command()
61
+ def logout(
62
+ workspace: str | None = typer.Argument(
63
+ None, help="Workspace to log out from (default: current)"
64
+ ),
65
+ all_workspaces: bool = typer.Option(False, "--all", help="Log out from all workspaces"),
66
+ ) -> None:
67
+ """Remove stored credentials."""
68
+ config = ConfigManager()
69
+
70
+ if all_workspaces:
71
+ for ws in config.workspaces:
72
+ delete_api_key(ws)
73
+ config.clear_workspaces()
74
+ console.print("[green]Logged out from all workspaces.[/green]")
75
+ return
76
+
77
+ target = workspace or config.workspace
78
+ if target:
79
+ delete_api_key(target)
80
+ config.remove_workspace(target)
81
+ if config.workspace == target:
82
+ # Switch to another workspace if available, or clear
83
+ remaining = config.workspaces
84
+ config.workspace = remaining[0] if remaining else None
85
+ console.print(f"[green]Logged out from '{target}'.[/green]")
86
+ else:
87
+ console.print("[dim]Not logged in.[/dim]")
88
+
89
+
90
+ @app.command()
91
+ def status() -> None:
92
+ """Show current authentication status."""
93
+ config = ConfigManager()
94
+ workspace = config.workspace
95
+ if not workspace:
96
+ console.print("[yellow]Not logged in.[/yellow]")
97
+ console.print("[dim]Run 'fulfil auth login' to authenticate.[/dim]")
98
+ raise typer.Exit(code=3)
99
+
100
+ has_key = get_api_key(workspace) is not None
101
+ env_key = "FULFIL_API_KEY" in os.environ
102
+
103
+ console.print(f"Workspace: [bold]{workspace}[/bold]")
104
+ if env_key:
105
+ console.print("API Key: [green]set via FULFIL_API_KEY[/green]")
106
+ elif has_key:
107
+ console.print("API Key: [green]stored in keyring[/green]")
108
+ else:
109
+ console.print("API Key: [red]not found[/red]")
110
+
111
+ all_ws = config.workspaces
112
+ if len(all_ws) > 1:
113
+ console.print(f"\nAll workspaces ({len(all_ws)}):")
114
+ for ws in all_ws:
115
+ marker = " [bold green]*[/bold green]" if ws == workspace else ""
116
+ console.print(f" {ws}{marker}")
117
+
118
+
119
+ @app.command()
120
+ def token() -> None:
121
+ """Print the current API key to stdout (for piping)."""
122
+ config = ConfigManager()
123
+ try:
124
+ workspace = resolve_workspace(config_workspace=config.workspace)
125
+ key = resolve_api_key(workspace=workspace)
126
+ except AuthError as exc:
127
+ console.print(f"[red]{exc}[/red]", file=sys.stderr)
128
+ raise typer.Exit(code=exc.exit_code) from None
129
+ print(key)
130
+
131
+
132
+ @app.command()
133
+ def workspaces() -> None:
134
+ """List all stored workspaces."""
135
+ config = ConfigManager()
136
+ all_ws = config.workspaces
137
+ current = config.workspace
138
+
139
+ if not all_ws:
140
+ console.print("[dim]No workspaces configured. Run 'fulfil auth login'.[/dim]")
141
+ return
142
+
143
+ for ws in all_ws:
144
+ if ws == current:
145
+ console.print(f" [bold green]*[/bold green] {ws} [dim](current)[/dim]")
146
+ else:
147
+ console.print(f" {ws}")
148
+
149
+
150
+ @app.command()
151
+ def use(
152
+ workspace: str = typer.Argument(help="Workspace to switch to"),
153
+ ) -> None:
154
+ """Switch the active workspace."""
155
+ config = ConfigManager()
156
+
157
+ # Normalize
158
+ if "." not in workspace:
159
+ workspace = f"{workspace}.fulfil.io"
160
+
161
+ if workspace not in config.workspaces:
162
+ console.print(f"[red]Workspace '{workspace}' not found.[/red]")
163
+ console.print("[dim]Run 'fulfil auth login' to add it first.[/dim]")
164
+ raise typer.Exit(code=3)
165
+
166
+ config.workspace = workspace
167
+ console.print(f"[green]Switched to workspace '{workspace}'.[/green]")
@@ -0,0 +1,65 @@
1
+ """Shell completion install command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+
16
+ def completion_install() -> None:
17
+ """Install shell completion for the current shell."""
18
+ shell = _detect_shell()
19
+ if not shell:
20
+ console.print("[red]Could not detect shell. Set $SHELL and try again.[/red]")
21
+ raise typer.Exit(code=2)
22
+
23
+ console.print(f"Detected shell: [bold]{shell}[/bold]")
24
+
25
+ try:
26
+ result = subprocess.run(
27
+ [sys.argv[0], "--show-completion", shell],
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+ if result.returncode == 0:
32
+ target = _completion_target(shell)
33
+ console.print(f"[green]Completion installed for {shell}.[/green]")
34
+ if target:
35
+ console.print(f"[dim]Wrote to: {target}[/dim]")
36
+ console.print("[dim]Restart your shell or source your profile to activate.[/dim]")
37
+ else:
38
+ console.print(f"[red]Failed to install completion: {result.stderr}[/red]")
39
+ except Exception as exc:
40
+ console.print(f"[red]Error: {exc}[/red]")
41
+ raise typer.Exit(code=1) from None
42
+
43
+
44
+ def _detect_shell() -> str | None:
45
+ """Detect the current shell."""
46
+ shell_path = os.environ.get("SHELL", "")
47
+ if "zsh" in shell_path:
48
+ return "zsh"
49
+ if "bash" in shell_path:
50
+ return "bash"
51
+ if "fish" in shell_path:
52
+ return "fish"
53
+ return None
54
+
55
+
56
+ def _completion_target(shell: str) -> str | None:
57
+ """Return the typical completion file path for display."""
58
+ home = Path.home()
59
+ if shell == "zsh":
60
+ return f"{home}/.zfunc/_fulfil"
61
+ if shell == "bash":
62
+ return f"{home}/.bash_completions/fulfil.bash"
63
+ if shell == "fish":
64
+ return f"{home}/.config/fish/completions/fulfil.fish"
65
+ return None