webbee 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- webbee-0.1.3/.github/workflows/publish.yml +47 -0
- {webbee-0.1.2 → webbee-0.1.3}/CHANGELOG.md +8 -0
- {webbee-0.1.2 → webbee-0.1.3}/PKG-INFO +6 -6
- {webbee-0.1.2 → webbee-0.1.3}/README.md +5 -5
- {webbee-0.1.2 → webbee-0.1.3}/pyproject.toml +1 -1
- webbee-0.1.3/src/webbee/__init__.py +1 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/commands.py +11 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/render.py +25 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/repl.py +29 -1
- webbee-0.1.3/src/webbee/sessions.py +48 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_commands.py +10 -2
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_render.py +20 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_repl.py +45 -1
- webbee-0.1.3/tests/test_sessions.py +51 -0
- webbee-0.1.2/.github/workflows/publish.yml +0 -28
- webbee-0.1.2/src/webbee/__init__.py +0 -1
- {webbee-0.1.2 → webbee-0.1.3}/.gitignore +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/LICENSE +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/install.sh +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/account.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/banner_art.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/cli.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/config.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/events.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/session.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/tools.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/tui.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/src/webbee/update.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/__init__.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_account.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_cli.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_config.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_events.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_packaging.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_session.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_tools.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_tui.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_update.py +0 -0
- {webbee-0.1.2 → webbee-0.1.3}/tests/test_version.py +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publish to PyPI on a version tag (e.g. `v0.1.0`), matching the Imperal
|
|
4
|
+
# convention (imperal-mcp). Auth uses the account API token stored as the
|
|
5
|
+
# GitHub Actions secret `PYPI_API_TOKEN` — add it to this repo, or set an
|
|
6
|
+
# `imperalcloud` org-level secret so every package inherits it. The token is
|
|
7
|
+
# NEVER stored in the repo/files; only in the encrypted Actions secret.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
tags: ["v*"]
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
publish:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
contents: write # create the GitHub Release for the tag
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
- name: Build sdist + wheel
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade build
|
|
26
|
+
python -m build
|
|
27
|
+
- name: Publish to PyPI
|
|
28
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
29
|
+
with:
|
|
30
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
31
|
+
# Mirror the PyPI release as a GitHub Release (so the repo's Releases
|
|
32
|
+
# sidebar tracks the latest version) with notes from CHANGELOG.md.
|
|
33
|
+
- name: Create GitHub Release
|
|
34
|
+
env:
|
|
35
|
+
GH_TOKEN: ${{ github.token }}
|
|
36
|
+
run: |
|
|
37
|
+
VERSION="${GITHUB_REF_NAME#v}"
|
|
38
|
+
python - "$VERSION" > notes.md <<'PY'
|
|
39
|
+
import sys, re, pathlib
|
|
40
|
+
v = sys.argv[1]
|
|
41
|
+
text = pathlib.Path("CHANGELOG.md").read_text()
|
|
42
|
+
m = re.search(rf"^## {re.escape(v)}\b.*?(?=^## |\Z)", text, re.S | re.M)
|
|
43
|
+
sys.stdout.write(m.group(0).strip() if m else
|
|
44
|
+
f"Published to PyPI: https://pypi.org/project/webbee/{v}/")
|
|
45
|
+
PY
|
|
46
|
+
gh release create "$GITHUB_REF_NAME" \
|
|
47
|
+
--title "webbee ${GITHUB_REF_NAME}" --latest --notes-file notes.md
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
- **Sessions & security.** `/sessions` lists everywhere your Imperal account is
|
|
6
|
+
signed in — terminal (webbee), API, and web — with the current terminal
|
|
7
|
+
marked. `/sessions revoke <#>` signs out one; `/logout-others` signs out
|
|
8
|
+
every session except this one. Manage the same sessions from the panel
|
|
9
|
+
(Settings → Security). Backed by a single gateway-owned session store.
|
|
10
|
+
|
|
3
11
|
## 0.1.2
|
|
4
12
|
|
|
5
13
|
- **Device-code login (RFC 8628).** `/login` and `webbee login` now use the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webbee
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
|
|
5
5
|
Project-URL: Homepage, https://imperal.io
|
|
6
6
|
Project-URL: Documentation, https://docs.imperal.io
|
|
@@ -32,10 +32,10 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
|
|
33
33
|
# Webbee 🐝 — the coding agent in your terminal
|
|
34
34
|
|
|
35
|
-
[](https://pypi.org/project/webbee/)
|
|
36
|
+
[](https://pypi.org/project/webbee/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://docs.imperal.io)
|
|
39
39
|
|
|
40
40
|
Webbee is the [Imperal Cloud](https://imperal.io) coding agent, in your terminal. It reads, writes, and runs code in your working directory — while the brain runs in the cloud on **ICNLI**, the open protocol behind Webbee. No model keys on your machine. Swap the model underneath and it behaves the same, because the safety was never in the model.
|
|
41
41
|
|
|
@@ -61,7 +61,7 @@ python3 -m venv .venv && . .venv/bin/activate && pip install webbee
|
|
|
61
61
|
|
|
62
62
|
```sh
|
|
63
63
|
webbee # start the agent in the current directory
|
|
64
|
-
webbee login # sign in
|
|
64
|
+
webbee login # sign in — shows a code + URL to open in any browser
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Type in plain English. Webbee reads your files, runs commands, and reaches your connected Imperal apps — mail, notes, tasks, and more — to get the job done. `/help` lists the commands: `/login` `/logout` `/mode` `/cost` `/status` `/clear` `/exit`.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Webbee 🐝 — the coding agent in your terminal
|
|
2
2
|
|
|
3
|
-
[](https://pypi.org/project/webbee/)
|
|
4
|
+
[](https://pypi.org/project/webbee/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://docs.imperal.io)
|
|
7
7
|
|
|
8
8
|
Webbee is the [Imperal Cloud](https://imperal.io) coding agent, in your terminal. It reads, writes, and runs code in your working directory — while the brain runs in the cloud on **ICNLI**, the open protocol behind Webbee. No model keys on your machine. Swap the model underneath and it behaves the same, because the safety was never in the model.
|
|
9
9
|
|
|
@@ -29,7 +29,7 @@ python3 -m venv .venv && . .venv/bin/activate && pip install webbee
|
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
31
|
webbee # start the agent in the current directory
|
|
32
|
-
webbee login # sign in
|
|
32
|
+
webbee login # sign in — shows a code + URL to open in any browser
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
Type in plain English. Webbee reads your files, runs commands, and reaches your connected Imperal apps — mail, notes, tasks, and more — to get the job done. `/help` lists the commands: `/login` `/logout` `/mode` `/cost` `/status` `/clear` `/exit`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -10,6 +10,9 @@ _HELP = """Commands:
|
|
|
10
10
|
/mode [default|plan|autopilot] consent mode (no arg — show current)
|
|
11
11
|
/cost (=/usage) tokens + $ cost this session
|
|
12
12
|
/status cwd · git · surface · tokens · version
|
|
13
|
+
/sessions list active sessions (this + other devices)
|
|
14
|
+
/sessions revoke <#> revoke a session by its number
|
|
15
|
+
/logout-others sign out every session except this one
|
|
13
16
|
/exit (=/quit) quit"""
|
|
14
17
|
|
|
15
18
|
|
|
@@ -32,6 +35,7 @@ class SlashResult:
|
|
|
32
35
|
message: str = ""
|
|
33
36
|
action: str = ""
|
|
34
37
|
new_mode: "str | None" = None
|
|
38
|
+
arg: str = ""
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
@@ -64,6 +68,13 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
|
64
68
|
f"cwd: {ctx.workspace} git: {ctx.git_branch}\n"
|
|
65
69
|
f"tokens: {ctx.session_tokens} (~${ctx.session_cost:.4f}) webbee v{ctx.version}")
|
|
66
70
|
return SlashResult(handled=True, action="status", message=msg)
|
|
71
|
+
if cmd == "/sessions":
|
|
72
|
+
if args and args[0].lower() == "revoke":
|
|
73
|
+
return SlashResult(handled=True, action="sessions_revoke",
|
|
74
|
+
arg=(args[1] if len(args) > 1 else ""))
|
|
75
|
+
return SlashResult(handled=True, action="sessions")
|
|
76
|
+
if cmd == "/logout-others":
|
|
77
|
+
return SlashResult(handled=True, action="logout_others")
|
|
67
78
|
if cmd == "/mode":
|
|
68
79
|
if not args:
|
|
69
80
|
return SlashResult(handled=True, action="mode", new_mode=None,
|
|
@@ -264,6 +264,31 @@ class RichSink:
|
|
|
264
264
|
self.console.print(Panel(body, title="🐝 Connect this terminal", border_style=_BEE))
|
|
265
265
|
self._nudge()
|
|
266
266
|
|
|
267
|
+
def sessions_table(self, sessions) -> None:
|
|
268
|
+
"""Active sessions as a compact table (English-only): #, session, IP,
|
|
269
|
+
last-seen, and a bee-yellow 'this device' marker for the current one."""
|
|
270
|
+
from rich.table import Table
|
|
271
|
+
self.console.print(Text(" Active sessions", style=f"bold {_BEE}"))
|
|
272
|
+
if not sessions:
|
|
273
|
+
self.console.print(Text(" (none)", style="dim"))
|
|
274
|
+
self._nudge()
|
|
275
|
+
return
|
|
276
|
+
t = Table(show_header=True, header_style="dim", box=None, padding=(0, 3, 0, 0))
|
|
277
|
+
t.add_column("#", style="dim", justify="right")
|
|
278
|
+
t.add_column("session")
|
|
279
|
+
t.add_column("ip", style="dim")
|
|
280
|
+
t.add_column("last seen", style="dim")
|
|
281
|
+
t.add_column("")
|
|
282
|
+
for i, s in enumerate(sessions, 1):
|
|
283
|
+
label = str(s.get("label") or s.get("surface") or "?")
|
|
284
|
+
ip = str(s.get("ip_address") or "-")
|
|
285
|
+
seen = str(s.get("last_seen_at") or "")[:16].replace("T", " ")
|
|
286
|
+
here = Text("this device", style=f"bold {_BEE}") if s.get("current") else Text("")
|
|
287
|
+
t.add_row(str(i), label, ip, seen, here)
|
|
288
|
+
self.console.print(t)
|
|
289
|
+
self.console.print(Text(" /sessions revoke <#> · /logout-others", style="dim"))
|
|
290
|
+
self._nudge()
|
|
291
|
+
|
|
267
292
|
def progress(self, text: str) -> None:
|
|
268
293
|
if text:
|
|
269
294
|
self.console.print(Text(" " + text, style="dim italic"))
|
|
@@ -19,7 +19,8 @@ def _git_branch(workspace: str) -> str:
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
async def run_repl(cfg, mode: str = "default", *, sink=None, read_line=input,
|
|
22
|
-
agent_factory=None, auth=None, account_fetcher=None
|
|
22
|
+
agent_factory=None, auth=None, account_fetcher=None,
|
|
23
|
+
sessions_client=None) -> None:
|
|
23
24
|
"""Interactive coding REPL. Production (a real tty, no injected sink) runs
|
|
24
25
|
the persistent prompt_toolkit dock (`tui.run_session`): the bordered input
|
|
25
26
|
box is pinned at the bottom, turn output scrolls above it (patch_stdout →
|
|
@@ -32,6 +33,8 @@ async def run_repl(cfg, mode: str = "default", *, sink=None, read_line=input,
|
|
|
32
33
|
agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m) # noqa: E731
|
|
33
34
|
if account_fetcher is None:
|
|
34
35
|
from webbee.account import fetch_account as account_fetcher
|
|
36
|
+
if sessions_client is None:
|
|
37
|
+
from webbee import sessions as sessions_client
|
|
35
38
|
|
|
36
39
|
workspace = os.getcwd()
|
|
37
40
|
|
|
@@ -85,6 +88,31 @@ async def run_repl(cfg, mode: str = "default", *, sink=None, read_line=input,
|
|
|
85
88
|
state["logged_in"] = False
|
|
86
89
|
_sink.note("Signed out, local credentials removed.")
|
|
87
90
|
return "continue"
|
|
91
|
+
if res.action == "sessions":
|
|
92
|
+
rows = await sessions_client.list_sessions(cfg, token_provider)
|
|
93
|
+
state["sessions"] = rows
|
|
94
|
+
_sink.sessions_table(rows)
|
|
95
|
+
return "continue"
|
|
96
|
+
if res.action == "sessions_revoke":
|
|
97
|
+
rows = state.get("sessions") or []
|
|
98
|
+
try:
|
|
99
|
+
idx = int(res.arg) - 1
|
|
100
|
+
except ValueError:
|
|
101
|
+
idx = -1
|
|
102
|
+
if idx < 0 or idx >= len(rows):
|
|
103
|
+
_sink.note("Usage: /sessions revoke <#> — run /sessions first to see the list.")
|
|
104
|
+
return "continue"
|
|
105
|
+
s = rows[idx]
|
|
106
|
+
if s.get("current"):
|
|
107
|
+
_sink.note("That's this terminal — use /logout to sign out here.")
|
|
108
|
+
return "continue"
|
|
109
|
+
ok = await sessions_client.revoke_session(cfg, token_provider, s["session_id"])
|
|
110
|
+
_sink.note(f"Revoked {s.get('label') or s.get('surface')}." if ok else "Failed to revoke session.")
|
|
111
|
+
return "continue"
|
|
112
|
+
if res.action == "logout_others":
|
|
113
|
+
n = await sessions_client.revoke_others(cfg, token_provider)
|
|
114
|
+
_sink.note(f"Signed out {n} other session(s)." if n >= 0 else "Failed to sign out other sessions.")
|
|
115
|
+
return "continue"
|
|
88
116
|
if res.action == "clear":
|
|
89
117
|
_sink.clear()
|
|
90
118
|
_sink.note(res.message)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Sessions client — list/revoke the user's Imperal Cloud sessions from the
|
|
2
|
+
terminal. Talks to the auth gateway with the user's access token (Bearer), so
|
|
3
|
+
the gateway marks THIS terminal as the current session via its `sid` claim.
|
|
4
|
+
Best-effort: network errors never raise — they return empty/False so the REPL
|
|
5
|
+
can note the failure instead of crashing."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _get(cfg, token_provider, path: str) -> dict:
|
|
10
|
+
import httpx
|
|
11
|
+
token = await token_provider()
|
|
12
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
13
|
+
r = await c.get(path, headers={"Authorization": f"Bearer {token}"})
|
|
14
|
+
r.raise_for_status()
|
|
15
|
+
return r.json()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _post(cfg, token_provider, path: str) -> dict:
|
|
19
|
+
import httpx
|
|
20
|
+
token = await token_provider()
|
|
21
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
22
|
+
r = await c.post(path, headers={"Authorization": f"Bearer {token}"})
|
|
23
|
+
r.raise_for_status()
|
|
24
|
+
return r.json() if r.content else {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def list_sessions(cfg, token_provider) -> list[dict]:
|
|
28
|
+
try:
|
|
29
|
+
return (await _get(cfg, token_provider, "/v1/auth/sessions")).get("sessions", [])
|
|
30
|
+
except Exception:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def revoke_session(cfg, token_provider, session_id: str) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
await _post(cfg, token_provider, f"/v1/auth/sessions/{session_id}/revoke")
|
|
37
|
+
return True
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def revoke_others(cfg, token_provider) -> int:
|
|
43
|
+
"""Revoke every session except this terminal's. Returns the count, or -1 on
|
|
44
|
+
error (the gateway keeps the current session, identified by the JWT sid)."""
|
|
45
|
+
try:
|
|
46
|
+
return int((await _post(cfg, token_provider, "/v1/auth/sessions/revoke-others")).get("revoked", 0))
|
|
47
|
+
except Exception:
|
|
48
|
+
return -1
|
|
@@ -26,13 +26,13 @@ def test_exit_and_quit():
|
|
|
26
26
|
def test_help_lists_commands():
|
|
27
27
|
r = dispatch("/help", _ctx())
|
|
28
28
|
assert r.handled and r.action == "help"
|
|
29
|
-
for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/exit"):
|
|
29
|
+
for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/sessions", "/logout-others", "/exit"):
|
|
30
30
|
assert c in r.message
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def test_help_is_english():
|
|
34
34
|
r = dispatch("/help", _ctx())
|
|
35
|
-
for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/exit"):
|
|
35
|
+
for c in ("/login", "/logout", "/clear", "/mode", "/cost", "/status", "/sessions", "/logout-others", "/exit"):
|
|
36
36
|
assert c in r.message
|
|
37
37
|
assert not NO_CYRILLIC.search(r.message)
|
|
38
38
|
|
|
@@ -98,6 +98,14 @@ def test_clear_login_logout_actions():
|
|
|
98
98
|
assert dispatch("/logout", _ctx()).action == "logout"
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def test_sessions_commands():
|
|
102
|
+
assert dispatch("/sessions", _ctx()).action == "sessions"
|
|
103
|
+
r = dispatch("/sessions revoke 2", _ctx())
|
|
104
|
+
assert r.action == "sessions_revoke" and r.arg == "2"
|
|
105
|
+
assert dispatch("/sessions revoke", _ctx()).action == "sessions_revoke" # no index -> arg ""
|
|
106
|
+
assert dispatch("/logout-others", _ctx()).action == "logout_others"
|
|
107
|
+
|
|
108
|
+
|
|
101
109
|
def test_unknown_slash_is_handled_with_hint():
|
|
102
110
|
r = dispatch("/frobnicate", _ctx())
|
|
103
111
|
assert r.handled and "/help" in r.message
|
|
@@ -36,6 +36,26 @@ def test_login_prompt_shows_code_and_url():
|
|
|
36
36
|
assert not NO_CYRILLIC.search(out) # English UI only
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def test_sessions_table_renders():
|
|
40
|
+
s = _sink()
|
|
41
|
+
s.sessions_table([
|
|
42
|
+
{"session_id": "s1", "surface": "cli", "label": "Terminal (webbee)",
|
|
43
|
+
"ip_address": "1.2.3.4", "last_seen_at": "2026-07-04T00:00:00", "current": True},
|
|
44
|
+
{"session_id": "s2", "surface": "web", "label": "Web (Chrome)",
|
|
45
|
+
"ip_address": None, "last_seen_at": "2026-07-03T10:00:00", "current": False},
|
|
46
|
+
])
|
|
47
|
+
out = s.console.export_text()
|
|
48
|
+
assert "Terminal (webbee)" in out and "Web (Chrome)" in out
|
|
49
|
+
assert "this device" in out and "1.2.3.4" in out
|
|
50
|
+
assert not NO_CYRILLIC.search(out)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_sessions_table_empty():
|
|
54
|
+
s = _sink()
|
|
55
|
+
s.sessions_table([])
|
|
56
|
+
assert "none" in s.console.export_text().lower()
|
|
57
|
+
|
|
58
|
+
|
|
39
59
|
def test_ask_consent_relays_raw_input():
|
|
40
60
|
console = Console(record=True, width=80)
|
|
41
61
|
s = RichSink(console=console, live_enabled=False, input_fn=lambda p: " ага давай ", clock=lambda: 0.0)
|
|
@@ -19,6 +19,7 @@ class FakeSink:
|
|
|
19
19
|
def abort(self): self.aborted = True
|
|
20
20
|
def welcome(self, *a, **kw): ...
|
|
21
21
|
def user_echo(self, text): self.echoed = getattr(self, "echoed", []) + [text]
|
|
22
|
+
def sessions_table(self, rows): self.session_tables = getattr(self, "session_tables", []) + [rows]
|
|
22
23
|
# TurnSink no-ops
|
|
23
24
|
def tool_start(self, *a): ...
|
|
24
25
|
def tool_result(self, *a): ...
|
|
@@ -50,6 +51,19 @@ class FakeAuth:
|
|
|
50
51
|
async def logout(self, cfg): self._in = False; self.logged_out = True
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
class FakeSessions:
|
|
55
|
+
def __init__(self, rows=None):
|
|
56
|
+
self.rows = rows if rows is not None else [
|
|
57
|
+
{"session_id": "s1", "surface": "cli", "label": "Terminal (webbee)", "current": True},
|
|
58
|
+
{"session_id": "s2", "surface": "web", "label": "Web (Chrome)", "current": False},
|
|
59
|
+
]
|
|
60
|
+
self.revoked = []
|
|
61
|
+
self.others_called = False
|
|
62
|
+
async def list_sessions(self, cfg, tp): return self.rows
|
|
63
|
+
async def revoke_session(self, cfg, tp, sid): self.revoked.append(sid); return True
|
|
64
|
+
async def revoke_others(self, cfg, tp): self.others_called = True; return 2
|
|
65
|
+
|
|
66
|
+
|
|
53
67
|
def _lines(*items):
|
|
54
68
|
it = iter(items)
|
|
55
69
|
def read(prompt=""):
|
|
@@ -71,7 +85,8 @@ def _run(**kw):
|
|
|
71
85
|
agent = kw.pop("agent", FakeAgent())
|
|
72
86
|
asyncio.run(run_repl(cfg, "default", sink=sink, agent_factory=lambda c, tp, ws, m: agent,
|
|
73
87
|
read_line=kw.pop("read_line"), auth=kw.pop("auth", FakeAuth()),
|
|
74
|
-
account_fetcher=kw.pop("account_fetcher", _fake_account_fetcher)
|
|
88
|
+
account_fetcher=kw.pop("account_fetcher", _fake_account_fetcher),
|
|
89
|
+
sessions_client=kw.pop("sessions_client", FakeSessions())))
|
|
75
90
|
return sink, agent
|
|
76
91
|
|
|
77
92
|
|
|
@@ -135,6 +150,35 @@ def test_login_uses_device_flow_and_renders_prompt():
|
|
|
135
150
|
assert any("Signed in as u@imperal.io" in n for n in sink.notes)
|
|
136
151
|
|
|
137
152
|
|
|
153
|
+
def test_sessions_list_renders():
|
|
154
|
+
fs = FakeSessions()
|
|
155
|
+
sink, agent = _run(read_line=_lines("/sessions", "/exit"), sessions_client=fs)
|
|
156
|
+
assert getattr(sink, "session_tables", None) # sessions_table was rendered
|
|
157
|
+
assert sink.session_tables[0] == fs.rows
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_sessions_revoke_by_index():
|
|
161
|
+
fs = FakeSessions()
|
|
162
|
+
sink, agent = _run(read_line=_lines("/sessions", "/sessions revoke 2", "/exit"), sessions_client=fs)
|
|
163
|
+
assert fs.revoked == ["s2"] # #2 = web (not current)
|
|
164
|
+
assert any("Revoked" in n for n in sink.notes)
|
|
165
|
+
assert not any(NO_CYRILLIC.search(n) for n in sink.notes)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_sessions_revoke_current_is_guarded():
|
|
169
|
+
fs = FakeSessions()
|
|
170
|
+
sink, agent = _run(read_line=_lines("/sessions", "/sessions revoke 1", "/exit"), sessions_client=fs)
|
|
171
|
+
assert fs.revoked == [] # #1 = current terminal -> guarded
|
|
172
|
+
assert any("this terminal" in n for n in sink.notes)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_logout_others_calls_client():
|
|
176
|
+
fs = FakeSessions()
|
|
177
|
+
sink, agent = _run(read_line=_lines("/logout-others", "/exit"), sessions_client=fs)
|
|
178
|
+
assert fs.others_called
|
|
179
|
+
assert any("other session" in n for n in sink.notes)
|
|
180
|
+
|
|
181
|
+
|
|
138
182
|
def test_mode_command_switches_agent_mode():
|
|
139
183
|
sink, agent = _run(read_line=_lines("/mode autopilot", "/exit"))
|
|
140
184
|
assert agent.mode == "autopilot"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import webbee.sessions as S
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_list_sessions_parses(monkeypatch):
|
|
7
|
+
async def fake_get(cfg, tp, path):
|
|
8
|
+
assert path == "/v1/auth/sessions"
|
|
9
|
+
return {"sessions": [{"session_id": "a", "surface": "cli", "current": True}]}
|
|
10
|
+
monkeypatch.setattr(S, "_get", fake_get)
|
|
11
|
+
out = asyncio.run(S.list_sessions(None, None))
|
|
12
|
+
assert out[0]["session_id"] == "a"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_list_sessions_network_error_returns_empty(monkeypatch):
|
|
16
|
+
async def boom(*a):
|
|
17
|
+
raise RuntimeError("net")
|
|
18
|
+
monkeypatch.setattr(S, "_get", boom)
|
|
19
|
+
assert asyncio.run(S.list_sessions(None, None)) == []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_revoke_session_posts_right_path(monkeypatch):
|
|
23
|
+
seen = {}
|
|
24
|
+
async def fake_post(cfg, tp, path):
|
|
25
|
+
seen["path"] = path
|
|
26
|
+
return {"status": "revoked"}
|
|
27
|
+
monkeypatch.setattr(S, "_post", fake_post)
|
|
28
|
+
assert asyncio.run(S.revoke_session(None, None, "sid1")) is True
|
|
29
|
+
assert seen["path"] == "/v1/auth/sessions/sid1/revoke"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_revoke_session_error_returns_false(monkeypatch):
|
|
33
|
+
async def boom(*a):
|
|
34
|
+
raise RuntimeError()
|
|
35
|
+
monkeypatch.setattr(S, "_post", boom)
|
|
36
|
+
assert asyncio.run(S.revoke_session(None, None, "x")) is False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_revoke_others_count(monkeypatch):
|
|
40
|
+
async def fake_post(cfg, tp, path):
|
|
41
|
+
assert path == "/v1/auth/sessions/revoke-others"
|
|
42
|
+
return {"revoked": 3}
|
|
43
|
+
monkeypatch.setattr(S, "_post", fake_post)
|
|
44
|
+
assert asyncio.run(S.revoke_others(None, None)) == 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_revoke_others_error_returns_minus_one(monkeypatch):
|
|
48
|
+
async def boom(*a):
|
|
49
|
+
raise RuntimeError()
|
|
50
|
+
monkeypatch.setattr(S, "_post", boom)
|
|
51
|
+
assert asyncio.run(S.revoke_others(None, None)) == -1
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
name: publish
|
|
2
|
-
|
|
3
|
-
# Publish to PyPI on a version tag (e.g. `v0.1.0`), matching the Imperal
|
|
4
|
-
# convention (imperal-mcp). Auth uses the account API token stored as the
|
|
5
|
-
# GitHub Actions secret `PYPI_API_TOKEN` — add it to this repo, or set an
|
|
6
|
-
# `imperalcloud` org-level secret so every package inherits it. The token is
|
|
7
|
-
# NEVER stored in the repo/files; only in the encrypted Actions secret.
|
|
8
|
-
|
|
9
|
-
on:
|
|
10
|
-
push:
|
|
11
|
-
tags: ["v*"]
|
|
12
|
-
|
|
13
|
-
jobs:
|
|
14
|
-
publish:
|
|
15
|
-
runs-on: ubuntu-latest
|
|
16
|
-
steps:
|
|
17
|
-
- uses: actions/checkout@v4
|
|
18
|
-
- uses: actions/setup-python@v5
|
|
19
|
-
with:
|
|
20
|
-
python-version: "3.12"
|
|
21
|
-
- name: Build sdist + wheel
|
|
22
|
-
run: |
|
|
23
|
-
python -m pip install --upgrade build
|
|
24
|
-
python -m build
|
|
25
|
-
- name: Publish to PyPI
|
|
26
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
27
|
-
with:
|
|
28
|
-
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|