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 +3 -0
- fulfil_cli/__main__.py +5 -0
- fulfil_cli/auth/__init__.py +0 -0
- fulfil_cli/auth/api_key.py +76 -0
- fulfil_cli/auth/keyring_store.py +25 -0
- fulfil_cli/cli/__init__.py +7 -0
- fulfil_cli/cli/app.py +248 -0
- fulfil_cli/cli/commands/__init__.py +0 -0
- fulfil_cli/cli/commands/api.py +63 -0
- fulfil_cli/cli/commands/auth.py +167 -0
- fulfil_cli/cli/commands/completion.py +65 -0
- fulfil_cli/cli/commands/config.py +68 -0
- fulfil_cli/cli/commands/model.py +445 -0
- fulfil_cli/cli/commands/report.py +229 -0
- fulfil_cli/cli/state.py +61 -0
- fulfil_cli/client/__init__.py +0 -0
- fulfil_cli/client/errors.py +121 -0
- fulfil_cli/client/http.py +164 -0
- fulfil_cli/config/__init__.py +0 -0
- fulfil_cli/config/manager.py +114 -0
- fulfil_cli/config/paths.py +29 -0
- fulfil_cli/output/__init__.py +0 -0
- fulfil_cli/output/describe.py +125 -0
- fulfil_cli/output/formatter.py +113 -0
- fulfil_cli/output/json_output.py +29 -0
- fulfil_cli/output/report.py +189 -0
- fulfil_cli/output/table.py +42 -0
- fulfil_cli-0.1.0.dist-info/METADATA +291 -0
- fulfil_cli-0.1.0.dist-info/RECORD +31 -0
- fulfil_cli-0.1.0.dist-info/WHEEL +4 -0
- fulfil_cli-0.1.0.dist-info/entry_points.txt +3 -0
fulfil_cli/__init__.py
ADDED
fulfil_cli/__main__.py
ADDED
|
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)
|
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
|