kctl-opencloud 0.5.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.
@@ -0,0 +1,3 @@
1
+ """kctl-opencloud: Kodemeio OpenCloud CLI."""
2
+
3
+ __version__ = "0.5.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_opencloud."""
2
+
3
+ from kctl_opencloud.cli import app
4
+
5
+ app()
kctl_opencloud/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ """Main CLI entry point for kctl-opencloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib import KctlError, handle_cli_error
9
+
10
+ from kctl_opencloud import __version__
11
+ from kctl_opencloud.commands.config_cmd import app as config_app
12
+ from kctl_opencloud.commands.dashboard import app as dashboard_app
13
+ from kctl_opencloud.commands.doctor_cmd import app as doctor_app
14
+ from kctl_opencloud.commands.groups import app as groups_app
15
+ from kctl_opencloud.commands.health import app as health_app
16
+ from kctl_opencloud.commands.shares import app as shares_app
17
+ from kctl_opencloud.commands.skill_cmd import app as skill_app
18
+ from kctl_opencloud.commands.spaces import app as spaces_app
19
+ from kctl_opencloud.commands.users import app as users_app
20
+ from kctl_opencloud.core.callbacks import AppContext
21
+ from kctl_lib.self_update import notify_if_outdated
22
+ from kctl_lib.tui import add_tui_command
23
+
24
+
25
+ def version_callback(value: bool) -> None:
26
+ if value:
27
+ typer.echo(f"kctl-opencloud {__version__}")
28
+ raise typer.Exit()
29
+
30
+
31
+ app = typer.Typer(
32
+ name="kctl-opencloud",
33
+ help="Kodemeio OpenCloud CLI - manage your OpenCloud file platform.",
34
+ no_args_is_help=True,
35
+ rich_markup_mode="rich",
36
+ pretty_exceptions_enable=False,
37
+ )
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ ctx: typer.Context,
43
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
44
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
45
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
46
+ url: Annotated[str | None, typer.Option("--url", help="API URL override")] = None,
47
+ token: Annotated[str | None, typer.Option("--token", help="API token override")] = None,
48
+ version: Annotated[
49
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
50
+ ] = False,
51
+ ) -> None:
52
+ """Kodemeio OpenCloud CLI."""
53
+ ctx.ensure_object(dict)
54
+ ctx.obj = AppContext(
55
+ json_mode=json_output,
56
+ quiet=quiet,
57
+ profile=profile,
58
+ url_override=url,
59
+ token_override=token,
60
+ )
61
+ notify_if_outdated(ctx.obj.output, "kctl-opencloud", __version__)
62
+
63
+
64
+ # Register command groups
65
+ app.add_typer(health_app, name="health")
66
+ app.add_typer(dashboard_app, name="dashboard")
67
+ app.add_typer(config_app, name="config")
68
+ app.add_typer(users_app, name="users")
69
+ app.add_typer(groups_app, name="groups")
70
+ app.add_typer(spaces_app, name="spaces")
71
+ app.add_typer(shares_app, name="shares")
72
+ app.add_typer(doctor_app, name="doctor")
73
+ app.add_typer(skill_app, name="skill", hidden=True)
74
+ add_tui_command(app, service_key="opencloud", version=__version__)
75
+
76
+
77
+ @app.command("self-update")
78
+ def self_update_cmd(ctx: typer.Context) -> None:
79
+ """Check for updates and upgrade kctl-opencloud."""
80
+ actx: AppContext = ctx.obj
81
+ out = actx.output
82
+
83
+ from kctl_lib.self_update import check_update
84
+ from kctl_lib.self_update import update as do_update
85
+
86
+ latest = check_update("kctl-opencloud", __version__)
87
+ if latest:
88
+ out.info(f"Updating to {latest}...")
89
+ do_update("kctl-opencloud")
90
+ out.success(f"Updated to {latest}")
91
+ else:
92
+ out.success("Already up to date")
93
+
94
+
95
+ @app.command()
96
+ def completions(
97
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
98
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
99
+ ) -> None:
100
+ """Generate or install shell completions."""
101
+ from kctl_lib.completions import get_completion_script, install_completions
102
+
103
+ if install:
104
+ path = install_completions("kctl-opencloud", shell)
105
+ if path:
106
+ typer.echo(f"Completions installed to {path}")
107
+ else:
108
+ typer.echo(f"Could not install completions for {shell}", err=True)
109
+ raise typer.Exit(code=1)
110
+ else:
111
+ script = get_completion_script("kctl-opencloud", shell)
112
+ typer.echo(script)
113
+
114
+
115
+ def _run() -> None:
116
+ """Entry point with error handling."""
117
+ try:
118
+ app()
119
+ except KctlError as e:
120
+ handle_cli_error(e)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ _run()
File without changes
@@ -0,0 +1,191 @@
1
+ """Configuration management commands for kctl-opencloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_opencloud.core.callbacks import AppContext
10
+ from kctl_opencloud.core.config import (
11
+ SERVICE_KEY,
12
+ ServiceConfig,
13
+ get_default_profile,
14
+ get_profile_names,
15
+ get_service_config,
16
+ remove_profile,
17
+ resolve_active_profile_name,
18
+ set_default_profile,
19
+ set_service_config,
20
+ )
21
+
22
+ app = typer.Typer(help="Configuration and profile management.")
23
+
24
+
25
+ def _mask_token(token: str) -> str:
26
+ """Mask a token for display."""
27
+ if not token or len(token) < 8:
28
+ return "****" if token else ""
29
+ return token[:4] + "****" + token[-4:]
30
+
31
+
32
+ @app.command()
33
+ def init(
34
+ ctx: typer.Context,
35
+ url: Annotated[str, typer.Option("--url", help="OpenCloud API URL")] = "",
36
+ token: Annotated[str, typer.Option("--token", help="Machine auth API key")] = "",
37
+ profile_name: Annotated[str, typer.Option("--profile", "-p", help="Profile name")] = "default",
38
+ ) -> None:
39
+ """Initialize configuration with API credentials."""
40
+ c: AppContext = ctx.obj
41
+
42
+ if not url:
43
+ url = typer.prompt("OpenCloud URL", default="https://cloud.kodeme.io")
44
+ if not token:
45
+ token = typer.prompt("Machine Auth API Key", hide_input=True)
46
+
47
+ svc = ServiceConfig(url=url, token=token)
48
+ set_service_config(profile_name, svc)
49
+ set_default_profile(profile_name)
50
+
51
+ # Test connection
52
+ try:
53
+ from kctl_opencloud.core.client import OpenCloudClient
54
+
55
+ client = OpenCloudClient(base_url=url, credential=token)
56
+ status = client.check_health()
57
+ if status == 200:
58
+ c.output.success(f"Connected to {url}")
59
+ elif status == 0:
60
+ c.output.warn(f"Could not reach {url} (connection error)")
61
+ else:
62
+ c.output.warn(f"Connection returned HTTP {status}")
63
+ except Exception as e:
64
+ c.output.warn(f"Could not verify connection: {e}")
65
+
66
+ c.output.success(f"Configuration saved to profile '{profile_name}'")
67
+
68
+
69
+ @app.command()
70
+ def show(ctx: typer.Context) -> None:
71
+ """Show current configuration."""
72
+ c: AppContext = ctx.obj
73
+ pname = resolve_active_profile_name(c.profile)
74
+ svc = get_service_config(pname)
75
+
76
+ data = {
77
+ "profile": pname,
78
+ "service_key": SERVICE_KEY,
79
+ "url": svc.url or "(not set)",
80
+ "token": _mask_token(svc.token),
81
+ "container_name": svc.container_name or "(default)",
82
+ }
83
+
84
+ sections = [
85
+ (
86
+ "Configuration",
87
+ [
88
+ ("Profile", data["profile"]),
89
+ ("Service Key", data["service_key"]),
90
+ ("URL", data["url"]),
91
+ ("Token", data["token"]),
92
+ ("Container", data["container_name"]),
93
+ ],
94
+ ),
95
+ ]
96
+ c.output.detail("OpenCloud Config", sections, data_for_json=data)
97
+
98
+
99
+ @app.command()
100
+ def current(ctx: typer.Context) -> None:
101
+ """Show the active profile name."""
102
+ c: AppContext = ctx.obj
103
+ pname = resolve_active_profile_name(c.profile)
104
+ c.output.text(pname)
105
+
106
+
107
+ @app.command("set")
108
+ def set_(
109
+ ctx: typer.Context,
110
+ key: Annotated[str, typer.Argument(help="Config key (url, token, container_name)")],
111
+ value: Annotated[str, typer.Argument(help="Config value")],
112
+ ) -> None:
113
+ """Set a configuration value."""
114
+ c: AppContext = ctx.obj
115
+ valid_fields = set(ServiceConfig.model_fields.keys())
116
+ if key not in valid_fields:
117
+ c.output.error(f"Invalid key '{key}'. Valid keys: {', '.join(sorted(valid_fields))}")
118
+ raise typer.Exit(code=1)
119
+
120
+ pname = resolve_active_profile_name(c.profile)
121
+ svc = get_service_config(pname)
122
+ setattr(svc, key, value)
123
+ set_service_config(pname, svc)
124
+ display_value = _mask_token(value) if "token" in key else value
125
+ c.output.success(f"Set {key} = {display_value}")
126
+
127
+
128
+ @app.command()
129
+ def profiles(ctx: typer.Context) -> None:
130
+ """List all configuration profiles."""
131
+ c: AppContext = ctx.obj
132
+ names = get_profile_names()
133
+ default = get_default_profile()
134
+
135
+ rows = []
136
+ for name in names:
137
+ svc = get_service_config(name)
138
+ marker = "*" if name == default else ""
139
+ rows.append([marker, name, svc.url or "(not set)"])
140
+
141
+ c.output.table(
142
+ "Profiles",
143
+ [("", "cyan"), ("Name", "green"), ("URL", "white")],
144
+ rows,
145
+ data_for_json=[{"name": n, "default": n == default} for n in names],
146
+ )
147
+
148
+
149
+ @app.command("use")
150
+ def use_(
151
+ ctx: typer.Context,
152
+ name: Annotated[str, typer.Argument(help="Profile name to activate")],
153
+ ) -> None:
154
+ """Switch to a different profile."""
155
+ c: AppContext = ctx.obj
156
+ names = get_profile_names()
157
+ if name not in names:
158
+ c.output.error(f"Profile '{name}' not found. Available: {', '.join(names)}")
159
+ raise typer.Exit(code=1)
160
+ set_default_profile(name)
161
+ c.output.success(f"Switched to profile '{name}'")
162
+
163
+
164
+ @app.command()
165
+ def remove(
166
+ ctx: typer.Context,
167
+ name: Annotated[str, typer.Argument(help="Profile name to remove")],
168
+ ) -> None:
169
+ """Remove a configuration profile."""
170
+ c: AppContext = ctx.obj
171
+ remove_profile(name)
172
+ c.output.success(f"Removed profile '{name}'")
173
+
174
+
175
+ @app.command()
176
+ def test(ctx: typer.Context) -> None:
177
+ """Test the current connection."""
178
+ c: AppContext = ctx.obj
179
+ try:
180
+ status = c.client.check_health()
181
+ if status == 200:
182
+ c.output.success("Connection OK")
183
+ version = c.client.get_version()
184
+ if version:
185
+ c.output.info(f"OpenCloud {version}")
186
+ else:
187
+ c.output.error(f"Connection failed (HTTP {status})")
188
+ raise typer.Exit(code=1)
189
+ except Exception as e:
190
+ c.output.error(f"Connection failed: {e}")
191
+ raise typer.Exit(code=1)
@@ -0,0 +1,80 @@
1
+ """Dashboard commands for kctl-opencloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ from kctl_lib.exceptions import AuthenticationError, ConfigError
10
+
11
+ from kctl_opencloud.core.callbacks import AppContext
12
+
13
+ app = typer.Typer(help="System overview and statistics.")
14
+
15
+
16
+ def _fetch_dashboard(c: AppContext) -> dict[str, Any]:
17
+ """Fetch dashboard data."""
18
+ client = c.client
19
+ data: dict[str, Any] = {}
20
+
21
+ # Version
22
+ data["version"] = client.get_version() or "unknown"
23
+
24
+ # Resource counts
25
+ for resource, endpoint in [("users", "users"), ("groups", "groups"), ("spaces", "drives")]:
26
+ try:
27
+ result = client.get(endpoint)
28
+ data[resource] = len(result.get("value", []))
29
+ except (AuthenticationError, ConfigError):
30
+ raise
31
+ except Exception:
32
+ data[resource] = 0
33
+
34
+ return data
35
+
36
+
37
+ def _display_dashboard(c: AppContext, data: dict[str, Any]) -> None:
38
+ """Display dashboard."""
39
+ out = c.output
40
+
41
+ if c.json_mode:
42
+ out.raw_json(data)
43
+ return
44
+
45
+ out.header("OpenCloud Dashboard")
46
+ out.kv("URL", c.client.root_url)
47
+ out.kv("Version", data["version"])
48
+ out.text("")
49
+ out.kv("Users", str(data.get("users", 0)))
50
+ out.kv("Groups", str(data.get("groups", 0)))
51
+ out.kv("Spaces", str(data.get("spaces", 0)))
52
+
53
+
54
+ @app.callback(invoke_without_command=True)
55
+ def show(
56
+ ctx: typer.Context,
57
+ watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuous monitoring")] = False,
58
+ interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval")] = 10,
59
+ compact: Annotated[bool, typer.Option("--compact", "-c", help="Compact output")] = False,
60
+ ) -> None:
61
+ """Show system overview."""
62
+ c: AppContext = ctx.obj
63
+
64
+ if watch:
65
+ try:
66
+ while True:
67
+ data = _fetch_dashboard(c)
68
+ if compact:
69
+ c.output.text(f"Users: {data['users']} | Groups: {data['groups']} | Spaces: {data['spaces']}")
70
+ else:
71
+ _display_dashboard(c, data)
72
+ time.sleep(interval)
73
+ except KeyboardInterrupt:
74
+ pass
75
+ else:
76
+ data = _fetch_dashboard(c)
77
+ if compact:
78
+ c.output.text(f"Users: {data['users']} | Groups: {data['groups']} | Spaces: {data['spaces']}")
79
+ else:
80
+ _display_dashboard(c, data)
@@ -0,0 +1,110 @@
1
+ """Diagnostic checks for kctl-opencloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import typer
8
+
9
+ from kctl_opencloud.core.callbacks import AppContext
10
+
11
+
12
+ @dataclass
13
+ class CheckResult:
14
+ name: str
15
+ status: str # ok, fail, warn
16
+ message: str
17
+ fix_command: str = ""
18
+
19
+
20
+ def _check_connectivity(ctx: AppContext) -> CheckResult:
21
+ """Check API connectivity."""
22
+ from kctl_opencloud.core.config import get_service_config, resolve_active_profile_name
23
+
24
+ try:
25
+ pname = resolve_active_profile_name(ctx.profile)
26
+ cfg = get_service_config(pname)
27
+ if not cfg.url:
28
+ return CheckResult(
29
+ "API Connectivity",
30
+ "fail",
31
+ "No URL configured",
32
+ fix_command="kctl-opencloud config init",
33
+ )
34
+ # Now try connecting
35
+ from kctl_opencloud.core.client import OpenCloudClient
36
+
37
+ client = OpenCloudClient(base_url=cfg.url, credential=cfg.token)
38
+ status = client.check_health()
39
+ if status == 200:
40
+ return CheckResult("API Connectivity", "ok", f"Connected to {cfg.url}")
41
+ return CheckResult(
42
+ "API Connectivity",
43
+ "warn",
44
+ f"HTTP {status} from {cfg.url}",
45
+ )
46
+ except Exception as e:
47
+ return CheckResult(
48
+ "API Connectivity",
49
+ "fail",
50
+ str(e),
51
+ fix_command="kctl-opencloud config init",
52
+ )
53
+
54
+
55
+ def _check_auth(ctx: AppContext) -> CheckResult:
56
+ """Check authentication."""
57
+ from kctl_opencloud.core.config import get_service_config, resolve_active_profile_name
58
+
59
+ try:
60
+ pname = resolve_active_profile_name(ctx.profile)
61
+ cfg = get_service_config(pname)
62
+ if not cfg.url or not cfg.token:
63
+ return CheckResult(
64
+ "Authentication",
65
+ "fail",
66
+ "No URL or token configured",
67
+ fix_command="kctl-opencloud config init",
68
+ )
69
+ from kctl_opencloud.core.client import OpenCloudClient
70
+
71
+ client = OpenCloudClient(base_url=cfg.url, credential=cfg.token)
72
+ client.get("me")
73
+ return CheckResult("Authentication", "ok", "Authenticated")
74
+ except Exception:
75
+ return CheckResult(
76
+ "Authentication",
77
+ "fail",
78
+ "Authentication failed",
79
+ fix_command="kctl-opencloud config set token <your-token>",
80
+ )
81
+
82
+
83
+ app = typer.Typer(help="Diagnostic checks.")
84
+
85
+
86
+ @app.callback(invoke_without_command=True)
87
+ def doctor(ctx: typer.Context) -> None:
88
+ """Run all diagnostic checks."""
89
+ c: AppContext = ctx.obj
90
+ out = c.output
91
+
92
+ checks = [
93
+ _check_connectivity(c),
94
+ _check_auth(c),
95
+ ]
96
+
97
+ all_ok = True
98
+ for check in checks:
99
+ if check.status == "ok":
100
+ out.success(f"{check.name}: {check.message}")
101
+ elif check.status == "warn":
102
+ out.warn(f"{check.name}: {check.message}")
103
+ else:
104
+ out.error(f"{check.name}: {check.message}")
105
+ if check.fix_command:
106
+ out.info(f" Fix: {check.fix_command}")
107
+ all_ok = False
108
+
109
+ if not all_ok:
110
+ raise typer.Exit(code=1)
@@ -0,0 +1,130 @@
1
+ """Group management commands for kctl-opencloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any
6
+
7
+ import typer
8
+
9
+ from kctl_opencloud.core.callbacks import AppContext
10
+
11
+ app = typer.Typer(help="Group management.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_(
16
+ ctx: typer.Context,
17
+ search: Annotated[str | None, typer.Option("--search", "-s", help="Search by name")] = None,
18
+ ) -> None:
19
+ """List all groups."""
20
+ c: AppContext = ctx.obj
21
+ params: dict[str, Any] = {}
22
+ if search:
23
+ params["$search"] = search
24
+
25
+ groups = c.client.get_all("groups", params=params)
26
+
27
+ rows = []
28
+ for g in groups:
29
+ members = g.get("members", [])
30
+ rows.append(
31
+ [
32
+ g.get("id", ""),
33
+ g.get("displayName", "-"),
34
+ str(len(members)) if members else "0",
35
+ ]
36
+ )
37
+
38
+ c.output.table(
39
+ "Groups",
40
+ [("ID", "cyan"), ("Name", "green"), ("Members", "yellow")],
41
+ rows,
42
+ data_for_json=groups,
43
+ )
44
+
45
+
46
+ @app.command()
47
+ def get(
48
+ ctx: typer.Context,
49
+ group_id: Annotated[str, typer.Argument(help="Group ID")],
50
+ ) -> None:
51
+ """Get group details."""
52
+ c: AppContext = ctx.obj
53
+ group = c.client.get(f"groups/{group_id}")
54
+
55
+ members = group.get("members", [])
56
+ sections = [
57
+ (
58
+ "Group",
59
+ [
60
+ ("ID", group.get("id", "")),
61
+ ("Name", group.get("displayName", "-")),
62
+ ("Members", str(len(members))),
63
+ ],
64
+ ),
65
+ ]
66
+ c.output.detail("Group Details", sections, data_for_json=group)
67
+
68
+
69
+ @app.command()
70
+ def create(
71
+ ctx: typer.Context,
72
+ name: Annotated[str, typer.Argument(help="Group display name")],
73
+ ) -> None:
74
+ """Create a new group."""
75
+ c: AppContext = ctx.obj
76
+ group = c.client.post("groups", data={"displayName": name})
77
+ c.output.success(f"Group created: {group.get('displayName', name)}")
78
+
79
+
80
+ @app.command()
81
+ def update(
82
+ ctx: typer.Context,
83
+ group_id: Annotated[str, typer.Argument(help="Group ID")],
84
+ name: Annotated[str, typer.Option("--name", "-n", help="New display name")],
85
+ ) -> None:
86
+ """Update a group."""
87
+ c: AppContext = ctx.obj
88
+ c.client.patch(f"groups/{group_id}", data={"displayName": name})
89
+ c.output.success(f"Group {group_id} updated")
90
+
91
+
92
+ @app.command()
93
+ def delete(
94
+ ctx: typer.Context,
95
+ group_id: Annotated[str, typer.Argument(help="Group ID")],
96
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
97
+ ) -> None:
98
+ """Delete a group."""
99
+ c: AppContext = ctx.obj
100
+ if not force:
101
+ typer.confirm(f"Delete group {group_id}?", abort=True)
102
+ c.client.delete(f"groups/{group_id}")
103
+ c.output.success(f"Group {group_id} deleted")
104
+
105
+
106
+ @app.command("add-member")
107
+ def add_member(
108
+ ctx: typer.Context,
109
+ group_id: Annotated[str, typer.Argument(help="Group ID")],
110
+ user_id: Annotated[str, typer.Argument(help="User ID to add")],
111
+ ) -> None:
112
+ """Add a member to a group."""
113
+ c: AppContext = ctx.obj
114
+ c.client.post(
115
+ f"groups/{group_id}/members/$ref",
116
+ data={"@odata.id": f"{c.client.api_base_url}/users/{user_id}"},
117
+ )
118
+ c.output.success(f"User {user_id} added to group {group_id}")
119
+
120
+
121
+ @app.command("remove-member")
122
+ def remove_member(
123
+ ctx: typer.Context,
124
+ group_id: Annotated[str, typer.Argument(help="Group ID")],
125
+ user_id: Annotated[str, typer.Argument(help="User ID to remove")],
126
+ ) -> None:
127
+ """Remove a member from a group."""
128
+ c: AppContext = ctx.obj
129
+ c.client.delete(f"groups/{group_id}/members/{user_id}/$ref")
130
+ c.output.success(f"User {user_id} removed from group {group_id}")