opensandbox-cli 0.1.0.dev1__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.
- opensandbox_cli/__init__.py +20 -0
- opensandbox_cli/__main__.py +20 -0
- opensandbox_cli/client.py +172 -0
- opensandbox_cli/commands/__init__.py +13 -0
- opensandbox_cli/commands/command.py +303 -0
- opensandbox_cli/commands/config_cmd.py +145 -0
- opensandbox_cli/commands/devops.py +99 -0
- opensandbox_cli/commands/egress.py +89 -0
- opensandbox_cli/commands/file.py +360 -0
- opensandbox_cli/commands/sandbox.py +341 -0
- opensandbox_cli/commands/skills.py +248 -0
- opensandbox_cli/config.py +167 -0
- opensandbox_cli/main.py +122 -0
- opensandbox_cli/output.py +339 -0
- opensandbox_cli/py.typed +1 -0
- opensandbox_cli/skills/opensandbox-troubleshoot.md +112 -0
- opensandbox_cli/utils.py +145 -0
- opensandbox_cli-0.1.0.dev1.dist-info/METADATA +612 -0
- opensandbox_cli-0.1.0.dev1.dist-info/RECORD +22 -0
- opensandbox_cli-0.1.0.dev1.dist-info/WHEEL +4 -0
- opensandbox_cli-0.1.0.dev1.dist-info/entry_points.txt +3 -0
- opensandbox_cli-0.1.0.dev1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Experimental DevOps diagnostics commands: logs, inspect, events, summary."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from opensandbox_cli.client import ClientContext
|
|
22
|
+
from opensandbox_cli.utils import handle_errors
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fetch_plain_text(obj: ClientContext, sandbox_id: str, endpoint: str, params: dict | None = None) -> str:
|
|
26
|
+
"""Fetch a diagnostics endpoint and return the plain-text body."""
|
|
27
|
+
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
|
|
28
|
+
client = obj.get_devops_client()
|
|
29
|
+
resp = client.get(f"/sandboxes/{sandbox_id}/diagnostics/{endpoint}", params=params)
|
|
30
|
+
if resp.status_code == 404:
|
|
31
|
+
raise click.ClickException(f"Sandbox '{sandbox_id}' not found.")
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
return resp.text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.group("devops", invoke_without_command=True)
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def devops_group(ctx: click.Context) -> None:
|
|
39
|
+
"""Experimental diagnostics for sandbox troubleshooting."""
|
|
40
|
+
if ctx.invoked_subcommand is None:
|
|
41
|
+
click.echo(ctx.get_help())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---- logs ----------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@devops_group.command("logs")
|
|
47
|
+
@click.argument("sandbox_id")
|
|
48
|
+
@click.option("--tail", "-n", type=int, default=100, show_default=True, help="Number of trailing log lines.")
|
|
49
|
+
@click.option("--since", "-s", default=None, help="Only logs newer than this duration (e.g. 10m, 1h).")
|
|
50
|
+
@click.pass_obj
|
|
51
|
+
@handle_errors
|
|
52
|
+
def devops_logs(obj: ClientContext, sandbox_id: str, tail: int, since: str | None) -> None:
|
|
53
|
+
"""Retrieve container logs for a sandbox."""
|
|
54
|
+
params: dict = {"tail": tail}
|
|
55
|
+
if since:
|
|
56
|
+
params["since"] = since
|
|
57
|
+
text = _fetch_plain_text(obj, sandbox_id, "logs", params=params)
|
|
58
|
+
click.echo(text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---- inspect -------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
@devops_group.command("inspect")
|
|
64
|
+
@click.argument("sandbox_id")
|
|
65
|
+
@click.pass_obj
|
|
66
|
+
@handle_errors
|
|
67
|
+
def devops_inspect(obj: ClientContext, sandbox_id: str) -> None:
|
|
68
|
+
"""Retrieve detailed container/pod inspection info."""
|
|
69
|
+
text = _fetch_plain_text(obj, sandbox_id, "inspect")
|
|
70
|
+
click.echo(text)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---- events --------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
@devops_group.command("events")
|
|
76
|
+
@click.argument("sandbox_id")
|
|
77
|
+
@click.option("--limit", "-l", type=int, default=50, show_default=True, help="Maximum number of events.")
|
|
78
|
+
@click.pass_obj
|
|
79
|
+
@handle_errors
|
|
80
|
+
def devops_events(obj: ClientContext, sandbox_id: str, limit: int) -> None:
|
|
81
|
+
"""Retrieve events related to a sandbox."""
|
|
82
|
+
params: dict = {"limit": limit}
|
|
83
|
+
text = _fetch_plain_text(obj, sandbox_id, "events", params=params)
|
|
84
|
+
click.echo(text)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---- summary -------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@devops_group.command("summary")
|
|
90
|
+
@click.argument("sandbox_id")
|
|
91
|
+
@click.option("--tail", "-n", type=int, default=50, show_default=True, help="Number of trailing log lines.")
|
|
92
|
+
@click.option("--event-limit", type=int, default=20, show_default=True, help="Maximum number of events.")
|
|
93
|
+
@click.pass_obj
|
|
94
|
+
@handle_errors
|
|
95
|
+
def devops_summary(obj: ClientContext, sandbox_id: str, tail: int, event_limit: int) -> None:
|
|
96
|
+
"""One-shot diagnostics: inspect + events + logs combined."""
|
|
97
|
+
params: dict = {"tail": tail, "event_limit": event_limit}
|
|
98
|
+
text = _fetch_plain_text(obj, sandbox_id, "summary", params=params)
|
|
99
|
+
click.echo(text)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Runtime egress policy commands."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
from opensandbox.models.sandboxes import NetworkRule
|
|
21
|
+
|
|
22
|
+
from opensandbox_cli.client import ClientContext
|
|
23
|
+
from opensandbox_cli.utils import handle_errors
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_rule(value: str) -> NetworkRule:
|
|
27
|
+
"""Parse ACTION=TARGET into a NetworkRule."""
|
|
28
|
+
action, sep, target = value.partition("=")
|
|
29
|
+
if not sep:
|
|
30
|
+
raise click.BadParameter(
|
|
31
|
+
f"Invalid rule '{value}'. Use ACTION=TARGET, for example allow=pypi.org."
|
|
32
|
+
)
|
|
33
|
+
action = action.strip().lower()
|
|
34
|
+
target = target.strip()
|
|
35
|
+
if action not in ("allow", "deny"):
|
|
36
|
+
raise click.BadParameter(
|
|
37
|
+
f"Invalid rule action '{action}'. Use allow or deny."
|
|
38
|
+
)
|
|
39
|
+
return NetworkRule(action=action, target=target)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.group("egress", invoke_without_command=True)
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def egress_group(ctx: click.Context) -> None:
|
|
45
|
+
"""Manage runtime egress policy for a sandbox."""
|
|
46
|
+
if ctx.invoked_subcommand is None:
|
|
47
|
+
click.echo(ctx.get_help())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@egress_group.command("get")
|
|
51
|
+
@click.argument("sandbox_id")
|
|
52
|
+
@click.pass_obj
|
|
53
|
+
@handle_errors
|
|
54
|
+
def egress_get(obj: ClientContext, sandbox_id: str) -> None:
|
|
55
|
+
"""Get the current egress policy."""
|
|
56
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
57
|
+
try:
|
|
58
|
+
policy = sandbox.get_egress_policy()
|
|
59
|
+
obj.output.print_model(policy, title="Egress Policy")
|
|
60
|
+
finally:
|
|
61
|
+
sandbox.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@egress_group.command("patch")
|
|
65
|
+
@click.argument("sandbox_id")
|
|
66
|
+
@click.option(
|
|
67
|
+
"--rule",
|
|
68
|
+
"rules",
|
|
69
|
+
multiple=True,
|
|
70
|
+
required=True,
|
|
71
|
+
help="Patch rule in ACTION=TARGET form. Repeatable, e.g. --rule allow=pypi.org.",
|
|
72
|
+
)
|
|
73
|
+
@click.pass_obj
|
|
74
|
+
@handle_errors
|
|
75
|
+
def egress_patch(obj: ClientContext, sandbox_id: str, rules: tuple[str, ...]) -> None:
|
|
76
|
+
"""Patch runtime egress rules with merge semantics."""
|
|
77
|
+
parsed_rules = [_parse_rule(rule) for rule in rules]
|
|
78
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
79
|
+
try:
|
|
80
|
+
sandbox.patch_egress_rules(parsed_rules)
|
|
81
|
+
obj.output.success_panel(
|
|
82
|
+
{
|
|
83
|
+
"sandbox_id": sandbox.id,
|
|
84
|
+
"patched_rules": [rule.model_dump(mode="json") for rule in parsed_rules],
|
|
85
|
+
},
|
|
86
|
+
title="Egress Policy Patched",
|
|
87
|
+
)
|
|
88
|
+
finally:
|
|
89
|
+
sandbox.close()
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""File operation commands: cat, write, upload, download, rm, mv, mkdir, rmdir, search, info, chmod, replace."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
from opensandbox_cli.client import ClientContext
|
|
25
|
+
from opensandbox_cli.utils import handle_errors
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_permission_mode(mode: str) -> int:
|
|
29
|
+
"""Parse a permission mode string into a base-10 integer."""
|
|
30
|
+
try:
|
|
31
|
+
return int(mode)
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise click.BadParameter(
|
|
34
|
+
f"Invalid permission mode '{mode}'. Use a base-10 integer like 644 or 755."
|
|
35
|
+
) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group("file", invoke_without_command=True)
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def file_group(ctx: click.Context) -> None:
|
|
41
|
+
"""📁 File operations on a sandbox."""
|
|
42
|
+
if ctx.invoked_subcommand is None:
|
|
43
|
+
click.echo(ctx.get_help())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---- cat (read) -----------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@file_group.command("cat")
|
|
49
|
+
@click.argument("sandbox_id")
|
|
50
|
+
@click.argument("path")
|
|
51
|
+
@click.option("--encoding", default="utf-8", help="File encoding.")
|
|
52
|
+
@click.pass_obj
|
|
53
|
+
@handle_errors
|
|
54
|
+
def file_cat(obj: ClientContext, sandbox_id: str, path: str, encoding: str) -> None:
|
|
55
|
+
"""Read a file from the sandbox."""
|
|
56
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
57
|
+
try:
|
|
58
|
+
content = sandbox.files.read_file(path, encoding=encoding)
|
|
59
|
+
click.echo(content, nl=False)
|
|
60
|
+
finally:
|
|
61
|
+
sandbox.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---- write ----------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@file_group.command("write")
|
|
67
|
+
@click.argument("sandbox_id")
|
|
68
|
+
@click.argument("path")
|
|
69
|
+
@click.option("--content", "-c", default=None, help="Content to write. Reads from stdin if not provided.")
|
|
70
|
+
@click.option("--encoding", default="utf-8", help="File encoding.")
|
|
71
|
+
@click.option("--mode", default=None, help="File permission mode (e.g. 0644).")
|
|
72
|
+
@click.option("--owner", default=None, help="File owner.")
|
|
73
|
+
@click.option("--group", default=None, help="File group.")
|
|
74
|
+
@click.pass_obj
|
|
75
|
+
@handle_errors
|
|
76
|
+
def file_write(
|
|
77
|
+
obj: ClientContext,
|
|
78
|
+
sandbox_id: str,
|
|
79
|
+
path: str,
|
|
80
|
+
content: str | None,
|
|
81
|
+
encoding: str,
|
|
82
|
+
mode: str | None,
|
|
83
|
+
owner: str | None,
|
|
84
|
+
group: str | None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Write content to a file in the sandbox."""
|
|
87
|
+
file_content = content
|
|
88
|
+
if file_content is None:
|
|
89
|
+
if sys.stdin.isatty():
|
|
90
|
+
click.echo("Reading from stdin (Ctrl+D to finish):", err=True)
|
|
91
|
+
file_content = sys.stdin.read()
|
|
92
|
+
|
|
93
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
94
|
+
try:
|
|
95
|
+
kwargs: dict = {"encoding": encoding}
|
|
96
|
+
if mode is not None:
|
|
97
|
+
kwargs["mode"] = _parse_permission_mode(mode)
|
|
98
|
+
if owner is not None:
|
|
99
|
+
kwargs["owner"] = owner
|
|
100
|
+
if group is not None:
|
|
101
|
+
kwargs["group"] = group
|
|
102
|
+
sandbox.files.write_file(path, file_content, **kwargs)
|
|
103
|
+
obj.output.success(f"Written: {path}")
|
|
104
|
+
finally:
|
|
105
|
+
sandbox.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- upload ---------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
@file_group.command("upload")
|
|
111
|
+
@click.argument("sandbox_id")
|
|
112
|
+
@click.argument("local_path", type=click.Path(exists=True))
|
|
113
|
+
@click.argument("remote_path")
|
|
114
|
+
@click.pass_obj
|
|
115
|
+
@handle_errors
|
|
116
|
+
def file_upload(
|
|
117
|
+
obj: ClientContext, sandbox_id: str, local_path: str, remote_path: str
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Upload a local file to the sandbox."""
|
|
120
|
+
data = Path(local_path).read_bytes()
|
|
121
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
122
|
+
try:
|
|
123
|
+
sandbox.files.write_file(remote_path, data)
|
|
124
|
+
obj.output.success(f"Uploaded: {local_path} → {remote_path}")
|
|
125
|
+
finally:
|
|
126
|
+
sandbox.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---- download -------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@file_group.command("download")
|
|
132
|
+
@click.argument("sandbox_id")
|
|
133
|
+
@click.argument("remote_path")
|
|
134
|
+
@click.argument("local_path", type=click.Path())
|
|
135
|
+
@click.pass_obj
|
|
136
|
+
@handle_errors
|
|
137
|
+
def file_download(
|
|
138
|
+
obj: ClientContext, sandbox_id: str, remote_path: str, local_path: str
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Download a file from the sandbox to local disk."""
|
|
141
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
142
|
+
try:
|
|
143
|
+
content = sandbox.files.read_bytes(remote_path)
|
|
144
|
+
Path(local_path).write_bytes(content)
|
|
145
|
+
obj.output.success(f"Downloaded: {remote_path} → {local_path}")
|
|
146
|
+
finally:
|
|
147
|
+
sandbox.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- rm (delete) ----------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
@file_group.command("rm")
|
|
153
|
+
@click.argument("sandbox_id")
|
|
154
|
+
@click.argument("paths", nargs=-1, required=True)
|
|
155
|
+
@click.pass_obj
|
|
156
|
+
@handle_errors
|
|
157
|
+
def file_rm(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:
|
|
158
|
+
"""Delete files from the sandbox."""
|
|
159
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
160
|
+
try:
|
|
161
|
+
sandbox.files.delete_files(list(paths))
|
|
162
|
+
for p in paths:
|
|
163
|
+
obj.output.success(f"Deleted: {p}")
|
|
164
|
+
finally:
|
|
165
|
+
sandbox.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---- mv (move) ------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
@file_group.command("mv")
|
|
171
|
+
@click.argument("sandbox_id")
|
|
172
|
+
@click.argument("source")
|
|
173
|
+
@click.argument("destination")
|
|
174
|
+
@click.pass_obj
|
|
175
|
+
@handle_errors
|
|
176
|
+
def file_mv(
|
|
177
|
+
obj: ClientContext, sandbox_id: str, source: str, destination: str
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Move/rename a file in the sandbox."""
|
|
180
|
+
from opensandbox.models.filesystem import MoveEntry
|
|
181
|
+
|
|
182
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
183
|
+
try:
|
|
184
|
+
sandbox.files.move_files([MoveEntry(source=source, destination=destination)])
|
|
185
|
+
obj.output.success(f"Moved: {source} → {destination}")
|
|
186
|
+
finally:
|
|
187
|
+
sandbox.close()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---- mkdir ----------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
@file_group.command("mkdir")
|
|
193
|
+
@click.argument("sandbox_id")
|
|
194
|
+
@click.argument("paths", nargs=-1, required=True)
|
|
195
|
+
@click.option("--mode", default=None, help="Directory permission mode.")
|
|
196
|
+
@click.option("--owner", default=None, help="Directory owner.")
|
|
197
|
+
@click.option("--group", default=None, help="Directory group.")
|
|
198
|
+
@click.pass_obj
|
|
199
|
+
@handle_errors
|
|
200
|
+
def file_mkdir(
|
|
201
|
+
obj: ClientContext,
|
|
202
|
+
sandbox_id: str,
|
|
203
|
+
paths: tuple[str, ...],
|
|
204
|
+
mode: str | None,
|
|
205
|
+
owner: str | None,
|
|
206
|
+
group: str | None,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Create directories in the sandbox."""
|
|
209
|
+
from opensandbox.models.filesystem import WriteEntry
|
|
210
|
+
|
|
211
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
212
|
+
try:
|
|
213
|
+
entries = []
|
|
214
|
+
for p in paths:
|
|
215
|
+
kwargs: dict = {"path": p}
|
|
216
|
+
if mode is not None:
|
|
217
|
+
kwargs["mode"] = _parse_permission_mode(mode)
|
|
218
|
+
if owner is not None:
|
|
219
|
+
kwargs["owner"] = owner
|
|
220
|
+
if group is not None:
|
|
221
|
+
kwargs["group"] = group
|
|
222
|
+
entries.append(WriteEntry(**kwargs))
|
|
223
|
+
sandbox.files.create_directories(entries)
|
|
224
|
+
for p in paths:
|
|
225
|
+
obj.output.success(f"Created: {p}")
|
|
226
|
+
finally:
|
|
227
|
+
sandbox.close()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---- rmdir ----------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
@file_group.command("rmdir")
|
|
233
|
+
@click.argument("sandbox_id")
|
|
234
|
+
@click.argument("paths", nargs=-1, required=True)
|
|
235
|
+
@click.pass_obj
|
|
236
|
+
@handle_errors
|
|
237
|
+
def file_rmdir(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:
|
|
238
|
+
"""Delete directories from the sandbox."""
|
|
239
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
240
|
+
try:
|
|
241
|
+
sandbox.files.delete_directories(list(paths))
|
|
242
|
+
for p in paths:
|
|
243
|
+
obj.output.success(f"Removed: {p}")
|
|
244
|
+
finally:
|
|
245
|
+
sandbox.close()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---- search ---------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@file_group.command("search")
|
|
251
|
+
@click.argument("sandbox_id")
|
|
252
|
+
@click.argument("path")
|
|
253
|
+
@click.option("--pattern", "-p", required=True, help="Glob pattern to search for.")
|
|
254
|
+
@click.pass_obj
|
|
255
|
+
@handle_errors
|
|
256
|
+
def file_search(
|
|
257
|
+
obj: ClientContext, sandbox_id: str, path: str, pattern: str
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Search for files in the sandbox."""
|
|
260
|
+
from opensandbox.models.filesystem import SearchEntry
|
|
261
|
+
|
|
262
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
263
|
+
try:
|
|
264
|
+
results = sandbox.files.search(SearchEntry(path=path, pattern=pattern))
|
|
265
|
+
if not results:
|
|
266
|
+
if obj.output.fmt in ("json", "yaml"):
|
|
267
|
+
obj.output.print_models([], columns=[])
|
|
268
|
+
else:
|
|
269
|
+
obj.output.info("No files found.")
|
|
270
|
+
return
|
|
271
|
+
if obj.output.fmt in ("json", "yaml"):
|
|
272
|
+
obj.output.print_models(results, columns=["path", "size", "mode", "owner", "modified_at"])
|
|
273
|
+
else:
|
|
274
|
+
obj.output.print_models(results, columns=["path", "size", "owner"], title="Search Results")
|
|
275
|
+
finally:
|
|
276
|
+
sandbox.close()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---- info (stat) ----------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
@file_group.command("info")
|
|
282
|
+
@click.argument("sandbox_id")
|
|
283
|
+
@click.argument("paths", nargs=-1, required=True)
|
|
284
|
+
@click.pass_obj
|
|
285
|
+
@handle_errors
|
|
286
|
+
def file_info(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:
|
|
287
|
+
"""Get file/directory info."""
|
|
288
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
289
|
+
try:
|
|
290
|
+
info_map = sandbox.files.get_file_info(list(paths))
|
|
291
|
+
for path, entry in info_map.items():
|
|
292
|
+
obj.output.print_dict(
|
|
293
|
+
{"path": path, **entry.model_dump(mode="json")},
|
|
294
|
+
title=path,
|
|
295
|
+
)
|
|
296
|
+
finally:
|
|
297
|
+
sandbox.close()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---- chmod ----------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
@file_group.command("chmod")
|
|
303
|
+
@click.argument("sandbox_id")
|
|
304
|
+
@click.argument("path")
|
|
305
|
+
@click.option("--mode", required=True, help="Permission mode (e.g. 0755).")
|
|
306
|
+
@click.option("--owner", default=None, help="File owner.")
|
|
307
|
+
@click.option("--group", default=None, help="File group.")
|
|
308
|
+
@click.pass_obj
|
|
309
|
+
@handle_errors
|
|
310
|
+
def file_chmod(
|
|
311
|
+
obj: ClientContext,
|
|
312
|
+
sandbox_id: str,
|
|
313
|
+
path: str,
|
|
314
|
+
mode: str,
|
|
315
|
+
owner: str | None,
|
|
316
|
+
group: str | None,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Set file permissions."""
|
|
319
|
+
from opensandbox.models.filesystem import SetPermissionEntry
|
|
320
|
+
|
|
321
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
322
|
+
try:
|
|
323
|
+
sandbox.files.set_permissions(
|
|
324
|
+
[
|
|
325
|
+
SetPermissionEntry(
|
|
326
|
+
path=path,
|
|
327
|
+
mode=_parse_permission_mode(mode),
|
|
328
|
+
owner=owner,
|
|
329
|
+
group=group,
|
|
330
|
+
)
|
|
331
|
+
]
|
|
332
|
+
)
|
|
333
|
+
obj.output.success(f"Permissions set: {path}")
|
|
334
|
+
finally:
|
|
335
|
+
sandbox.close()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---- replace --------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
@file_group.command("replace")
|
|
341
|
+
@click.argument("sandbox_id")
|
|
342
|
+
@click.argument("path")
|
|
343
|
+
@click.option("--old", required=True, help="Text to search for.")
|
|
344
|
+
@click.option("--new", required=True, help="Replacement text.")
|
|
345
|
+
@click.pass_obj
|
|
346
|
+
@handle_errors
|
|
347
|
+
def file_replace(
|
|
348
|
+
obj: ClientContext, sandbox_id: str, path: str, old: str, new: str
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Replace content in a file."""
|
|
351
|
+
from opensandbox.models.filesystem import ContentReplaceEntry
|
|
352
|
+
|
|
353
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
354
|
+
try:
|
|
355
|
+
sandbox.files.replace_contents(
|
|
356
|
+
[ContentReplaceEntry(path=path, old_content=old, new_content=new)]
|
|
357
|
+
)
|
|
358
|
+
obj.output.success(f"Replaced in: {path}")
|
|
359
|
+
finally:
|
|
360
|
+
sandbox.close()
|