shelldeck 0.0.1__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.
Files changed (45) hide show
  1. shelldeck/__init__.py +0 -0
  2. shelldeck/__main__.py +4 -0
  3. shelldeck/addons/__init__.py +1 -0
  4. shelldeck/addons/fast_context.py +1346 -0
  5. shelldeck/auth.py +207 -0
  6. shelldeck/cli.py +587 -0
  7. shelldeck/db.py +794 -0
  8. shelldeck/gitgraph.py +150 -0
  9. shelldeck/integration/bash.sh +27 -0
  10. shelldeck/integration/shelldeck.fish +5 -0
  11. shelldeck/integration/shelldeck.ps1 +35 -0
  12. shelldeck/integration/zsh/.zshenv +6 -0
  13. shelldeck/integration/zsh/.zshrc +10 -0
  14. shelldeck/pty.py +352 -0
  15. shelldeck/runner.py +75 -0
  16. shelldeck/scheduler.py +283 -0
  17. shelldeck/server.py +1216 -0
  18. shelldeck/shells.py +174 -0
  19. shelldeck/static/app.css +2288 -0
  20. shelldeck/static/app.js +1863 -0
  21. shelldeck/static/gitgraph.js +142 -0
  22. shelldeck/static/history.js +172 -0
  23. shelldeck/static/icon-192.png +0 -0
  24. shelldeck/static/icon-32.png +0 -0
  25. shelldeck/static/icon-512.png +0 -0
  26. shelldeck/static/icon.svg +1 -0
  27. shelldeck/static/index.html +102 -0
  28. shelldeck/static/manifest.webmanifest +1 -0
  29. shelldeck/static/monitor.js +143 -0
  30. shelldeck/static/ui.js +316 -0
  31. shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
  32. shelldeck/static/vendor/addon-fit.js +2 -0
  33. shelldeck/static/vendor/addon-search.js +2 -0
  34. shelldeck/static/vendor/addon-serialize.js +2 -0
  35. shelldeck/static/vendor/addon-web-links.js +2 -0
  36. shelldeck/static/vendor/addon-webgl.js +2 -0
  37. shelldeck/static/vendor/xterm.css +218 -0
  38. shelldeck/static/vendor/xterm.js +2 -0
  39. shelldeck/static/views.js +658 -0
  40. shelldeck/stats.py +128 -0
  41. shelldeck-0.0.1.dist-info/METADATA +254 -0
  42. shelldeck-0.0.1.dist-info/RECORD +45 -0
  43. shelldeck-0.0.1.dist-info/WHEEL +4 -0
  44. shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
  45. shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
