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 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."
@@ -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", []))
@@ -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}")