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.
Files changed (39) hide show
  1. webbee-0.1.3/.github/workflows/publish.yml +47 -0
  2. {webbee-0.1.2 → webbee-0.1.3}/CHANGELOG.md +8 -0
  3. {webbee-0.1.2 → webbee-0.1.3}/PKG-INFO +6 -6
  4. {webbee-0.1.2 → webbee-0.1.3}/README.md +5 -5
  5. {webbee-0.1.2 → webbee-0.1.3}/pyproject.toml +1 -1
  6. webbee-0.1.3/src/webbee/__init__.py +1 -0
  7. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/commands.py +11 -0
  8. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/render.py +25 -0
  9. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/repl.py +29 -1
  10. webbee-0.1.3/src/webbee/sessions.py +48 -0
  11. {webbee-0.1.2 → webbee-0.1.3}/tests/test_commands.py +10 -2
  12. {webbee-0.1.2 → webbee-0.1.3}/tests/test_render.py +20 -0
  13. {webbee-0.1.2 → webbee-0.1.3}/tests/test_repl.py +45 -1
  14. webbee-0.1.3/tests/test_sessions.py +51 -0
  15. webbee-0.1.2/.github/workflows/publish.yml +0 -28
  16. webbee-0.1.2/src/webbee/__init__.py +0 -1
  17. {webbee-0.1.2 → webbee-0.1.3}/.gitignore +0 -0
  18. {webbee-0.1.2 → webbee-0.1.3}/LICENSE +0 -0
  19. {webbee-0.1.2 → webbee-0.1.3}/install.sh +0 -0
  20. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/account.py +0 -0
  21. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/banner_art.py +0 -0
  22. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/cli.py +0 -0
  23. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/config.py +0 -0
  24. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/events.py +0 -0
  25. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/session.py +0 -0
  26. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/tools.py +0 -0
  27. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/tui.py +0 -0
  28. {webbee-0.1.2 → webbee-0.1.3}/src/webbee/update.py +0 -0
  29. {webbee-0.1.2 → webbee-0.1.3}/tests/__init__.py +0 -0
  30. {webbee-0.1.2 → webbee-0.1.3}/tests/test_account.py +0 -0
  31. {webbee-0.1.2 → webbee-0.1.3}/tests/test_cli.py +0 -0
  32. {webbee-0.1.2 → webbee-0.1.3}/tests/test_config.py +0 -0
  33. {webbee-0.1.2 → webbee-0.1.3}/tests/test_events.py +0 -0
  34. {webbee-0.1.2 → webbee-0.1.3}/tests/test_packaging.py +0 -0
  35. {webbee-0.1.2 → webbee-0.1.3}/tests/test_session.py +0 -0
  36. {webbee-0.1.2 → webbee-0.1.3}/tests/test_tools.py +0 -0
  37. {webbee-0.1.2 → webbee-0.1.3}/tests/test_tui.py +0 -0
  38. {webbee-0.1.2 → webbee-0.1.3}/tests/test_update.py +0 -0
  39. {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.2
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
- [![PyPI](https://img.shields.io/pypi/v/webbee.svg)](https://pypi.org/project/webbee/)
36
- [![Python](https://img.shields.io/pypi/pyversions/webbee.svg)](https://pypi.org/project/webbee/)
37
- [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
38
- [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7.svg)](https://docs.imperal.io)
35
+ [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/webbee)](https://pypi.org/project/webbee/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
38
+ [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](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 to your Imperal account (opens the browser)
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
- [![PyPI](https://img.shields.io/pypi/v/webbee.svg)](https://pypi.org/project/webbee/)
4
- [![Python](https://img.shields.io/pypi/pyversions/webbee.svg)](https://pypi.org/project/webbee/)
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
- [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7.svg)](https://docs.imperal.io)
3
+ [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/webbee)](https://pypi.org/project/webbee/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](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 to your Imperal account (opens the browser)
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`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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) -> 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