shelldeck/cli.py ADDED
@@ -0,0 +1,587 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import ssl
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ import webbrowser
11
+ from pathlib import Path
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ from . import auth, db
18
+
19
+ app = typer.Typer(
20
+ help="shelldeck: project-first multi-terminal. Run with no arguments to open the app.",
21
+ no_args_is_help=False,
22
+ add_completion=False,
23
+ )
24
+ schedule_app = typer.Typer(help="Scheduled jobs.")
25
+ task_app = typer.Typer(help="Personal task board.")
26
+ app.add_typer(schedule_app, name="schedule")
27
+ app.add_typer(task_app, name="task")
28
+
29
+ HOST = "127.0.0.1"
30
+ CFG = {"port": int(os.environ.get("SHELLDECK_PORT", "5455"))}
31
+ console = Console()
32
+
33
+
34
+ def _scheme() -> str:
35
+ """http, or https when the server on this port was started with --cert."""
36
+ try:
37
+ info = json.loads((db.config_dir() / "server.json").read_text(encoding="utf-8"))
38
+ return info["scheme"] if info.get("port") == CFG["port"] else "http"
39
+ except (OSError, ValueError, KeyError):
40
+ return "http"
41
+
42
+
43
+ def _url(path: str = "") -> str:
44
+ return f"{_scheme()}://{HOST}:{CFG['port']}{path}"
45
+
46
+
47
+ def _open_url(req, timeout: float):
48
+ # the CLI only talks to this machine; a self-signed certificate is expected there
49
+ ctx = ssl._create_unverified_context() if req.full_url.startswith("https:") else None
50
+ return urllib.request.urlopen(req, timeout=timeout, context=ctx)
51
+
52
+
53
+ def _api(path: str, method: str = "GET", payload: dict | None = None) -> dict:
54
+ """Call the local server. Exits with a readable message on failure."""
55
+ data = None if payload is None else json.dumps(payload).encode()
56
+ headers = {"Content-Type": "application/json", "X-Shelldeck-Token": auth.read_cli_token()}
57
+ req = urllib.request.Request(_url(path), data=data, method=method, headers=headers)
58
+ try:
59
+ with _open_url(req, 10) as r:
60
+ return json.loads(r.read().decode() or "{}")
61
+ except urllib.error.HTTPError as e:
62
+ try:
63
+ code = json.loads(e.read().decode()).get("error", e.reason)
64
+ except ValueError:
65
+ code = e.reason
66
+ if code in ("locked", "setup_required"):
67
+ code = "the CLI token was rejected; restart shelldeck (`sd stop`, then `sd`)"
68
+ typer.echo(f"error: {str(code).replace('_', ' ')}", err=True)
69
+ raise typer.Exit(1) from None
70
+ except OSError:
71
+ typer.echo(f"shelldeck is not running on port {CFG['port']} (start it with `shelldeck`)", err=True)
72
+ raise typer.Exit(1) from None
73
+
74
+
75
+ def _health() -> str | None:
76
+ """'ok' if shelldeck answers, 'other' if something else holds the port, None if free."""
77
+ try:
78
+ with _open_url(urllib.request.Request(_url("/api/health")), 1.5) as r:
79
+ return "ok" if json.loads(r.read().decode()).get("app") == "shelldeck" else "other"
80
+ except urllib.error.HTTPError:
81
+ return "other"
82
+ except (OSError, ValueError):
83
+ return None
84
+
85
+
86
+ def ensure_server() -> None:
87
+ state = _health()
88
+ if state == "ok":
89
+ return
90
+ if state == "other":
91
+ typer.echo(f"port {CFG['port']} is used by another program; try `shelldeck --port 5456`", err=True)
92
+ raise typer.Exit(1)
93
+ # a hidden console of its own, not DETACHED_PROCESS: venv python.exe is a launcher that starts the real
94
+ # python, and a console-less parent makes Windows open a new (visible) terminal window for that child
95
+ detach = (
96
+ {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
97
+ if sys.platform == "win32"
98
+ else {"start_new_session": True}
99
+ )
100
+ subprocess.Popen(
101
+ [sys.executable, "-m", "shelldeck", "--port", str(CFG["port"]), "serve"],
102
+ stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL,
105
+ close_fds=True,
106
+ **detach,
107
+ )
108
+ for _ in range(60):
109
+ time.sleep(0.25)
110
+ if _health() == "ok":
111
+ return
112
+ typer.echo(f"server did not start; see {db.config_dir() / 'shelldeck.log'}", err=True)
113
+ raise typer.Exit(1)
114
+
115
+
116
+ def _browser() -> str | None:
117
+ """Edge or Chrome, for a chromeless --app window."""
118
+ candidates = []
119
+ for env in ("PROGRAMFILES(X86)", "PROGRAMFILES", "LOCALAPPDATA"):
120
+ base = os.environ.get(env)
121
+ if base:
122
+ candidates += [
123
+ Path(base) / "Microsoft/Edge/Application/msedge.exe",
124
+ Path(base) / "Google/Chrome/Application/chrome.exe",
125
+ ]
126
+ found = next((str(p) for p in candidates if p.exists()), None)
127
+ names = ("msedge", "chrome", "microsoft-edge", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser")
128
+ return found or next((p for n in names if (p := shutil.which(n))), None)
129
+
130
+
131
+ def open_window(path: str = "/") -> None:
132
+ url = _url(path)
133
+ exe = _browser() if CFG.get("app") else None
134
+ if not exe:
135
+ webbrowser.open(url)
136
+ return
137
+ # default browser profile: a fresh --user-data-dir triggers Edge sign-in/sync prompts
138
+ subprocess.Popen(
139
+ [exe, f"--app={url}"],
140
+ stdin=subprocess.DEVNULL,
141
+ stdout=subprocess.DEVNULL,
142
+ stderr=subprocess.DEVNULL,
143
+ close_fds=True,
144
+ )
145
+
146
+
147
+ @app.callback(invoke_without_command=True)
148
+ def main(
149
+ ctx: typer.Context,
150
+ port: int = typer.Option(CFG["port"], "--port", "-p", envvar="SHELLDECK_PORT", help="Server port."),
151
+ no_window: bool = typer.Option(False, "--no-window", help="Print the URL instead of opening the browser."),
152
+ app_window: bool = typer.Option(False, "--app", help="Open a chromeless Edge/Chrome app window instead of a browser tab."),
153
+ ):
154
+ CFG["port"] = port
155
+ CFG["no_window"] = no_window
156
+ CFG["app"] = app_window
157
+ if ctx.invoked_subcommand is None:
158
+ web()
159
+
160
+
161
+ @app.command()
162
+ def web():
163
+ """Start the server if needed and open shelldeck in the browser (same as no arguments)."""
164
+ started = _health() is None
165
+ ensure_server()
166
+ _banner(_server_host(), "started" if started else "already running")
167
+ _show("/")
168
+ if _own_console():
169
+ try:
170
+ input(" Press Enter to close this window (shelldeck keeps running)")
171
+ except (EOFError, KeyboardInterrupt):
172
+ pass
173
+
174
+
175
+ def _own_console() -> bool:
176
+ """True when Windows opened a console just for us (Win+R, Start menu, a shortcut): it closes when we exit."""
177
+ if sys.platform != "win32" or not sys.stdin.isatty():
178
+ return False
179
+ import ctypes
180
+
181
+ import psutil
182
+
183
+ pids = (ctypes.c_uint * 64)()
184
+ attached = set(pids[: ctypes.windll.kernel32.GetConsoleProcessList(pids, 64)])
185
+ # climb our ancestors that share this console; the topmost one created it (a shell, or our own launcher)
186
+ top = psutil.Process()
187
+ while (parent := top.parent()) and parent.pid in attached:
188
+ top = parent
189
+ return top.name().lower() in ("sd.exe", "shelldeck.exe", "python.exe")
190
+
191
+
192
+ LOGO = (
193
+ r" _ _ _ _ _",
194
+ r" ___ | |__ ___ | || | __| | ___ ___ | | __",
195
+ r"/ __|| '_ \ / _ \| || | / _` | / _ \ / __|| |/ /",
196
+ r"\__ \| | | || __/| || || (_| || __/| (__ | <",
197
+ r"|___/|_| |_| \___||_||_| \__,_| \___| \___||_|\_\ ",
198
+ )
199
+ LOGO_COLORS = ("#ddd6fe", "#c4b5fd", "#a78bfa", "#8b5cf6", "#7c3aed") # violet accent, top to bottom
200
+
201
+
202
+ def _version() -> str:
203
+ from importlib.metadata import PackageNotFoundError, version
204
+
205
+ try:
206
+ return version("shelldeck")
207
+ except PackageNotFoundError:
208
+ return "dev"
209
+
210
+
211
+ def _server_host() -> str:
212
+ """Bind address of the running server, from server.json (the detached server is always loopback)."""
213
+ try:
214
+ info = json.loads((db.config_dir() / "server.json").read_text(encoding="utf-8"))
215
+ return info.get("host", HOST) if info.get("port") == CFG["port"] else HOST
216
+ except (OSError, ValueError):
217
+ return HOST
218
+
219
+
220
+ def _lan_ip() -> str | None:
221
+ """This machine's LAN address: the source address of the default route (a UDP connect sends nothing)."""
222
+ import socket
223
+
224
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
225
+ try:
226
+ s.connect(("10.255.255.255", 1))
227
+ return s.getsockname()[0]
228
+ except OSError:
229
+ return None
230
+
231
+
232
+ def _banner(host: str, status: str, scheme: str = "") -> None:
233
+ """Logo, version and URLs. rich drops the colors when the output isn't an ANSI terminal (or NO_COLOR is set)."""
234
+ console.print()
235
+ for line, color in zip(LOGO, LOGO_COLORS, strict=True):
236
+ console.print(f" {line}", style=f"bold {color}", highlight=False, markup=False)
237
+ console.print(f"\n [bold]v{_version()}[/] [dim]project-first multi-terminal - {status}[/]\n", highlight=False)
238
+ scheme, port = scheme or _scheme(), CFG["port"]
239
+ console.print(f" [green]>[/] [bold]Local:[/] [cyan]{scheme}://127.0.0.1:{port}[/]", highlight=False)
240
+ if host in LOOPBACK_HOSTS:
241
+ console.print(" [dim]> Network: off (share: sd serve --host 0.0.0.0 --cert C --key K)[/]", highlight=False)
242
+ else:
243
+ net = _lan_ip() if host in ("0.0.0.0", "::") else host
244
+ console.print(f" [green]>[/] [bold]Network:[/] [cyan]{scheme}://{net or host}:{port}[/]", highlight=False)
245
+ console.print()
246
+
247
+
248
+ def _show(path: str) -> None:
249
+ if CFG.get("no_window"):
250
+ typer.echo(_url(path))
251
+ else:
252
+ open_window(path)
253
+
254
+
255
+ LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
256
+
257
+
258
+ @app.command()
259
+ def serve(
260
+ host: str = typer.Option("127.0.0.1", "--host", help="Bind address. Keep 127.0.0.1 unless you know why."),
261
+ cert: Path = typer.Option(None, "--cert", exists=True, dir_okay=False, help="TLS certificate (PEM) for HTTPS."),
262
+ key: Path = typer.Option(None, "--key", exists=True, dir_okay=False, help="TLS private key (PEM)."),
263
+ insecure_http: bool = typer.Option(False, "--insecure-http", help="Allow plain HTTP on a non-local address (trusted network only)."),
264
+ ):
265
+ """Run the server in the foreground."""
266
+ from .server import run
267
+
268
+ if bool(cert) != bool(key):
269
+ typer.echo("error: --cert and --key go together", err=True)
270
+ raise typer.Exit(2)
271
+ if host not in LOOPBACK_HOSTS and not cert and not insecure_http:
272
+ typer.echo(
273
+ f"error: refusing to serve plain HTTP on {host}: passwords and terminal traffic would cross the network unencrypted.\n"
274
+ " Safest: keep the default 127.0.0.1 and use an SSH tunnel: ssh -L 5455:127.0.0.1:5455 you@vm\n"
275
+ " Or serve HTTPS: --cert cert.pem --key key.pem (or add --insecure-http on a trusted network)",
276
+ err=True,
277
+ )
278
+ raise typer.Exit(2)
279
+ db.init_db()
280
+ _banner(host, "foreground, Ctrl+C stops", "https" if cert else "http")
281
+ run(host=host, port=CFG["port"], certfile=str(cert) if cert else None, keyfile=str(key) if key else None)
282
+
283
+
284
+ @app.command("reset-password")
285
+ def reset_password(yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask for confirmation.")):
286
+ """EMERGENCY, on the host only: forget the password and sign out every browser."""
287
+ if not yes and not typer.confirm("Forget the shelldeck password and sign out every browser?"):
288
+ raise typer.Exit(1)
289
+ db.init_db()
290
+ code = auth.reset()
291
+ typer.echo("Password removed and every login ended. Open shelldeck to create a new password.")
292
+ typer.echo(f"From another machine, setup asks for this code: {code}")
293
+
294
+
295
+ @app.command("login-link")
296
+ def login_link():
297
+ """EMERGENCY, on the host only: print a one-time URL (5 minutes) that logs a browser in."""
298
+ ensure_server()
299
+ path = _api("/api/auth/login-link", "POST", {})["path"]
300
+ typer.echo(_url(path))
301
+ typer.echo("One use, valid for 5 minutes. Through an SSH tunnel, open it on your machine as-is.")
302
+
303
+
304
+ @app.command()
305
+ def stop():
306
+ """Stop the background server (closes all terminals)."""
307
+ if _health() != "ok":
308
+ typer.echo("shelldeck is not running")
309
+ return
310
+ _api("/api/shutdown", "POST", {})
311
+ typer.echo("stopped")
312
+
313
+
314
+ @app.command("open")
315
+ def open_(
316
+ path: str = typer.Argument(".", help="Project folder."),
317
+ shell: str = typer.Option("", "--shell", "-s", help="Shell kind, e.g. pwsh, cmd, wsl, bash, zsh (default from settings)."),
318
+ ):
319
+ """Add a folder as a project and open a terminal in it."""
320
+ folder = str(Path(path).resolve())
321
+ ensure_server()
322
+ project = _api("/api/projects", "POST", {"path": folder})
323
+ session = _api("/api/sessions", "POST", {"project_id": project["id"], "shell": shell})
324
+ _show(f"/?session={session['id']}")
325
+
326
+
327
+ @app.command("list")
328
+ def list_sessions():
329
+ """List terminals grouped by project."""
330
+ projects = _api("/api/projects")["projects"]
331
+ if not projects:
332
+ typer.echo("no projects")
333
+ return
334
+ table = Table(box=None)
335
+ for col in ("Project", "Terminal", "Shell", "ID", "Running"):
336
+ table.add_column(col)
337
+ for p in projects:
338
+ if not p["sessions"]:
339
+ table.add_row(p["name"], "-", "", "", "")
340
+ for s in p["sessions"]:
341
+ table.add_row(p["name"], s.get("name") or "", s.get("shell") or "default", s["id"], "yes" if s["alive"] else "no")
342
+ console.print(table)
343
+
344
+
345
+ @app.command()
346
+ def info():
347
+ """Show details about the shelldeck terminal you are in."""
348
+ sid = os.environ.get("SHELLDECK_SESSION_ID")
349
+ if not sid:
350
+ typer.echo("not inside a shelldeck terminal", err=True)
351
+ raise typer.Exit(1)
352
+ s = _api(f"/api/sessions/{sid}")["session"]
353
+ p = s.get("project") or {}
354
+ table = Table(show_header=False, box=None)
355
+ table.add_column(style="dim")
356
+ table.add_column()
357
+ for k, v in (
358
+ ("Session", s.get("id", sid)),
359
+ ("Name", s.get("name") or ""),
360
+ ("Project", p.get("name", "")),
361
+ ("Project path", p.get("path", "")),
362
+ ("Working dir", s.get("cwd", "")),
363
+ ("Shell", s.get("shell") or "default"),
364
+ ("Size", f"{s.get('cols', 0)}x{s.get('rows', 0)}"),
365
+ ("Created", s.get("created_at", "")),
366
+ ):
367
+ table.add_row(k, str(v))
368
+ console.print(table)
369
+
370
+
371
+ @app.command()
372
+ def render(
373
+ file: Path = typer.Argument(..., exists=True, dir_okay=False, help="File to render."),
374
+ theme: str = typer.Option("monokai", "--theme", help="Syntax theme."),
375
+ ):
376
+ """Pretty-print a code or markdown file."""
377
+ from rich.markdown import Markdown # ~100ms of imports, only needed here
378
+ from rich.syntax import Syntax
379
+
380
+ text = file.read_text(encoding="utf-8", errors="replace")
381
+ if file.suffix.lower() in (".md", ".markdown"):
382
+ console.print(Markdown(text))
383
+ else:
384
+ lexer = Syntax.guess_lexer(str(file), text)
385
+ console.print(Syntax(text, lexer, theme=theme, line_numbers=True))
386
+
387
+
388
+ @app.command()
389
+ def search(
390
+ query: str = typer.Argument(..., help='Search query, e.g. "lazy load" or "auth token validation"'),
391
+ root: Path = typer.Option(Path("."), "--root", "-r", file_okay=False, exists=True, help="Codebase root."),
392
+ top: int = typer.Option(6, "--top", "-t", help="Max files to return."),
393
+ context: int = typer.Option(3, "--context", "-c", help="Context lines around each hit."),
394
+ per_file: int = typer.Option(3, "--per-file", help="Max snippets per file."),
395
+ ext: str = typer.Option(None, "--ext", "-e", help="Only these extensions, e.g. py,ts"),
396
+ glob: str = typer.Option(None, "--glob", help='Only paths matching glob(s), e.g. "src/*.py"'),
397
+ tests: bool = typer.Option(False, "--tests", help="Don't down-rank test files."),
398
+ reindex: bool = typer.Option(False, "--reindex", help="Rebuild the index from scratch."),
399
+ fmt: str = typer.Option("text", "--format", "-f", help="text, json or paths."),
400
+ ):
401
+ """Search a codebase with fastcontext (LLM-free code search)."""
402
+ from .addons import fast_context as fc # heavy module, only needed here
403
+
404
+ if fmt not in ("text", "json", "paths"):
405
+ typer.echo("--format must be text, json or paths", err=True)
406
+ raise typer.Exit(1)
407
+ retriever = fc.Retriever(
408
+ str(root.resolve()),
409
+ reindex=reindex,
410
+ context=context,
411
+ per_file=per_file,
412
+ include_tests=tests,
413
+ exts=ext.split(",") if ext else None,
414
+ globs=glob.split(",") if glob else None,
415
+ )
416
+ out = retriever.run(query, top_k=top)
417
+ if fmt == "json":
418
+ print(fc.format_json(out))
419
+ elif fmt == "paths":
420
+ print(fc.format_paths(out))
421
+ else:
422
+ _render_search(out, fc)
423
+
424
+
425
+ def _render_search(out: dict, fc) -> None:
426
+ """fast_context's text format, with rich highlighting for the snippets."""
427
+ from rich.markup import escape
428
+ from rich.syntax import Syntax
429
+
430
+ conf = out["confidence"]
431
+ filled = int(conf["score"] / 10)
432
+ console.print(f"[bold cyan]Query:[/] {escape(out['query'])}")
433
+ console.print(f"[bold]Confidence:[/] {conf['score']:.0f}/100 \\[{'#' * filled}{'.' * (10 - filled)}] {conf['label']}"
434
+ f" [dim]({out['turns']} turn(s), {out['elapsed_ms']} ms)[/]", highlight=False)
435
+ for r in conf["reasons"]:
436
+ console.print(f" [dim]- {escape(r)}[/]")
437
+ if conf.get("suggestion"):
438
+ console.print(f" [yellow]tip:[/] {escape(conf['suggestion'])}")
439
+ if out["definitions"]:
440
+ console.print("\n[bold]Definitions:[/]")
441
+ for d in out["definitions"]:
442
+ console.print(f" [cyan]{escape(d['name'])}[/] ({d['kind']}) {escape(d['path'])}:{d['line']}")
443
+ if not out["results"]:
444
+ console.print("\n[yellow]No relevant code found.[/]")
445
+ for i, s in enumerate(out["results"], 1):
446
+ console.print(f"\n[bold]{i}.[/] [cyan]{escape(s.path)}[/] confidence {s.confidence:.0f} ({fc.label_of(s.confidence)})")
447
+ console.print(f" [dim]matched: {escape(', '.join(dict.fromkeys(s.present)))}[/]")
448
+ for sn in s.snippets:
449
+ sym = f" [{sn.symbol}]" if sn.symbol else ""
450
+ console.print(f" [dim]--- {escape(f'{s.path}:{sn.start}-{sn.end}{sym}')}[/]")
451
+ console.print(Syntax("\n".join(sn.text), fc.language_of(s.path) or "text", line_numbers=True,
452
+ start_line=sn.start, highlight_lines=set(sn.hit_lines)))
453
+
454
+
455
+ def _project_id(project: str) -> str:
456
+ folder = Path(project)
457
+ if folder.is_dir():
458
+ return _api("/api/projects", "POST", {"path": str(folder.resolve())})["id"]
459
+ return project # assume an id
460
+
461
+
462
+ # ------------------------------------------------------------------ schedule
463
+
464
+
465
+ @schedule_app.command("list")
466
+ def schedule_list():
467
+ table = Table(box=None)
468
+ for col in ("ID", "Name", "Cron", "Status", "Next run", "Runs"):
469
+ table.add_column(col)
470
+ for j in _api("/api/schedule/jobs")["jobs"]:
471
+ status = j["last_status"] if j["enabled"] else "disabled"
472
+ table.add_row(j["id"], j["name"], j["cron"], status, j.get("next_run_on") or "-", f"{j['successful_runs']}/{j['total_runs']}")
473
+ console.print(table)
474
+
475
+
476
+ @schedule_app.command("add")
477
+ def schedule_add(
478
+ name: str = typer.Argument(...),
479
+ command: str = typer.Argument(...),
480
+ project: str = typer.Option(".", "--project", help="Folder or project id."),
481
+ cron: str = typer.Option("0 * * * *", "--cron"),
482
+ timezone: str = typer.Option("UTC", "--timezone"),
483
+ timeout: int = typer.Option(60, "--timeout"),
484
+ enabled: bool = typer.Option(True, "--enabled/--disabled"),
485
+ ):
486
+ payload = {
487
+ "name": name,
488
+ "command": command,
489
+ "project_id": _project_id(project),
490
+ "cron": cron,
491
+ "timezone": timezone,
492
+ "timeout_seconds": timeout,
493
+ "enabled": enabled,
494
+ }
495
+ typer.echo(f"created {_api('/api/schedule/jobs', 'POST', payload)['id']}")
496
+
497
+
498
+ @schedule_app.command("run")
499
+ def schedule_run(job_id: str):
500
+ typer.echo(_api(f"/api/schedule/jobs/{job_id}/run", "POST", {})["status"])
501
+
502
+
503
+ @schedule_app.command("toggle")
504
+ def schedule_toggle(job_id: str):
505
+ typer.echo(f"enabled={_api(f'/api/schedule/jobs/{job_id}/toggle', 'POST', {})['enabled']}")
506
+
507
+
508
+ @schedule_app.command("delete")
509
+ def schedule_delete(job_id: str):
510
+ _api(f"/api/schedule/jobs/{job_id}", "DELETE")
511
+ typer.echo("deleted")
512
+
513
+
514
+ @schedule_app.command("logs")
515
+ def schedule_logs(job_id: str, limit: int = typer.Option(20, "--limit")):
516
+ for run in _api(f"/api/schedule/jobs/{job_id}/runs?limit={limit}")["runs"]:
517
+ console.rule(f"{run['run_at']} {run['status']} {run.get('duration_ms')}ms")
518
+ typer.echo(run.get("output") or run.get("error") or "")
519
+
520
+
521
+ # --------------------------------------------------------------------- tasks
522
+
523
+
524
+ @task_app.command("list")
525
+ def task_list(column: str = typer.Option(None, "--column")):
526
+ table = Table(box=None)
527
+ for col in ("ID", "Title", "Column", "Priority", "Due"):
528
+ table.add_column(col)
529
+ for t in _api("/api/tasks")["tasks"]:
530
+ if column and t["column"] != column:
531
+ continue
532
+ table.add_row(t["id"], t["title"], t["column"], t["priority"], t.get("due_at") or "-")
533
+ console.print(table)
534
+
535
+
536
+ @task_app.command("add")
537
+ def task_add(
538
+ title: str = typer.Argument(...),
539
+ column: str = typer.Option("backlog", "--column"),
540
+ priority: str = typer.Option("medium", "--priority"),
541
+ due: str = typer.Option(None, "--due", help="ISO date-time"),
542
+ reminder: str = typer.Option(None, "--reminder", help="ISO date-time"),
543
+ tags: str = typer.Option("", "--tags"),
544
+ ):
545
+ payload = {"title": title, "column": column, "priority": priority, "due_at": due, "reminder_at": reminder, "tags": tags}
546
+ typer.echo(f"created {_api('/api/tasks', 'POST', payload)['id']}")
547
+
548
+
549
+ @task_app.command("move")
550
+ def task_move(task_id: str, column: str):
551
+ typer.echo(f"moved to {_api(f'/api/tasks/{task_id}/move', 'POST', {'column': column})['column']}")
552
+
553
+
554
+ @task_app.command("delete")
555
+ def task_delete(task_id: str):
556
+ _api(f"/api/tasks/{task_id}", "DELETE")
557
+ typer.echo("deleted")
558
+
559
+
560
+ @task_app.command("alarms")
561
+ def task_alarms():
562
+ for a in _api("/api/tasks/alarms")["alarms"]:
563
+ typer.echo(f"{a['id']} {a['title']} due={a.get('due_at')}")
564
+
565
+
566
+ def _commands() -> set[str]:
567
+ """Registered command names (so the folder shorthand never shadows a new command)."""
568
+ names = {c.name or c.callback.__name__.replace("_", "-") for c in app.registered_commands}
569
+ return names | {g.name for g in app.registered_groups if g.name}
570
+
571
+
572
+ COMMANDS = _commands() # every command is registered by now
573
+
574
+
575
+ def _main() -> None:
576
+ # `shelldeck <folder>` is shorthand for `shelldeck open <folder>`
577
+ args = sys.argv[1:]
578
+ i = 0
579
+ while i < len(args) and args[i].startswith("-"):
580
+ i += 2 if args[i] in ("--port", "-p") else 1
581
+ if i < len(args) and args[i] not in COMMANDS:
582
+ sys.argv.insert(i + 1, "open")
583
+ app()
584
+
585
+
586
+ if __name__ == "__main__":
587
+ _main()