hivespace 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.
- hivespace/__init__.py +1 -0
- hivespace/cli/__init__.py +0 -0
- hivespace/cli/app.py +73 -0
- hivespace/cli/banner.py +28 -0
- hivespace/cli/cmd_artifact.py +37 -0
- hivespace/cli/cmd_auth.py +108 -0
- hivespace/cli/cmd_chat.py +72 -0
- hivespace/cli/cmd_fs.py +247 -0
- hivespace/cli/cmd_inbox.py +65 -0
- hivespace/cli/cmd_tasks.py +260 -0
- hivespace/cli/cmd_team.py +60 -0
- hivespace/cli/cmd_workflow.py +221 -0
- hivespace/cli/components/__init__.py +3 -0
- hivespace/cli/components/chat.py +43 -0
- hivespace/cli/console.py +10 -0
- hivespace/cli/formatting.py +71 -0
- hivespace/cli/help_text.py +29 -0
- hivespace/cli/helpers.py +328 -0
- hivespace/cli/state.py +41 -0
- hivespace-0.1.0.dist-info/METADATA +30 -0
- hivespace-0.1.0.dist-info/RECORD +24 -0
- hivespace-0.1.0.dist-info/WHEEL +5 -0
- hivespace-0.1.0.dist-info/entry_points.txt +2 -0
- hivespace-0.1.0.dist-info/top_level.txt +1 -0
hivespace/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# hivespace package marker
|
|
File without changes
|
hivespace/cli/app.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json as json_mod
|
|
2
|
+
from typing import Annotated, Optional
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from hivespace.cli.state import set_json_mode
|
|
8
|
+
from hivespace.cli.cmd_auth import auth_app
|
|
9
|
+
from hivespace.cli.cmd_artifact import artifact_app
|
|
10
|
+
from hivespace.cli.cmd_chat import chat_app
|
|
11
|
+
from hivespace.cli.cmd_inbox import inbox_app
|
|
12
|
+
from hivespace.cli.cmd_team import team_app
|
|
13
|
+
from hivespace.cli.cmd_tasks import task_app
|
|
14
|
+
from hivespace.cli.cmd_workflow import workflow_app
|
|
15
|
+
from hivespace.cli.cmd_fs import fs_app
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="hivespace",
|
|
19
|
+
rich_markup_mode="rich",
|
|
20
|
+
context_settings={"max_content_width": 120},
|
|
21
|
+
no_args_is_help=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _version_callback(value: bool):
|
|
26
|
+
if value:
|
|
27
|
+
from importlib.metadata import version
|
|
28
|
+
click.echo(f"hivespace {version('hivespace')}")
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback(invoke_without_command=True)
|
|
33
|
+
def main(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
version: Annotated[Optional[bool], typer.Option(
|
|
36
|
+
"--version", "-v", help="Show version", callback=_version_callback, is_eager=True,
|
|
37
|
+
)] = None,
|
|
38
|
+
):
|
|
39
|
+
"""Hivespace — multi-agent workspace CLI."""
|
|
40
|
+
set_json_mode(False)
|
|
41
|
+
if ctx.invoked_subcommand is None:
|
|
42
|
+
from hivespace.cli.banner import print_banner
|
|
43
|
+
print_banner()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
app.add_typer(auth_app, name="auth", help="Sign in with a personal access token.")
|
|
47
|
+
app.add_typer(chat_app, name="chat", help="Send and read messages.")
|
|
48
|
+
app.add_typer(artifact_app, name="artifact", help="Link artifacts to this chat.")
|
|
49
|
+
app.add_typer(inbox_app, name="inbox", help="View and manage @-mentions.")
|
|
50
|
+
app.add_typer(team_app, name="team", help="Team info and agents.")
|
|
51
|
+
app.add_typer(task_app, name="task", help="Create and manage tasks.")
|
|
52
|
+
app.add_typer(workflow_app, name="workflow", help="Create, manage, suggest, and report on workflows (each runs in its own dedicated agent).")
|
|
53
|
+
app.add_typer(fs_app, name="fs", help="Shared team drive (ls/cat/write/edit/cp).")
|
|
54
|
+
|
|
55
|
+
_base_cli = typer.main.get_command(app)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HivespaceGroup(type(_base_cli)):
|
|
59
|
+
def invoke(self, ctx):
|
|
60
|
+
try:
|
|
61
|
+
return super().invoke(ctx)
|
|
62
|
+
except click.ClickException as e:
|
|
63
|
+
from hivespace.cli.state import is_json_mode
|
|
64
|
+
if is_json_mode():
|
|
65
|
+
click.echo(json_mod.dumps({"error": e.format_message()}))
|
|
66
|
+
ctx.exit(e.exit_code)
|
|
67
|
+
else:
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
cli = _base_cli
|
|
72
|
+
cli.__class__ = HivespaceGroup
|
|
73
|
+
cli.help = "Hivespace — multi-agent workspace CLI."
|
hivespace/cli/banner.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from hivespace.cli.console import get_console
|
|
2
|
+
|
|
3
|
+
WORDMARK = r"""
|
|
4
|
+
██╗ ██╗██╗██╗ ██╗███████╗███████╗██████╗ █████╗ ██████╗███████╗
|
|
5
|
+
██║ ██║██║██║ ██║██╔════╝██╔════╝██╔══██╗██╔══██╗██╔════╝██╔════╝
|
|
6
|
+
███████║██║██║ ██║█████╗ ███████╗██████╔╝███████║██║ █████╗
|
|
7
|
+
██╔══██║██║╚██╗ ██╔╝██╔══╝ ╚════██║██╔═══╝ ██╔══██║██║ ██╔══╝
|
|
8
|
+
██║ ██║██║ ╚████╔╝ ███████╗███████║██║ ██║ ██║╚██████╗███████╗
|
|
9
|
+
╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
COMMANDS_SUMMARY = """\
|
|
13
|
+
[bold]Commands:[/bold]
|
|
14
|
+
[#e68a00]auth[/#e68a00] Authentication and identity
|
|
15
|
+
[#e68a00]team[/#e68a00] Team info and agents
|
|
16
|
+
[#e68a00]chat[/#e68a00] Send and read messages
|
|
17
|
+
[#e68a00]task[/#e68a00] Create and manage tasks
|
|
18
|
+
[#e68a00]inbox[/#e68a00] View @-mentions
|
|
19
|
+
[#e68a00]fs[/#e68a00] Shared team drive (ls/cat/write/edit/cp)
|
|
20
|
+
|
|
21
|
+
[dim]Run 'hivespace <command> --help' for details on any command.[/dim]"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_banner() -> None:
|
|
25
|
+
console = get_console()
|
|
26
|
+
console.print(WORDMARK, style="bold #e68a00", highlight=False)
|
|
27
|
+
console.print("[dim]Multi-human, multi-agent teamspace[/dim]\n")
|
|
28
|
+
console.print(COMMANDS_SUMMARY)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from hivespace.cli.formatting import ok
|
|
6
|
+
from hivespace.cli.helpers import _api, _json_out, _resolve_team
|
|
7
|
+
from hivespace.cli.state import DeprecatedWorkspaceOpt, JsonFlag, TeamOpt, _set_team
|
|
8
|
+
|
|
9
|
+
artifact_app = typer.Typer(no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@artifact_app.callback()
|
|
13
|
+
def artifact_callback(team_opt: TeamOpt = None, workspace_opt: DeprecatedWorkspaceOpt = None):
|
|
14
|
+
"""Artifacts — rendered webapps under the team's .artifact/ folder."""
|
|
15
|
+
_set_team(team_opt)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@artifact_app.command("link")
|
|
19
|
+
def artifact_link(
|
|
20
|
+
name: Annotated[str, typer.Argument(help="Artifact folder name under .artifact/")],
|
|
21
|
+
as_json: JsonFlag = False,
|
|
22
|
+
team_opt: TeamOpt = None,
|
|
23
|
+
workspace_opt: DeprecatedWorkspaceOpt = None,
|
|
24
|
+
):
|
|
25
|
+
"""Link an artifact to this chat.
|
|
26
|
+
|
|
27
|
+
Run this after writing/updating ``.artifact/<name>/`` so the artifact
|
|
28
|
+
surfaces as this chat's default in the preview panel. Safe to re-run —
|
|
29
|
+
it just refreshes the link.
|
|
30
|
+
"""
|
|
31
|
+
team = _resolve_team(team_opt)
|
|
32
|
+
data = _api("POST", f"/teams/{team}/artifacts/{name}/link")
|
|
33
|
+
if as_json:
|
|
34
|
+
_json_out(data)
|
|
35
|
+
else:
|
|
36
|
+
url = data.get("external_url")
|
|
37
|
+
ok(f"team:{team} artifact={data.get('name', name)}" + (f" {url}" if url else ""))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""hivespace auth — sign a local session in with a personal access token.
|
|
2
|
+
|
|
3
|
+
PATs are minted in the web UI (scoped to a team, read or read/write) and
|
|
4
|
+
pasted here. ``login`` validates and stores the token; the CLI then talks
|
|
5
|
+
to the server as your user (``Authorization: Bearer``) instead of as an
|
|
6
|
+
agent. ``whoami`` shows what the token can reach; ``logout`` clears it.
|
|
7
|
+
|
|
8
|
+
hivespace auth login [TOKEN] # arg, prompt, or piped stdin
|
|
9
|
+
hivespace auth whoami
|
|
10
|
+
hivespace auth logout
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Annotated, Optional
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from hivespace.cli.formatting import ok
|
|
19
|
+
from hivespace.cli.helpers import _api, _config, _json_out, _server_url, _set_auth, _set_server_url
|
|
20
|
+
from hivespace.cli.state import JsonFlag, is_json_mode
|
|
21
|
+
|
|
22
|
+
auth_app = typer.Typer(no_args_is_help=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _scope_label(me: dict) -> str:
|
|
26
|
+
teams = ", ".join(t["slug"] for t in me.get("teams", [])) or "(no team)"
|
|
27
|
+
level = "read/write" if me.get("can_write") else "read-only"
|
|
28
|
+
return f"team {teams} — {level}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@auth_app.command("login")
|
|
32
|
+
def auth_login(
|
|
33
|
+
token: Annotated[Optional[str], typer.Argument(help="PAT (omit to paste/pipe)")] = None,
|
|
34
|
+
as_json: JsonFlag = False,
|
|
35
|
+
):
|
|
36
|
+
"""Store a personal access token for this machine.
|
|
37
|
+
|
|
38
|
+
The token is validated against the server before it's saved. Pass it
|
|
39
|
+
as an argument, pipe it on stdin, or omit it to be prompted (hidden).
|
|
40
|
+
"""
|
|
41
|
+
if token is None:
|
|
42
|
+
if not sys.stdin.isatty():
|
|
43
|
+
token = sys.stdin.read().strip()
|
|
44
|
+
else:
|
|
45
|
+
token = click.prompt("Paste your token", hide_input=True).strip()
|
|
46
|
+
token = (token or "").strip()
|
|
47
|
+
if not token:
|
|
48
|
+
raise click.ClickException("no token provided")
|
|
49
|
+
|
|
50
|
+
server = _server_url()
|
|
51
|
+
# Validate by identifying ourselves; this also fetches the scope to
|
|
52
|
+
# show. Pass the token explicitly so we don't depend on it being saved.
|
|
53
|
+
me = _api("GET", "/me", headers={"Authorization": f"Bearer {token}"})
|
|
54
|
+
if not me.get("is_pat"):
|
|
55
|
+
raise click.ClickException("that token is not a personal access token")
|
|
56
|
+
|
|
57
|
+
_set_auth({
|
|
58
|
+
"token": token,
|
|
59
|
+
"prefix": token[:12],
|
|
60
|
+
"label": _scope_label(me),
|
|
61
|
+
"server_url": server.rstrip("/"),
|
|
62
|
+
})
|
|
63
|
+
# Pin the CLI to this server too, so later shells resolve the same host
|
|
64
|
+
# without HIVESPACE_SERVER set — otherwise _active_pat() drops the token
|
|
65
|
+
# on a server mismatch and you'd have to log in again.
|
|
66
|
+
_set_server_url(server)
|
|
67
|
+
|
|
68
|
+
if as_json:
|
|
69
|
+
_json_out({"ok": True, **me})
|
|
70
|
+
return
|
|
71
|
+
user = me.get("user") or {}
|
|
72
|
+
who = user.get("handle") or user.get("email") or "you"
|
|
73
|
+
ok(f"authenticated as {who} — {_scope_label(me)}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@auth_app.command("whoami")
|
|
77
|
+
def auth_whoami(as_json: JsonFlag = False):
|
|
78
|
+
"""Show the current identity and what the stored token can reach."""
|
|
79
|
+
me = _api("GET", "/me")
|
|
80
|
+
if as_json:
|
|
81
|
+
_json_out(me)
|
|
82
|
+
return
|
|
83
|
+
if me.get("kind") == "agent":
|
|
84
|
+
# An agent's identity is the agent itself, not its owner user.
|
|
85
|
+
a = me.get("agent") or {}
|
|
86
|
+
who = a.get("name") or (f"agent#{a['id']}" if a.get("id") else "(agent)")
|
|
87
|
+
mode = "agent"
|
|
88
|
+
else:
|
|
89
|
+
user = me.get("user") or {}
|
|
90
|
+
who = user.get("handle") or user.get("email") or "(unknown)"
|
|
91
|
+
mode = "PAT" if me.get("is_pat") else me.get("kind", "?")
|
|
92
|
+
click.echo(f"{who} [{mode}] {_scope_label(me)}")
|
|
93
|
+
teams = me.get("teams", [])
|
|
94
|
+
if teams:
|
|
95
|
+
click.echo("in-scope teams:")
|
|
96
|
+
for t in teams:
|
|
97
|
+
click.echo(f" --team {t['slug']} {t.get('name', '')}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@auth_app.command("logout")
|
|
101
|
+
def auth_logout():
|
|
102
|
+
"""Forget the stored token (reverts to agent-token mode if configured)."""
|
|
103
|
+
had = bool((_config().get("auth") or {}).get("token"))
|
|
104
|
+
_set_auth(None)
|
|
105
|
+
if is_json_mode():
|
|
106
|
+
_json_out({"ok": True, "was_logged_in": had})
|
|
107
|
+
return
|
|
108
|
+
ok("logged out" if had else "no token was stored")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from typing import Annotated, Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from hivespace.cli.components.chat import print_history, print_thread
|
|
6
|
+
from hivespace.cli.formatting import ok
|
|
7
|
+
from hivespace.cli.helpers import _api, _json_out, _resolve_team
|
|
8
|
+
from hivespace.cli.state import DeprecatedWorkspaceOpt, JsonFlag, TeamOpt, _set_team
|
|
9
|
+
|
|
10
|
+
chat_app = typer.Typer(no_args_is_help=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@chat_app.callback()
|
|
14
|
+
def chat_callback(team_opt: TeamOpt = None, workspace_opt: DeprecatedWorkspaceOpt = None):
|
|
15
|
+
"""Chat — the team's Group Chat: messages and threads."""
|
|
16
|
+
_set_team(team_opt)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@chat_app.command("send")
|
|
20
|
+
def chat_send(
|
|
21
|
+
text: Annotated[str, typer.Argument(help="Message text")],
|
|
22
|
+
thread: Annotated[Optional[str], typer.Option("--thread", "-t", help="Reply to a message ts")] = None,
|
|
23
|
+
as_json: JsonFlag = False,
|
|
24
|
+
team_opt: TeamOpt = None,
|
|
25
|
+
workspace_opt: DeprecatedWorkspaceOpt = None,
|
|
26
|
+
):
|
|
27
|
+
"""Post a message to the team's Group Chat."""
|
|
28
|
+
team = _resolve_team(team_opt)
|
|
29
|
+
payload: dict = {"text": text}
|
|
30
|
+
if thread:
|
|
31
|
+
payload["thread_ts"] = thread
|
|
32
|
+
data = _api("POST", f"/teams/{team}/messages", json=payload)
|
|
33
|
+
if as_json:
|
|
34
|
+
_json_out(data)
|
|
35
|
+
else:
|
|
36
|
+
ok(f"team:{team} ts={data.get('ts')}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@chat_app.command("history")
|
|
40
|
+
def chat_history(
|
|
41
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max messages")] = 50,
|
|
42
|
+
before: Annotated[Optional[str], typer.Option("--before", help="Cursor: ts to page back from")] = None,
|
|
43
|
+
as_json: JsonFlag = False,
|
|
44
|
+
team_opt: TeamOpt = None,
|
|
45
|
+
workspace_opt: DeprecatedWorkspaceOpt = None,
|
|
46
|
+
):
|
|
47
|
+
"""Read recent messages in the team's Group Chat."""
|
|
48
|
+
team = _resolve_team(team_opt)
|
|
49
|
+
params: dict = {"limit": limit}
|
|
50
|
+
if before:
|
|
51
|
+
params["before"] = before
|
|
52
|
+
data = _api("GET", f"/teams/{team}/messages", params=params)
|
|
53
|
+
if as_json:
|
|
54
|
+
_json_out(data)
|
|
55
|
+
return
|
|
56
|
+
print_history(f"team:{team}", data.get("messages", []))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@chat_app.command("thread")
|
|
60
|
+
def chat_thread(
|
|
61
|
+
ts: Annotated[str, typer.Argument(help="Parent message ts")],
|
|
62
|
+
as_json: JsonFlag = False,
|
|
63
|
+
team_opt: TeamOpt = None,
|
|
64
|
+
workspace_opt: DeprecatedWorkspaceOpt = None,
|
|
65
|
+
):
|
|
66
|
+
"""Show a thread (parent message and replies)."""
|
|
67
|
+
team = _resolve_team(team_opt)
|
|
68
|
+
data = _api("GET", f"/teams/{team}/messages/{ts}/replies")
|
|
69
|
+
if as_json:
|
|
70
|
+
_json_out(data)
|
|
71
|
+
return
|
|
72
|
+
print_thread(f"team:{team}", data.get("parent", {}), data.get("replies", []))
|
hivespace/cli/cmd_fs.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""hivespace fs — the shared team drive (Dropbox-style, CLI-only).
|
|
2
|
+
|
|
3
|
+
Every agent in a team reads/writes one shared filesystem, namespaced
|
|
4
|
+
server-side by ``team/<team_id>/``. The team is inferred from the agent
|
|
5
|
+
token, so no ``--workspace`` is needed. Drive paths are written relative
|
|
6
|
+
to the drive root (``reports/q1.csv``); ``cp`` marks the drive side with
|
|
7
|
+
an ``drive:`` scheme so it can tell a drive path from a local sandbox
|
|
8
|
+
path.
|
|
9
|
+
|
|
10
|
+
hivespace fs ls [path]
|
|
11
|
+
hivespace fs cat <path>
|
|
12
|
+
hivespace fs write <path> [content] [--from <local>]
|
|
13
|
+
hivespace fs edit <path> --old <s> --new <s> [--all]
|
|
14
|
+
hivespace fs mkdir <path>
|
|
15
|
+
hivespace fs mv <src> <dst>
|
|
16
|
+
hivespace fs rm <path>
|
|
17
|
+
hivespace fs cp drive:/reports/a.csv ./a.csv # download
|
|
18
|
+
hivespace fs cp ./a.csv drive:/reports/a.csv # upload
|
|
19
|
+
"""
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Annotated, Optional
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
import typer
|
|
26
|
+
|
|
27
|
+
from hivespace.cli.formatting import empty, ok
|
|
28
|
+
from hivespace.cli.helpers import _api, _api_bytes, _api_upload, _json_out
|
|
29
|
+
from hivespace.cli.state import JsonFlag
|
|
30
|
+
|
|
31
|
+
fs_app = typer.Typer(no_args_is_help=True)
|
|
32
|
+
|
|
33
|
+
_SCHEME = "drive:"
|
|
34
|
+
_team: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@fs_app.callback()
|
|
38
|
+
def fs_callback(
|
|
39
|
+
team: Annotated[Optional[str], typer.Option(
|
|
40
|
+
"--team", help="Team slug. Required for user/PAT sessions that "
|
|
41
|
+
"belong to more than one team; agents infer it from their token.",
|
|
42
|
+
)] = None,
|
|
43
|
+
):
|
|
44
|
+
"""Shared team drive."""
|
|
45
|
+
global _team
|
|
46
|
+
_team = team or None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _params(extra: Optional[dict] = None) -> dict:
|
|
50
|
+
"""Merge the selected ``--team`` into a query-param dict."""
|
|
51
|
+
p = dict(extra or {})
|
|
52
|
+
if _team:
|
|
53
|
+
p["team"] = _team
|
|
54
|
+
return p
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_drive(p: str) -> bool:
|
|
58
|
+
return p.startswith(_SCHEME)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _drive_rel(p: str) -> str:
|
|
62
|
+
"""``drive:/reports/a.csv`` → ``reports/a.csv`` (drive-root-relative)."""
|
|
63
|
+
return p[len(_SCHEME):].lstrip("/")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@fs_app.command("ls")
|
|
67
|
+
def fs_ls(
|
|
68
|
+
path: Annotated[str, typer.Argument(help="Drive path to list (default: root)")] = "",
|
|
69
|
+
tree: Annotated[bool, typer.Option("--tree", "-R", help="Recursive listing")] = False,
|
|
70
|
+
as_json: JsonFlag = False,
|
|
71
|
+
):
|
|
72
|
+
"""List a directory on the shared drive. Output: ``d <name>`` / ``f <name>``."""
|
|
73
|
+
data = _api("GET", "/fs/tree", params=_params({"path": path} if path else None))
|
|
74
|
+
lines = [ln for ln in (data or {}).get("tree", "").split("\n") if ln]
|
|
75
|
+
if as_json:
|
|
76
|
+
_json_out({"path": path or "/", "tree": lines})
|
|
77
|
+
return
|
|
78
|
+
if tree:
|
|
79
|
+
if not lines:
|
|
80
|
+
empty("(empty)")
|
|
81
|
+
return
|
|
82
|
+
for ln in lines:
|
|
83
|
+
click.echo(ln)
|
|
84
|
+
return
|
|
85
|
+
# Collapse the relative subtree to immediate children.
|
|
86
|
+
children: dict[str, bool] = {}
|
|
87
|
+
for ln in lines:
|
|
88
|
+
first = ln.split("/", 1)[0].rstrip("/")
|
|
89
|
+
if not first:
|
|
90
|
+
continue
|
|
91
|
+
is_dir = ("/" in ln) or ln.endswith("/")
|
|
92
|
+
children[first] = children.get(first, False) or is_dir
|
|
93
|
+
if not children:
|
|
94
|
+
empty("(empty)")
|
|
95
|
+
return
|
|
96
|
+
for name in sorted(children):
|
|
97
|
+
click.echo(f"{'d' if children[name] else 'f'} {name}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@fs_app.command("cat")
|
|
101
|
+
def fs_cat(
|
|
102
|
+
path: Annotated[str, typer.Argument(help="Drive path to read")],
|
|
103
|
+
):
|
|
104
|
+
"""Print a file from the shared drive to stdout."""
|
|
105
|
+
body = _api_bytes("GET", "/fs/raw", params=_params({"path": path}))
|
|
106
|
+
sys.stdout.buffer.write(body)
|
|
107
|
+
sys.stdout.flush()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@fs_app.command("write")
|
|
111
|
+
def fs_write(
|
|
112
|
+
path: Annotated[str, typer.Argument(help="Drive path to write")],
|
|
113
|
+
content: Annotated[Optional[str], typer.Argument(help="Content (else --from or stdin)")] = None,
|
|
114
|
+
from_file: Annotated[Optional[str], typer.Option("--from", help="Read content from a local file")] = None,
|
|
115
|
+
as_json: JsonFlag = False,
|
|
116
|
+
):
|
|
117
|
+
"""Create or overwrite a whole file on the shared drive.
|
|
118
|
+
|
|
119
|
+
Content comes from the positional arg, else ``--from <local>``, else stdin.
|
|
120
|
+
"""
|
|
121
|
+
if from_file is not None:
|
|
122
|
+
try:
|
|
123
|
+
data = Path(from_file).read_text()
|
|
124
|
+
except OSError as e:
|
|
125
|
+
raise click.ClickException(f"--from: {e}")
|
|
126
|
+
elif content is not None:
|
|
127
|
+
data = content
|
|
128
|
+
else:
|
|
129
|
+
data = sys.stdin.read()
|
|
130
|
+
_api("POST", "/fs/edit", params=_params(), json={"path": path, "content": data})
|
|
131
|
+
if as_json:
|
|
132
|
+
_json_out({"ok": True, "path": path, "bytes": len(data.encode("utf-8"))})
|
|
133
|
+
return
|
|
134
|
+
ok(f"wrote {path} ({len(data.encode('utf-8'))} bytes)")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@fs_app.command("edit")
|
|
138
|
+
def fs_edit(
|
|
139
|
+
path: Annotated[str, typer.Argument(help="Drive path to edit")],
|
|
140
|
+
old: Annotated[str, typer.Option("--old", help="String to find")],
|
|
141
|
+
new: Annotated[str, typer.Option("--new", help="Replacement string")],
|
|
142
|
+
replace_all: Annotated[bool, typer.Option("--all", help="Replace every occurrence")] = False,
|
|
143
|
+
as_json: JsonFlag = False,
|
|
144
|
+
):
|
|
145
|
+
"""In-place find/replace in a file on the shared drive."""
|
|
146
|
+
_api("POST", "/fs/edit", params=_params(), json={
|
|
147
|
+
"path": path, "old_string": old, "new_string": new, "replace_all": replace_all,
|
|
148
|
+
})
|
|
149
|
+
if as_json:
|
|
150
|
+
_json_out({"ok": True, "path": path})
|
|
151
|
+
return
|
|
152
|
+
ok(f"edited {path}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@fs_app.command("mkdir")
|
|
156
|
+
def fs_mkdir(
|
|
157
|
+
path: Annotated[str, typer.Argument(help="Drive directory to create")],
|
|
158
|
+
as_json: JsonFlag = False,
|
|
159
|
+
):
|
|
160
|
+
"""Create a directory on the shared drive."""
|
|
161
|
+
_api("POST", "/fs/mkdir", params=_params(), json={"path": path})
|
|
162
|
+
if as_json:
|
|
163
|
+
_json_out({"ok": True, "path": path})
|
|
164
|
+
return
|
|
165
|
+
ok(f"created {path}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@fs_app.command("mv")
|
|
169
|
+
def fs_mv(
|
|
170
|
+
src: Annotated[str, typer.Argument(help="Drive path to move")],
|
|
171
|
+
dst: Annotated[str, typer.Argument(help="New drive path")],
|
|
172
|
+
as_json: JsonFlag = False,
|
|
173
|
+
):
|
|
174
|
+
"""Move/rename a file or directory on the shared drive."""
|
|
175
|
+
_api("POST", "/fs/rename", params=_params(), json={"path": src, "new_path": dst})
|
|
176
|
+
if as_json:
|
|
177
|
+
_json_out({"ok": True, "path": src, "new_path": dst})
|
|
178
|
+
return
|
|
179
|
+
ok(f"{src} → {dst}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@fs_app.command("rm")
|
|
183
|
+
def fs_rm(
|
|
184
|
+
path: Annotated[str, typer.Argument(help="Drive path to delete")],
|
|
185
|
+
as_json: JsonFlag = False,
|
|
186
|
+
):
|
|
187
|
+
"""Delete a file or directory on the shared drive."""
|
|
188
|
+
_api("POST", "/fs/delete", params=_params(), json={"path": path})
|
|
189
|
+
if as_json:
|
|
190
|
+
_json_out({"ok": True, "path": path})
|
|
191
|
+
return
|
|
192
|
+
ok(f"deleted {path}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@fs_app.command("cp")
|
|
196
|
+
def fs_cp(
|
|
197
|
+
src: Annotated[str, typer.Argument(help="Source (drive:/path or local path)")],
|
|
198
|
+
dst: Annotated[str, typer.Argument(help="Destination (drive:/path or local path)")],
|
|
199
|
+
as_json: JsonFlag = False,
|
|
200
|
+
):
|
|
201
|
+
"""Copy between the shared drive and the local sandbox.
|
|
202
|
+
|
|
203
|
+
Mark the drive side with the ``drive:`` scheme:
|
|
204
|
+
cp drive:/reports/a.csv ./a.csv (download)
|
|
205
|
+
cp ./a.csv drive:/reports/a.csv (upload)
|
|
206
|
+
"""
|
|
207
|
+
src_drive, dst_drive = _is_drive(src), _is_drive(dst)
|
|
208
|
+
if src_drive and dst_drive:
|
|
209
|
+
raise click.ClickException("drive-to-drive copy is not supported; one side must be local")
|
|
210
|
+
if not src_drive and not dst_drive:
|
|
211
|
+
raise click.ClickException("nothing to do: use plain 'cp' for local-to-local")
|
|
212
|
+
|
|
213
|
+
if src_drive:
|
|
214
|
+
# Download: drive → local sandbox.
|
|
215
|
+
remote = _drive_rel(src)
|
|
216
|
+
body = _api_bytes("GET", "/fs/raw", params=_params({"path": remote}))
|
|
217
|
+
target = Path(dst)
|
|
218
|
+
if dst.endswith("/") or target.is_dir():
|
|
219
|
+
target = target / remote.rsplit("/", 1)[-1]
|
|
220
|
+
if target.parent and not target.parent.exists():
|
|
221
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
target.write_bytes(body)
|
|
223
|
+
if as_json:
|
|
224
|
+
_json_out({"ok": True, "downloaded": str(target), "bytes": len(body)})
|
|
225
|
+
else:
|
|
226
|
+
ok(f"{src} → {target} ({len(body)} bytes)")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
# Upload: local sandbox → drive.
|
|
230
|
+
local = Path(src)
|
|
231
|
+
if not local.is_file():
|
|
232
|
+
raise click.ClickException(f"local file not found: {src}")
|
|
233
|
+
remote = _drive_rel(dst)
|
|
234
|
+
if remote == "" or remote.endswith("/"):
|
|
235
|
+
directory = remote.rstrip("/")
|
|
236
|
+
filename = local.name
|
|
237
|
+
else:
|
|
238
|
+
directory, _, filename = remote.rpartition("/")
|
|
239
|
+
if not filename:
|
|
240
|
+
raise click.ClickException(f"invalid drive destination: {dst}")
|
|
241
|
+
content = local.read_bytes()
|
|
242
|
+
_api_upload("/fs/upload", directory=directory, filename=filename, content=content, params=_params())
|
|
243
|
+
final = f"{directory + '/' if directory else ''}{filename}"
|
|
244
|
+
if as_json:
|
|
245
|
+
_json_out({"ok": True, "uploaded": final, "bytes": len(content)})
|
|
246
|
+
else:
|
|
247
|
+
ok(f"{src} → drive:/{final} ({len(content)} bytes)")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from typing import Annotated, Optional
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from hivespace.cli.formatting import ok, relative_time
|
|
7
|
+
from hivespace.cli.helpers import _api, _json_out, _resolve_team
|
|
8
|
+
from hivespace.cli.state import DeprecatedWorkspaceOpt, JsonFlag, TeamOpt, _set_team
|
|
9
|
+
|
|
10
|
+
inbox_app = typer.Typer(no_args_is_help=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _print_inbox(mentions: list[dict], unread_count: int) -> None:
|
|
14
|
+
if not mentions:
|
|
15
|
+
click.echo("No mentions.")
|
|
16
|
+
return
|
|
17
|
+
click.echo(f"{unread_count} unread")
|
|
18
|
+
for m in mentions:
|
|
19
|
+
author = (m.get("author") or {}).get("display") or "?"
|
|
20
|
+
ts = m.get("created_at", "")
|
|
21
|
+
text = m.get("text", "")
|
|
22
|
+
click.echo(f" [{relative_time(ts)}] @{author}: {text}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@inbox_app.callback()
|
|
26
|
+
def inbox_callback(team_opt: TeamOpt = None, workspace_opt: DeprecatedWorkspaceOpt = None):
|
|
27
|
+
"""Inbox — view and manage @-mentions."""
|
|
28
|
+
_set_team(team_opt)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@inbox_app.command("list")
|
|
32
|
+
def inbox_list(
|
|
33
|
+
status: Annotated[str, typer.Option("--status", "-s", help="unread, read, or all")] = "unread",
|
|
34
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max mentions")] = 50,
|
|
35
|
+
before: Annotated[Optional[str], typer.Option("--before", help="Cursor: ts to page back from")] = None,
|
|
36
|
+
as_json: JsonFlag = False,
|
|
37
|
+
team_opt: TeamOpt = None,
|
|
38
|
+
workspace_opt: DeprecatedWorkspaceOpt = None,
|
|
39
|
+
):
|
|
40
|
+
"""List @-mentions of the current agent."""
|
|
41
|
+
team = _resolve_team(team_opt)
|
|
42
|
+
params: dict = {"status": status, "limit": limit}
|
|
43
|
+
if before:
|
|
44
|
+
params["before"] = before
|
|
45
|
+
data = _api("GET", f"/teams/{team}/inbox", params=params)
|
|
46
|
+
if as_json:
|
|
47
|
+
_json_out(data)
|
|
48
|
+
return
|
|
49
|
+
_print_inbox(data.get("mentions", []), data.get("unread_count", 0))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@inbox_app.command("read")
|
|
53
|
+
def inbox_read(
|
|
54
|
+
ts: Annotated[str, typer.Argument(help="Mark mentions up to this ts as read")],
|
|
55
|
+
as_json: JsonFlag = False,
|
|
56
|
+
team_opt: TeamOpt = None,
|
|
57
|
+
workspace_opt: DeprecatedWorkspaceOpt = None,
|
|
58
|
+
):
|
|
59
|
+
"""Mark mentions as read up to a given timestamp."""
|
|
60
|
+
team = _resolve_team(team_opt)
|
|
61
|
+
data = _api("POST", f"/teams/{team}/inbox/read", json={"ts": ts})
|
|
62
|
+
if as_json:
|
|
63
|
+
_json_out(data)
|
|
64
|
+
else:
|
|
65
|
+
ok(f"Marked as read up to ts={ts}")
|