agentstreamdeck 2.1.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.
- agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
- agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
- agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
- agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
- agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
- agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
- ocdeck/__init__.py +1 -0
- ocdeck/__main__.py +307 -0
- ocdeck/alerts.py +120 -0
- ocdeck/appearance.py +133 -0
- ocdeck/appearance_io.py +70 -0
- ocdeck/art.py +181 -0
- ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
- ocdeck/assets/logos/claude.png +0 -0
- ocdeck/assets/logos/copilot.png +0 -0
- ocdeck/assets/logos/copilot.svg +1 -0
- ocdeck/assets/logos/cursor.png +0 -0
- ocdeck/assets/logos/gemini.png +0 -0
- ocdeck/assets/logos/opencode.png +0 -0
- ocdeck/assets/logos/sources.json +27 -0
- ocdeck/broker.py +273 -0
- ocdeck/common.py +82 -0
- ocdeck/device.py +247 -0
- ocdeck/diagnostics.py +227 -0
- ocdeck/errors.py +22 -0
- ocdeck/focus.py +156 -0
- ocdeck/hardware_check.py +66 -0
- ocdeck/harness.py +203 -0
- ocdeck/launcher.py +203 -0
- ocdeck/model.py +177 -0
- ocdeck/observability.py +57 -0
- ocdeck/runtime/plugins/core.mjs +122 -0
- ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
- ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
- ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
- ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
- ocdeck/runtime/plugins/server.mjs +67 -0
- ocdeck/runtime/plugins/tui.mjs +44 -0
- ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
- ocdeck/runtime/scripts/Install.ps1 +79 -0
- ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
- ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
- ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
- ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
- ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
- ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
- ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
- ocdeck/runtime/scripts/Test.ps1 +11 -0
- ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
- ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
- ocdeck/runtime/scripts/check-js.py +8 -0
- ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
- ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
- ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
- ocdeck/runtime/scripts/render-gallery.py +94 -0
- ocdeck/security.py +38 -0
- ocdeck/settings.py +42 -0
- ocdeck/uninstall.py +100 -0
- ocdeck/updates.py +46 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
setlocal
|
|
3
|
+
REM Uses providers in your normal OpenCode config; pass --model provider/model.
|
|
4
|
+
set "OPENCODE_CONFIG="
|
|
5
|
+
set "OPENCODE_CONFIG_CONTENT="
|
|
6
|
+
set "OPENCODE_CONFIG_DIR="
|
|
7
|
+
call "%~dp0..\Launch-Agent.bat" --profile opencode -- %*
|
|
8
|
+
exit /b %ERRORLEVEL%
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Regenerate README examples from the actual device renderer (no hardware needed)."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import sys
|
|
4
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
7
|
+
from ocdeck.art import frame
|
|
8
|
+
from ocdeck.appearance import Appearance, THEMES, animation_phase
|
|
9
|
+
|
|
10
|
+
OUT = Path(__file__).resolve().parents[1] / 'docs' / 'visuals'
|
|
11
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
BG = '#101620'
|
|
13
|
+
FONT = ImageFont.load_default(size=16)
|
|
14
|
+
SMALL = ImageFont.load_default(size=12)
|
|
15
|
+
BASE = Appearance(layout='harness', theme='aurora', show_slot=False)
|
|
16
|
+
STATES = ['running','idle','input','unknown','ready','off']
|
|
17
|
+
HARNESSES = ['opencode','claude','copilot','copilot-vscode','gemini','cursor']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def panel(title, rows, phase=24):
|
|
21
|
+
"""Each row: name and (state, style, harness, label, caption) examples."""
|
|
22
|
+
im = Image.new('RGB', (840, 60 + len(rows)*150), BG)
|
|
23
|
+
d = ImageDraw.Draw(im)
|
|
24
|
+
d.text((22,18), title, font=FONT, fill='white')
|
|
25
|
+
for r, (name, examples) in enumerate(rows):
|
|
26
|
+
y = 60 + r*150
|
|
27
|
+
d.text((22,y+44), name, font=SMALL, fill='#a9b8cb')
|
|
28
|
+
for k, (state, style, harness, label, caption) in enumerate(examples):
|
|
29
|
+
x = 140 + k*114
|
|
30
|
+
im.paste(frame(state,label,k,phase,100,style,harness,'Review changes'),(x,y))
|
|
31
|
+
d.text((x+50,y+113),caption,font=SMALL,fill='#c6d2e3',anchor='mt')
|
|
32
|
+
return im
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save(name, title, rows):
|
|
36
|
+
panel(title,rows).save(OUT / (name+'.png'), optimize=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def row(style, states=STATES):
|
|
40
|
+
return [(state,style,HARNESSES[k],'AgentStreamDeck',state.upper()) for k,state in enumerate(states)]
|
|
41
|
+
|
|
42
|
+
save('layouts','THREE LAYOUTS / the same six states',[(name.title(),row(replace(BASE,layout=name))) for name in ['classic','harness','minimal']])
|
|
43
|
+
save('themes','FIVE PALETTES / consistent state labels',[(name.title(),row(replace(BASE,theme=name))) for name in THEMES])
|
|
44
|
+
save('harnesses','SIX ADAPTERS / one visual language',[('Harness',[( 'running',replace(BASE,primary='harness',secondary='status'),h,'AgentStreamDeck',n) for h,n in zip(HARNESSES,['OpenCode','Claude','Copilot CLI','Copilot VS Code','Gemini','Cursor'])])])
|
|
45
|
+
text_options=[('status','project','State + project'),('project','status','Project + state'),('harness','status','Harness + state'),('detail','project','Detail + project'),('custom','status','Custom + state'),('none','none','Icon only')]
|
|
46
|
+
save('labels','CHOOSE WHAT GOES UNDER THE ICON',[('Text lines', [('running',replace(BASE,primary=p,secondary=s,custom_text='Code review'),'claude','HomeAILab',caption) for p,s,caption in text_options])])
|
|
47
|
+
save('brightness','PER-BUTTON DIMMING / rendered pixels, fixed device backlight',[('Brightness',[('running',replace(BASE,brightness=b),'gemini','AgentStreamDeck',f'{int(b*100)}%') for b in [.15,.3,.45,.6,.8,1]])])
|
|
48
|
+
styles=[replace(BASE,theme='classic',primary='project',secondary='status'),replace(BASE,theme='ocean',primary='custom',custom_text='Code review'),replace(BASE,layout='minimal',theme='accessible'),replace(BASE,theme='aurora',primary='harness'),replace(BASE,theme='mono',effect='steady'),replace(BASE,theme='ocean',brightness=.6)]
|
|
49
|
+
save('mixed','MIX AND MATCH / six independent button styles',[('Per key',[(s,a,h,'HomeAILab',f'KEY {k+1}') for k,(s,a,h) in enumerate(zip(['running','input','idle','running','unknown','idle'],styles,HARNESSES))])])
|
|
50
|
+
# Compact, fixed-palette animated strips keep the README lightweight.
|
|
51
|
+
for name, title, specs in [
|
|
52
|
+
('effects','ANIMATION EFFECTS / same state, different motion', [(e,replace(BASE,layout='classic',effect=e,intensity=1),'input') for e in ['breathe','glow','steady']]),
|
|
53
|
+
('speeds','PULSE SPEED / slow, standard, fast', [(f'{v}x',replace(BASE,layout='classic',speed=v,intensity=1),'running') for v in [.5,1,2]])]:
|
|
54
|
+
frames=[]
|
|
55
|
+
for tick in range(96):
|
|
56
|
+
im=Image.new('RGB',(510,210),BG); d=ImageDraw.Draw(im)
|
|
57
|
+
d.text((18,15),title,font=SMALL,fill='white')
|
|
58
|
+
for k,(caption,a,state) in enumerate(specs):
|
|
59
|
+
im.paste(frame(state,'AgentStreamDeck',k,animation_phase(tick/24,a),120,a,'claude'),(25+k*165,52))
|
|
60
|
+
d.text((85+k*165,185),caption,font=SMALL,fill='white',anchor='mt')
|
|
61
|
+
frames.append(im)
|
|
62
|
+
palette = frames[0].quantize(colors=64)
|
|
63
|
+
frames = [im.quantize(palette=palette,dither=Image.Dither.NONE) for im in frames]
|
|
64
|
+
frames[0].save(OUT/(name+'.gif'),save_all=True,append_images=frames[1:],duration=[40,40,40,40,40,50]*16,loop=0,optimize=True)
|
|
65
|
+
print('\n'.join(f'{p.name}: {p.stat().st_size:,} bytes' for p in sorted(OUT.iterdir())))
|
|
66
|
+
|
|
67
|
+
# Extended customization examples, also drawn by the shipped renderer.
|
|
68
|
+
from ocdeck.appearance import PRESETS
|
|
69
|
+
save('presets','ONE-COMMAND PRESETS / real harness marks', [('Presets', [
|
|
70
|
+
('running',Appearance(**{**options,'alias':'My agent'}),h,'AgentStreamDeck',name.title())
|
|
71
|
+
for (name,options),h in zip(PRESETS.items(),HARNESSES)])])
|
|
72
|
+
save('details','SMALL DETAILS / make each key recognizable', [
|
|
73
|
+
('Badges', [('input',replace(BASE,badge=b),h,'AgentStreamDeck',b.title()) for b,h in zip(['dot','ring','pill'],HARNESSES)]),
|
|
74
|
+
('Borders', [('running',replace(BASE,border=b),'gemini','AgentStreamDeck',b.title()) for b in ['solid','double','corners','none']]),
|
|
75
|
+
('Backgrounds', [('idle',replace(BASE,background=b),'claude','AgentStreamDeck',b.title()) for b in ['solid','gradient','grid']]),
|
|
76
|
+
('Logo size', [('running',replace(BASE,logo_size=b),'cursor','AgentStreamDeck',b.title()) for b in ['small','normal','large']]),
|
|
77
|
+
])
|
|
78
|
+
save('typography','ALIASES AND TYPOGRAPHY / labels that work for you', [
|
|
79
|
+
('Alias', [('running',replace(BASE,alias=a,primary='alias',secondary='status'),'claude','AgentStreamDeck',caption) for a,caption in [('Reviewer','Review agent'),('Builder','Build agent'),('Docs','Writing agent')]]),
|
|
80
|
+
('Text size', [('running',replace(BASE,text_size=b,alias='Builder'),'copilot','AgentStreamDeck',b.title()) for b in ['small','normal','large']]),
|
|
81
|
+
('Alignment', [('idle',replace(BASE,text_align=b,alias='Docs'),'opencode','AgentStreamDeck',b.title()) for b in ['left','center','right']]),
|
|
82
|
+
])
|
|
83
|
+
frames=[]
|
|
84
|
+
for tick in range(96):
|
|
85
|
+
im=Image.new('RGB',(510,210),BG);d=ImageDraw.Draw(im)
|
|
86
|
+
d.text((18,15),'TEXT EFFECTS / clipped scrolling and shimmer',font=SMALL,fill='white')
|
|
87
|
+
for k,effect in enumerate(['none','scroll','shimmer']):
|
|
88
|
+
a=replace(BASE,alias='Backend review agent',primary='alias',secondary='status',text_effect=effect,intensity=0)
|
|
89
|
+
im.paste(frame('running','AgentStreamDeck',k,tick,120,a,'claude'),(25+k*165,52))
|
|
90
|
+
d.text((85+k*165,185),effect.upper(),font=SMALL,fill='white',anchor='mt')
|
|
91
|
+
frames.append(im)
|
|
92
|
+
palette=frames[0].quantize(colors=96)
|
|
93
|
+
frames=[im.quantize(palette=palette,dither=Image.Dither.NONE) for im in frames]
|
|
94
|
+
frames[0].save(OUT/'text-effects.gif',save_all=True,append_images=frames[1:],duration=[80,80,80,80,80,100]*16,loop=0,optimize=True)
|
ocdeck/security.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Defense in depth for metadata, diagnostics and logs; never collect prompt bodies."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
REDACTED = "[REDACTED]"
|
|
7
|
+
KEY = re.compile(r"token|secret|password|passwd|credential|authorization|api.?key|cookie|private.?key", re.I)
|
|
8
|
+
PATTERNS = [
|
|
9
|
+
re.compile(
|
|
10
|
+
r"""(?i)(?:[\w.-]*(?:token|secret|password|passwd|credential|api[_-]?key)[\w.-]*)[\s"']*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*')"""
|
|
11
|
+
),
|
|
12
|
+
re.compile(r"-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----"),
|
|
13
|
+
re.compile(r"\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.I),
|
|
14
|
+
re.compile(
|
|
15
|
+
r"\b(?:sk-[\w-]{8,}|gh[pousr]_[\w]{8,}|github_pat_[\w]{8,}|AKIA[A-Z0-9]{16}|eyJ[\w-]+\.[\w-]+\.[\w-]+)\b"
|
|
16
|
+
),
|
|
17
|
+
re.compile(
|
|
18
|
+
r"(?i)(?:[\w.-]*(?:token|secret|password|passwd|credential|api[_-]?key)[\w.-]*)[\s\"\']*[:=][\s\"\']*[^\s,;\"\'&}]+"
|
|
19
|
+
),
|
|
20
|
+
re.compile(r"https?://[^\s/@:]+:[^\s/@]+@"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def scrub_text(value: str, secrets: tuple[str, ...] = ()) -> str:
|
|
25
|
+
for secret in secrets:
|
|
26
|
+
if secret:
|
|
27
|
+
value = value.replace(secret, REDACTED)
|
|
28
|
+
for pattern in PATTERNS:
|
|
29
|
+
value = pattern.sub(REDACTED, value)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def scrub(value: Any, secrets: tuple[str, ...] = ()) -> Any:
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
return {str(k): REDACTED if KEY.search(str(k)) else scrub(v, secrets) for k, v in value.items()}
|
|
36
|
+
if isinstance(value, (list, tuple)):
|
|
37
|
+
return [scrub(v, secrets) for v in value]
|
|
38
|
+
return scrub_text(value, secrets) if isinstance(value, str) else value
|
ocdeck/settings.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Validate optional broker features before starting background workers."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from .appearance_io import export_settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_config(config):
|
|
8
|
+
if not isinstance(config, dict):
|
|
9
|
+
raise ValueError("config.json must contain an object")
|
|
10
|
+
export_settings(config)
|
|
11
|
+
if type(config.get("slots", 6)) is not int or config.get("slots", 6) not in (6, 15, 32):
|
|
12
|
+
raise ValueError("slots must be 6, 15 or 32 (mock capacity; physical deck auto-detects)")
|
|
13
|
+
for name in ("check_updates", "allow_elgato", "animations", "ready"):
|
|
14
|
+
if name in config and type(config[name]) is not bool:
|
|
15
|
+
raise ValueError(name + " must be boolean")
|
|
16
|
+
if type(config.get("brightness", 45)) is not int or not 0 <= config.get("brightness", 45) <= 100:
|
|
17
|
+
raise ValueError("brightness must be 0..100")
|
|
18
|
+
alerts = config.get("alerts", {})
|
|
19
|
+
if not isinstance(alerts, dict) or set(alerts) - {
|
|
20
|
+
"sound",
|
|
21
|
+
"toast",
|
|
22
|
+
"sound_file",
|
|
23
|
+
"states",
|
|
24
|
+
"cooldown_seconds",
|
|
25
|
+
"muted_slots",
|
|
26
|
+
}:
|
|
27
|
+
raise ValueError("Unknown or invalid alerts configuration")
|
|
28
|
+
for name in ("sound", "toast"):
|
|
29
|
+
if type(alerts.get(name, False)) is not bool:
|
|
30
|
+
raise ValueError("alerts." + name + " must be boolean")
|
|
31
|
+
states = alerts.get("states", ["input"])
|
|
32
|
+
if not isinstance(states, list) or any(x not in ("input", "running", "idle", "unknown") for x in states):
|
|
33
|
+
raise ValueError("alerts.states must contain input, running, idle or unknown")
|
|
34
|
+
cooldown = alerts.get("cooldown_seconds", 10)
|
|
35
|
+
if type(cooldown) not in (int, float) or not math.isfinite(cooldown) or not 0 <= cooldown <= 3600:
|
|
36
|
+
raise ValueError("alerts.cooldown_seconds must be 0..3600")
|
|
37
|
+
muted = alerts.get("muted_slots", [])
|
|
38
|
+
if not isinstance(muted, list) or any(x not in [str(i) for i in range(1, 33)] for x in muted):
|
|
39
|
+
raise ValueError("alerts.muted_slots must be strings 1..32")
|
|
40
|
+
if "sound_file" in alerts and not isinstance(alerts["sound_file"], str):
|
|
41
|
+
raise ValueError("alerts.sound_file must be a WAV file path")
|
|
42
|
+
return config
|
ocdeck/uninstall.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Receipt-scoped removal; never delete a project or an environment wholesale."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from .common import home, read_json, request
|
|
10
|
+
from .harness import install, PROFILES, SOURCE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover(roots):
|
|
14
|
+
found = set()
|
|
15
|
+
for root in roots:
|
|
16
|
+
root = Path(root).expanduser().resolve()
|
|
17
|
+
if not root.is_dir():
|
|
18
|
+
continue
|
|
19
|
+
for directory, children, files in os.walk(root, followlinks=False):
|
|
20
|
+
children[:] = [
|
|
21
|
+
c
|
|
22
|
+
for c in children
|
|
23
|
+
if c not in (".git", "node_modules", ".venv", "venv") and not (Path(directory) / c).is_symlink()
|
|
24
|
+
]
|
|
25
|
+
if Path(directory).name == ".agentdeck":
|
|
26
|
+
if any(f == p + ".json" for p in PROFILES for f in files):
|
|
27
|
+
found.add(str(Path(directory).parent))
|
|
28
|
+
children[:] = []
|
|
29
|
+
return sorted(found)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def uninstall(scan=(), dry_run=False):
|
|
33
|
+
root = home().resolve()
|
|
34
|
+
registered = read_json(root / "projects.json", []) or []
|
|
35
|
+
projects = sorted(set([*registered, *discover(scan or [Path.cwd()])]))
|
|
36
|
+
plans = []
|
|
37
|
+
for project in projects:
|
|
38
|
+
for profile in PROFILES:
|
|
39
|
+
if (Path(project) / ".agentdeck" / (profile + ".json")).exists():
|
|
40
|
+
plans.append((profile, project))
|
|
41
|
+
print(
|
|
42
|
+
json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"projects": plans,
|
|
45
|
+
"local": ["scheduled task", "managed OpenCode plugin", "managed shims", "configuration"],
|
|
46
|
+
"backups": "Retained under " + str(root / "backups"),
|
|
47
|
+
"dryRun": dry_run,
|
|
48
|
+
},
|
|
49
|
+
indent=2,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
# Preflight every receipt before changing anything. The native installer refuses
|
|
53
|
+
# moved targets, malformed JSON and shell metacharacters in installed paths.
|
|
54
|
+
for profile, project in plans:
|
|
55
|
+
if install(profile, project, remove=True, dry_run=True):
|
|
56
|
+
raise RuntimeError("Hook removal preflight failed; no uninstall changes applied")
|
|
57
|
+
if dry_run:
|
|
58
|
+
return 0
|
|
59
|
+
try:
|
|
60
|
+
status = request("GET", "/v1/status")
|
|
61
|
+
except Exception:
|
|
62
|
+
status = {}
|
|
63
|
+
if any(v.get("id") for v in status.get("slots", [])) or status.get("overflow"):
|
|
64
|
+
raise RuntimeError("Close managed agent sessions before uninstalling; run ocdeck status")
|
|
65
|
+
for profile, project in plans:
|
|
66
|
+
if install(profile, project, remove=True):
|
|
67
|
+
raise RuntimeError("Project hook removal failed; local installation retained")
|
|
68
|
+
metadata = read_json(root / "install.json", {}) or {}
|
|
69
|
+
if os.name == "nt":
|
|
70
|
+
script = SOURCE / "scripts" / "Remove-Integration.ps1"
|
|
71
|
+
subprocess.run(["powershell.exe", "-NoProfile", "-File", str(script), "-Data", str(root)], check=True)
|
|
72
|
+
try:
|
|
73
|
+
request("POST", "/v1/stop")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
# Give the broker time to release discovery/log files; never move its token live.
|
|
77
|
+
for _ in range(30):
|
|
78
|
+
try:
|
|
79
|
+
request("GET", "/v1/status", timeout=0.2)
|
|
80
|
+
except Exception:
|
|
81
|
+
break
|
|
82
|
+
time.sleep(0.1)
|
|
83
|
+
else:
|
|
84
|
+
raise RuntimeError("Broker did not stop; local files retained. Run ocdeck stop")
|
|
85
|
+
backup = root / "backups" / str(time.time_ns())
|
|
86
|
+
backup.mkdir(parents=True, exist_ok=False)
|
|
87
|
+
for name in ("config.json", "install.json", "projects.json", "update.json", "token", "discovery.json"):
|
|
88
|
+
path = root / name
|
|
89
|
+
if path.exists():
|
|
90
|
+
shutil.move(str(path), str(backup / name))
|
|
91
|
+
# Remove only exact known shims after verifying their runtime/command marker.
|
|
92
|
+
runtime = metadata.get("python")
|
|
93
|
+
for name in ("ocdeck.cmd", "oc.cmd", "opencode.cmd"):
|
|
94
|
+
path = root / "bin" / name
|
|
95
|
+
if path.exists() and runtime:
|
|
96
|
+
text = path.read_text(errors="replace")
|
|
97
|
+
if runtime in text and " -m ocdeck " in text:
|
|
98
|
+
shutil.move(str(path), str(backup / name))
|
|
99
|
+
print("Uninstalled managed hooks, integration and configuration. Backups and Python environment retained.")
|
|
100
|
+
return 0
|
ocdeck/updates.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""One bounded, optional release lookup per broker start; never installs software."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import urllib.request
|
|
6
|
+
from packaging.version import Version, InvalidVersion
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .common import read_json, atomic_json
|
|
9
|
+
from .security import scrub
|
|
10
|
+
|
|
11
|
+
URL = "https://api.github.com/repos/darkmatter2222/AgentStreamDeck/releases/latest"
|
|
12
|
+
LOG = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check(root, config, fetch=None):
|
|
16
|
+
if not config.get("check_updates", True):
|
|
17
|
+
return None
|
|
18
|
+
try:
|
|
19
|
+
if fetch is None:
|
|
20
|
+
req = urllib.request.Request(
|
|
21
|
+
URL, headers={"User-Agent": "AgentStreamDeck", "Accept": "application/vnd.github+json"}
|
|
22
|
+
)
|
|
23
|
+
with urllib.request.urlopen(req, timeout=3) as response:
|
|
24
|
+
raw = response.read(262145)
|
|
25
|
+
if len(raw) > 262144:
|
|
26
|
+
raise ValueError("Release response too large")
|
|
27
|
+
release = json.loads(raw)
|
|
28
|
+
else:
|
|
29
|
+
release = fetch()
|
|
30
|
+
version = str(release["tag_name"])
|
|
31
|
+
if release.get("draft") or release.get("prerelease") or Version(version) <= Version(__version__):
|
|
32
|
+
return None
|
|
33
|
+
info = {
|
|
34
|
+
"version": version,
|
|
35
|
+
"url": "https://github.com/darkmatter2222/AgentStreamDeck/releases",
|
|
36
|
+
"notes": scrub(str(release.get("body", ""))[:8000]),
|
|
37
|
+
}
|
|
38
|
+
state = read_json(root / "update.json", {}) or {}
|
|
39
|
+
if state.get("version") != version:
|
|
40
|
+
LOG.info("Update available: %s — %s", version, info["url"])
|
|
41
|
+
print(f"AgentStreamDeck {version} available: {info['url']}\n{info['notes']}", flush=True)
|
|
42
|
+
atomic_json(root / "update.json", info)
|
|
43
|
+
return info
|
|
44
|
+
except (OSError, ValueError, KeyError, TypeError, InvalidVersion):
|
|
45
|
+
LOG.info("Update check unavailable; continuing offline")
|
|
46
|
+
return None
|