ssh-server-manager 0.5.0__tar.gz → 0.6.0__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.
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/PKG-INFO +27 -1
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/README.md +25 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/pyproject.toml +2 -1
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/__init__.py +1 -1
- ssh_server_manager-0.6.0/scripts/ssh_server_manager/assets/ui/app.js +1 -0
- ssh_server_manager-0.6.0/scripts/ssh_server_manager/assets/ui/index.html +1 -0
- ssh_server_manager-0.6.0/scripts/ssh_server_manager/assets/ui/skills.css +1 -0
- ssh_server_manager-0.6.0/scripts/ssh_server_manager/assets/ui/skills.js +1 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/cli.py +70 -1
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/db.py +335 -3
- ssh_server_manager-0.6.0/scripts/ssh_server_manager/service.py +430 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/validation.py +55 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/webapp.py +50 -1
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager.egg-info/PKG-INFO +27 -1
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager.egg-info/SOURCES.txt +2 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager.egg-info/requires.txt +1 -0
- ssh_server_manager-0.6.0/tests/test_cli.py +110 -0
- ssh_server_manager-0.6.0/tests/test_db.py +390 -0
- ssh_server_manager-0.6.0/tests/test_service.py +222 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_ui_assets.py +49 -1
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_webapp.py +116 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_website.py +41 -3
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/app.js +0 -1
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/index.html +0 -1
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/service.py +0 -102
- ssh_server_manager-0.5.0/tests/test_cli.py +0 -41
- ssh_server_manager-0.5.0/tests/test_db.py +0 -179
- ssh_server_manager-0.5.0/tests/test_service.py +0 -28
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/askpass.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/contexts.css +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/contexts.js +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/diagnostics.css +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/diagnostics.js +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/notes.css +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/notes.js +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/styles.css +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/assets/ui/themes.css +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/auth.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/importer.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/paths.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/ssh_config.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/ssh_runner.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager/vault.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager.egg-info/dependency_links.txt +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager.egg-info/entry_points.txt +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/scripts/ssh_server_manager.egg-info/top_level.txt +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/setup.cfg +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_askpass.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_auth.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_importer.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_runner.py +0 -0
- {ssh_server_manager-0.5.0 → ssh_server_manager-0.6.0}/tests/test_ssh_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ssh-server-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Local-first SSH host and credential manager for humans and AI agents — OS-keychain vault, loopback web UI, agent-safe by design
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://xiayh0107.github.io/servers-connect/
|
|
@@ -25,6 +25,7 @@ Requires-Dist: argon2-cffi>=23.1
|
|
|
25
25
|
Requires-Dist: fastapi>=0.115
|
|
26
26
|
Requires-Dist: keyring>=25.0
|
|
27
27
|
Requires-Dist: platformdirs>=4.0
|
|
28
|
+
Requires-Dist: PyYAML>=6.0
|
|
28
29
|
Requires-Dist: uvicorn>=0.34
|
|
29
30
|
Requires-Dist: webauthn>=2.2
|
|
30
31
|
Provides-Extra: test
|
|
@@ -71,6 +72,7 @@ SSH Server Manager is different by construction:
|
|
|
71
72
|
| The web UI is unreachable from the network | Loopback-only bind, one-time launch token, CSRF + Origin checks, strict CSP |
|
|
72
73
|
| Revealing a stored secret requires re-auth | WebAuthn passkey (Touch ID / Windows Hello) or an Argon2id master password; grants are single-use and expire in 30 s |
|
|
73
74
|
| AI agents can drive it without seeing secrets | Every command has `--json`; SSH authentication happens through AskPass so secrets never appear in argv, env, logs, or model context |
|
|
75
|
+
| Environment guidance stays with the right hosts | Local Agent Skills have explicit many-to-many host bindings; resolution partitions multi-host tasks without installing skills or returning their bodies |
|
|
74
76
|
| Host keys are always verified | `StrictHostKeyChecking` is never weakened, by policy and by code |
|
|
75
77
|
|
|
76
78
|
## Quick start
|
|
@@ -105,6 +107,12 @@ browser with a one-time tokenized URL.
|
|
|
105
107
|
|
|
106
108
|
- **Connection profiles** — alias, host, port, user, project/scenario tags,
|
|
107
109
|
notes, ordered ProxyJump chains (with cycle detection).
|
|
110
|
+
- **Host-bound Agent Skills** — discover and register local skills, bind one
|
|
111
|
+
skill to several related nodes or several skills to one host, and resolve
|
|
112
|
+
exact `applies_to` partitions before an agent acts. Bindings are routing
|
|
113
|
+
metadata, not remote authorization. Manage a host's assignments directly
|
|
114
|
+
from its Connections row or active Workspace; use the Skill Library for
|
|
115
|
+
discovery and registration.
|
|
108
116
|
- **Three credential kinds** — vault-backed passwords, private keys with
|
|
109
117
|
optional vault-backed passphrases, and ssh-agent/OpenSSH defaults.
|
|
110
118
|
Credentials are reusable across servers and protected against deletion
|
|
@@ -157,6 +165,24 @@ inventory questions from a single `--json` call, hand interactive shells to
|
|
|
157
165
|
refuses to run without a TTY instead of hanging), and clean up a background
|
|
158
166
|
UI with `serverctl ui --stop`.
|
|
159
167
|
|
|
168
|
+
Environment-specific skills can follow only the hosts where they belong. The
|
|
169
|
+
agent identifies the target first, runs `serverctl skill resolve ALIAS
|
|
170
|
+
[ALIAS ...] --json`, then selects relevant ready skills by their normal trigger
|
|
171
|
+
rules and applies them only to their returned `applies_to` aliases. Switching
|
|
172
|
+
hosts discards the previous host-specific context; no cluster name or provider
|
|
173
|
+
is hard-coded.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
serverctl skill discover --json
|
|
177
|
+
serverctl skill add ~/.agents/skills/gpu-ops \
|
|
178
|
+
--server gpu-lab-01 --server gpu-lab-02 --json
|
|
179
|
+
serverctl skill resolve gpu-lab-01 gpu-lab-02 --json
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Discovery is local and read-only. Registration does not install or execute a
|
|
183
|
+
skill, and resolution returns metadata and paths rather than instruction
|
|
184
|
+
bodies. See the [agent deployment guide](docs/ai-agents.md#host-specific-agent-skills).
|
|
185
|
+
|
|
160
186
|
Deploy the skill with one line:
|
|
161
187
|
|
|
162
188
|
```bash
|
|
@@ -38,6 +38,7 @@ SSH Server Manager is different by construction:
|
|
|
38
38
|
| The web UI is unreachable from the network | Loopback-only bind, one-time launch token, CSRF + Origin checks, strict CSP |
|
|
39
39
|
| Revealing a stored secret requires re-auth | WebAuthn passkey (Touch ID / Windows Hello) or an Argon2id master password; grants are single-use and expire in 30 s |
|
|
40
40
|
| AI agents can drive it without seeing secrets | Every command has `--json`; SSH authentication happens through AskPass so secrets never appear in argv, env, logs, or model context |
|
|
41
|
+
| Environment guidance stays with the right hosts | Local Agent Skills have explicit many-to-many host bindings; resolution partitions multi-host tasks without installing skills or returning their bodies |
|
|
41
42
|
| Host keys are always verified | `StrictHostKeyChecking` is never weakened, by policy and by code |
|
|
42
43
|
|
|
43
44
|
## Quick start
|
|
@@ -72,6 +73,12 @@ browser with a one-time tokenized URL.
|
|
|
72
73
|
|
|
73
74
|
- **Connection profiles** — alias, host, port, user, project/scenario tags,
|
|
74
75
|
notes, ordered ProxyJump chains (with cycle detection).
|
|
76
|
+
- **Host-bound Agent Skills** — discover and register local skills, bind one
|
|
77
|
+
skill to several related nodes or several skills to one host, and resolve
|
|
78
|
+
exact `applies_to` partitions before an agent acts. Bindings are routing
|
|
79
|
+
metadata, not remote authorization. Manage a host's assignments directly
|
|
80
|
+
from its Connections row or active Workspace; use the Skill Library for
|
|
81
|
+
discovery and registration.
|
|
75
82
|
- **Three credential kinds** — vault-backed passwords, private keys with
|
|
76
83
|
optional vault-backed passphrases, and ssh-agent/OpenSSH defaults.
|
|
77
84
|
Credentials are reusable across servers and protected against deletion
|
|
@@ -124,6 +131,24 @@ inventory questions from a single `--json` call, hand interactive shells to
|
|
|
124
131
|
refuses to run without a TTY instead of hanging), and clean up a background
|
|
125
132
|
UI with `serverctl ui --stop`.
|
|
126
133
|
|
|
134
|
+
Environment-specific skills can follow only the hosts where they belong. The
|
|
135
|
+
agent identifies the target first, runs `serverctl skill resolve ALIAS
|
|
136
|
+
[ALIAS ...] --json`, then selects relevant ready skills by their normal trigger
|
|
137
|
+
rules and applies them only to their returned `applies_to` aliases. Switching
|
|
138
|
+
hosts discards the previous host-specific context; no cluster name or provider
|
|
139
|
+
is hard-coded.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
serverctl skill discover --json
|
|
143
|
+
serverctl skill add ~/.agents/skills/gpu-ops \
|
|
144
|
+
--server gpu-lab-01 --server gpu-lab-02 --json
|
|
145
|
+
serverctl skill resolve gpu-lab-01 gpu-lab-02 --json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Discovery is local and read-only. Registration does not install or execute a
|
|
149
|
+
skill, and resolution returns metadata and paths rather than instruction
|
|
150
|
+
bodies. See the [agent deployment guide](docs/ai-agents.md#host-specific-agent-skills).
|
|
151
|
+
|
|
127
152
|
Deploy the skill with one line:
|
|
128
153
|
|
|
129
154
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ssh-server-manager"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = "Local-first SSH host and credential manager for humans and AI agents — OS-keychain vault, loopback web UI, agent-safe by design"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -25,6 +25,7 @@ dependencies = [
|
|
|
25
25
|
"fastapi>=0.115",
|
|
26
26
|
"keyring>=25.0",
|
|
27
27
|
"platformdirs>=4.0",
|
|
28
|
+
"PyYAML>=6.0",
|
|
28
29
|
"uvicorn>=0.34",
|
|
29
30
|
"webauthn>=2.2",
|
|
30
31
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e="ssh-manager-theme",t="ssh-manager-host-context",ta="ssh-manager-accent",tb=["indigo","teal","emerald","amber","rose","violet","graphite"];function a(e,t){try{return localStorage.getItem(e)||t}catch(e){return t}}function s(e,t){try{localStorage.setItem(e,t)}catch(e){}}const n={csrf:"",servers:[],credentials:[],contexts:[],skills:[],auth:{},view:"workspace",hostQuery:"",hostContext:a(t,"*"),theme:a(e,"system"),accent:a(ta,"indigo"),credentialQuery:"",credentialKind:"*",files:E()};let r,i,o,skillModule,l=null,d=null,c=0,u=0,p=0;const h=e=>document.querySelector(e);function v(e,t=!1){const n=h("#toast");n.textContent=e,n.className=t?"show error":"show",clearTimeout(r),r=setTimeout(()=>{n.className=""},4500)}async function f(e,t={}){const a=t.method||"GET",s={...t.headers||{}};t.body&&"string"!=typeof t.body&&(s["Content-Type"]="application/json",t.body=JSON.stringify(t.body)),"GET"!==a&&(s["X-CSRF-Token"]=n.csrf);const i=await fetch(e,{...t,method:a,headers:s,credentials:"same-origin"}),r=(i.headers.get("content-type")||"").includes("json")?await i.json():await i.text();if(!i.ok){const e=r.message||r.detail||r.error||`Request failed (${i.status})`;throw new Error(e)}return r}function m(e){return String(e??"").replace(/[&<>'"]/g,e=>({"&":"&","<":"<",">":">","'":"'",'"':"""}[e]))}function y(e){const t=Math.max(0,Math.floor(e/1e3));if(t<60)return"just now";const n=Math.floor(t/60);if(n<60)return`${n}m ago`;const a=Math.floor(n/60);return a<24?`${a}h ago`:`${Math.floor(a/24)}d ago`}function w(e){if(!e.last_test_status||!e.last_test_at)return{tone:"neutral",short:"Not checked",meta:"—",label:"Not checked",title:"No connection check has been run"};const t=Date.parse(e.last_test_at);if(!Number.isFinite(t))return{tone:"neutral",short:"Unknown",meta:"—",label:"Unknown",title:"The last check time is unavailable"};const n=Math.max(0,Date.now()-t),a=new Date(t).toLocaleString();if(n>12e4){const t="ok"===e.last_test_status?"reachable":"unreachable";return{tone:"stale",short:`Last ${t}`,meta:y(n),label:`Last ${t} ${y(n)}`,title:`Last checked ${a}`}}if("ok"===e.last_test_status){const t=null==e.last_test_latency_ms?"":` · ${e.last_test_latency_ms} ms`;return{tone:"ok",short:"Reachable",meta:null==e.last_test_latency_ms?y(n):`${e.last_test_latency_ms} ms`,label:`Reachable ${y(n)}${t}`,title:`Verified ${a}`}}return{tone:"failed",short:"Unreachable",meta:y(n),label:`Unavailable ${y(n)}`,title:`Failed ${a}${e.last_test_message?`: ${e.last_test_message}`:""}`}}function b(t,a=!1){const i=["system","light","dark","contrast"].includes(t)?t:"system";n.theme=i;const r="system"===i?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":i;document.documentElement.dataset.theme=r,h("#themeSelect")&&(h("#themeSelect").value=i),a&&s(e,i)}function tc(e,t=!1){const a=tb.includes(e)?e:"indigo";n.accent=a,"indigo"===a?delete document.documentElement.dataset.accent:document.documentElement.dataset.accent=a,h("#accentSelect")&&(h("#accentSelect").value=a),t&&s(ta,a)}function g(e){return`tag:${String(e).toLocaleLowerCase()}`}let tcs,tcm;function tc(e){return tcs!==n.contexts&&(tcs=n.contexts,tcm=new Map([...n.contexts].sort((e,t)=>e.name.localeCompare(t.name,void 0,{sensitivity:"base"})).map((e,t)=>[e.name.toLocaleLowerCase(),t%6]))),tcm.get(String(e).toLocaleLowerCase())??0}function C(){return o||=import("/assets/contexts.js")}function loadSkillsModule(){return skillModule||=import("/assets/skills.js")}function $(){return{getState:()=>n,getVisibleServers:x,api:f,refresh:q,toast:v,setHostContext:L,contextKey:g,tagColor:tc,showView:P,openServer:K,serverStatus:w}}function skillOptions(){return{getState:()=>n,api:f,refresh:q,toast:v,showView:P}}function k(e){if("*"===n.hostContext)return!0;const t=e.tags||[];return"-"===n.hostContext?0===t.length:t.some(e=>g(e)===n.hostContext)}function x(){return n.servers.filter(k)}function S(){let e=0;n.servers.forEach(t=>{(t.tags||[]).length||(e+=1)});const t=new Map(n.contexts.map(e=>[g(e.name),{label:e.name,count:e.count}])),a=new Set(["*",...t.keys()]);e&&a.add("-"),a.has(n.hostContext)||(n.hostContext="*");const s=[`<option value="*">All hosts (${n.servers.length})</option>`];e&&s.push(`<option value="-">Unassigned (${e})</option>`),[...t.entries()].sort((e,t)=>e[1].label.localeCompare(t[1].label,void 0,{sensitivity:"base"})).forEach(([e,t])=>s.push(`<option value="${m(e)}">${m(t.label)} (${t.count})</option>`)),h("#hostContextSelect").innerHTML=s.join(""),h("#hostContextSelect").value=n.hostContext;const i=[...n.contexts].sort((e,t)=>e.name.localeCompare(t.name,void 0,{sensitivity:"base"}));h("#sidebarContextList").innerHTML=i.map((e,t)=>{const a=g(e.name),s=a===n.hostContext;return`<button type="button" class="sidebar-context-row${s?" active":""}" data-sidebar-filter-context="${m(a)}" aria-pressed="${s}"><i class="tc-${t%6}" aria-hidden="true"></i><span>${m(e.name)}</span><b>${e.count}</b></button>`}).join(""),h("#emptySidebarContexts").hidden=i.length>0;const r=x();h("#fileServerSelect").innerHTML=['<option value="">Choose a host…</option>'].concat(r.map(e=>`<option value="${m(e.id)}">${m(e.alias)}</option>`)).join(""),h("#fileServerSelect").disabled=0===r.length,h("#fileServerSelect").value=n.files.serverId}function L(e){n.hostContext=e,s(t,e);const a=n.servers.find(e=>e.id===n.files.serverId);a&&!k(a)&&(c+=1,n.files=E()),S(),T(),h("#serverCount").textContent=x().length,h("#emptyServers").hidden=x().length>0,O(),o&&o.then(e=>e.syncContextUI($())),I()}function E(e=""){return{serverId:e,path:"",requestedPath:"~",parent:null,entries:[],total:0,truncated:!1,unparsed:0,latencyMs:null,showHidden:!1,query:"",sortBy:"name",sortDirection:"asc",visibleLimit:250,loading:!1,error:""}}const _={workspace:{eyebrow:"REMOTE WORKSPACE",title:"Workspace",description:"Browse and reference remote files without opening a shell."},servers:{eyebrow:"CONNECTION INVENTORY",title:"Connections",description:"Manage endpoints, credentials, and jump hosts."},contexts:{eyebrow:"HOST ORGANIZATION",title:"Tags",description:"Label hosts for fast filtering by project, environment, or role."},skills:{eyebrow:"HOST-SCOPED GUIDANCE",title:"Skill Library",description:"Register local Skills and see which hosts they can serve."},credentials:{eyebrow:"SECURE STORAGE",title:"Credentials",description:"Reusable authentication profiles stored securely."}};function P(e){_[e]&&(n.view=e,document.body.dataset.view=e,document.querySelectorAll("[data-view-panel]").forEach(t=>{t.hidden=t.dataset.viewPanel!==e}),document.querySelectorAll(".nav-item").forEach(t=>{const n=t.dataset.view===e;t.classList.toggle("active",n),n?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),h("#hostNavigation").hidden="contexts"===e||"skills"===e,h("#labelNavigation").hidden="skills"===e,h("#contextFilterControl").hidden="workspace"!==e&&"servers"!==e,h("#serverHeaderActions").hidden="servers"!==e,h("#contextHeaderActions").hidden="contexts"!==e,h("#credentialHeaderActions").hidden="credentials"!==e,h("#workspaceHeaderActions").hidden="workspace"!==e,h("#viewEyebrow").textContent=_[e].eyebrow,h("#viewTitle").textContent=_[e].title,h("#viewDescription").textContent=_[e].description)}function T(){const e=n.hostQuery.trim().toLocaleLowerCase(),t=x().filter(t=>!e||[t.alias,t.hostname,t.username,t.notes,...t.tags||[]].some(t=>String(t||"").toLocaleLowerCase().includes(e)));h("#emptyWorkspaceHosts").hidden=t.length>0,h("#workspaceHostList").innerHTML=t.map(e=>{const t=w(e),a=e.id===n.files.serverId,s=e.tags||[],i=s.slice(0,2).map(e=>`<button type="button" class="host-tag tc-${tc(e)}" data-sidebar-filter-context="${m(g(e))}" title="Show ${m(e)} hosts">${m(e)}</button>`).join(""),r=s.length>2?`<span class="host-tag-more">+${s.length-2}</span>`:"";return`<div class="workspace-host-row ${a?"active":""}"><button type="button" class="workspace-host" data-id="${m(e.id)}" aria-pressed="${a}" title="${m(t.label)} — ${m(t.title)}"><span class="host-status-dot ${t.tone}" aria-hidden="true"></span><span class="host-copy"><strong>${m(e.alias)}</strong><small>${m(e.username)}@${m(e.hostname)}</small></span></button><button type="button" class="workspace-host-context${s.length?"":" empty"}" data-sidebar-contexts data-id="${m(e.id)}" aria-label="Edit tags for ${m(e.alias)}" aria-haspopup="dialog" aria-expanded="false" title="${m(s.length?`Edit tags: ${s.join(", ")}`:"Add tag")}">${s.length?"•••":"+"}</button><div class="workspace-host-tags">${i}${r}</div></div>`}).join("")}function B(e,t){const a="directory"===e.type;if(a!==("directory"===t.type))return a?-1:1;let s=0;return s="size"===n.files.sortBy?Number(e.size)-Number(t.size):"modified"===n.files.sortBy?String(e.modified).localeCompare(String(t.modified)):String(e.name).localeCompare(String(t.name),void 0,{numeric:!0,sensitivity:"base"}),"asc"===n.files.sortDirection?s:-s}function H(){const e=n.files.query.trim().toLocaleLowerCase();return n.files.entries.filter(e=>n.files.showHidden||!e.hidden).filter(t=>!e||t.name.toLocaleLowerCase().includes(e)).slice().sort(B)}function M(e){if(!e)return;const t=w(e);h("#fileHostStatus").className=`compact-status ${t.tone}`,h("#fileHostStatus").innerHTML=`<i></i>${m(t.label)}`,h("#fileHostStatus").title=t.title}function N(){T(),O(),o&&o.then(e=>e.syncContextUI($())),M(n.servers.find(e=>e.id===n.files.serverId))}function I(){const e=n.servers.find(e=>e.id===n.files.serverId),t=Boolean(e);if(h("#fileBrowserEmpty").hidden=t,h("#fileBrowserContent").hidden=!t,h("#emptyChooseHostButton").hidden=0===n.servers.length,h("#workspaceActiveActions").hidden=!t,h("#workspaceAddHeaderButton").hidden=t,h("#workspaceHeaderActions").hidden="workspace"!==n.view,!t)return;h("#fileConnectionLabel").innerHTML=`<strong>${m(e.alias)}<small>Active SSH host</small></strong><span>${m(e.username)}@${m(e.hostname)} · port ${e.port}</span>`,M(e),h("#fileShowHidden").checked=n.files.showHidden,h("#fileSearchInput").value=n.files.query,h("#fileHomeButton").disabled=n.files.loading,h("#fileUpButton").disabled=n.files.loading||!n.files.parent,h("#fileRefreshButton").disabled=n.files.loading||!n.files.path,h("#copyDirectoryReferenceButton").disabled=n.files.loading||!n.files.path,function(e){if(!e)return void(h("#fileBreadcrumbs").innerHTML='<span class="breadcrumb-current">Home</span>');const t=e.split("/").filter(Boolean),n=[{label:"/",path:"/"}];let a="";for(const e of t)a+=`/${e}`,n.push({label:e,path:a});h("#fileBreadcrumbs").innerHTML=n.map((e,t)=>`${t?'<span class="breadcrumb-separator" aria-hidden="true">/</span>':""}${t===n.length-1?`<span class="breadcrumb-current" aria-current="page">${m(e.label)}</span>`:`<button type="button" class="breadcrumb-button" data-path="${m(e.path)}">${m(e.label)}</button>`}`).join("")}(n.files.path),document.querySelectorAll(".sort-button").forEach(e=>{const t=e.dataset.sort===n.files.sortBy;e.classList.toggle("active",t),e.querySelector("span").textContent=t?"asc"===n.files.sortDirection?"↑":"↓":""});const a=Boolean(n.files.error);h("#fileBrowserError").hidden=!a,h("#fileBrowserErrorMessage").textContent=n.files.error,h("#fileLoading").hidden=!n.files.loading;const s=n.files.showHidden?0:n.files.entries.filter(e=>e.hidden).length,i=n.files.query.trim().toLocaleLowerCase(),r=H(),o=r.slice(0,n.files.visibleLimit),l=!n.files.loading&&!a&&o.length>0,d=Math.max(0,r.length-o.length);if(h("#fileTableWrap").hidden=!l,h("#fileListMore").hidden=n.files.loading||a||0===d,h("#fileListMoreStatus").textContent=`${o.length} of ${r.length} items shown`,h("#fileLoadMoreButton").textContent=`Show ${Math.min(250,d)} more`,h("#fileListEmpty").hidden=n.files.loading||a||r.length>0,h("#copyVisibleReferencesButton").disabled=n.files.loading||a||0===o.length,h("#fileRows").innerHTML=o.map(e=>{const t="directory"===e.type?`<button type="button" class="file-name-button open-directory" data-path="${m(e.path)}">${m(e.name)}</button>`:`<span class="file-name">${m(e.name)}</span>`;return`<tr>\n <td><div class="file-name-cell">${function(e){return`<span class="file-icon ${["directory","symlink"].includes(e)?e:"file"}" aria-hidden="true"></span>`}(e.type)}<div>${t}<span class="file-kind">${m(e.type)}</span></div></div></td>\n <td class="file-size">${function(e,t){if("directory"===t)return"—";const n=Number(e)||0;if(n<1024)return`${n} B`;const a=["KB","MB","GB","TB"];let s=n,i=-1;do{s/=1024,i+=1}while(s>=1024&&i<a.length-1);return`${s>=10?s.toFixed(0):s.toFixed(1)} ${a[i]}`}(e.size,e.type)}</td>\n <td class="file-modified-column subtle">${m(function(e){const t=String(e||"—").trim().split(/\s+/);return t.length<3?t.join(" "):`${t[0]} ${t[1]} · ${t.slice(2).join(" ")}`}(e.modified))}</td>\n <td class="file-permissions-column">${function(e){const t=String(e||"----------").padEnd(10,"-"),n=[t.slice(1,4),t.slice(4,7),t.slice(7,10)],a=n.map(e=>e.replace(/-/g,"").toUpperCase()||"—"),s=n.map(e=>["-"!==e[0]?"read":"","-"!==e[1]?"write":"","-"!==e[2]?"execute":""].filter(Boolean).join("/")||"none");return`<span class="permission-summary" title="${m(`Owner: ${s[0]} · Group: ${s[1]} · Others: ${s[2]} · ${t}`)}"><b>Owner ${a[0]}</b><small>Group ${a[1]} · Others ${a[2]}</small></span>`}(e.permissions)}</td>\n <td><button type="button" class="row-icon-button copy-file-reference" data-path="${m(e.path)}" aria-label="Copy ${m(e.name)} reference" title="Copy Agent reference"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg></button></td>\n </tr>`}).join(""),n.files.loading)h("#fileListStatus").textContent="Connecting over SFTP…";else if(a)h("#fileListStatus").textContent="Connection failed";else if(n.files.path){const e=[d?`${o.length} of ${r.length} items`:`${r.length} item${1===r.length?"":"s"}`];i&&e.push("filtered from "+(n.files.entries.length-s)),s&&e.push(`${s} hidden`),n.files.truncated&&e.push(`showing the first ${n.files.entries.length}`),n.files.unparsed&&e.push(`${n.files.unparsed} unsupported name${1===n.files.unparsed?"":"s"} skipped`),null!=n.files.latencyMs&&e.push(`${n.files.latencyMs} ms`),h("#fileListStatus").textContent=e.join(" · ")}else h("#fileListStatus").textContent="Ready to connect";const c=n.files.entries.length>0&&0===r.length;h("#fileListEmptyTitle").textContent=c?"No matching files":"This directory is empty",h("#fileListEmptyMessage").textContent=c?"Try another filter or include hidden files.":"There is nothing to display here.",n.files.serverId?loadSkillsModule().then(e=>e.syncHostSkills(skillOptions(),n.files.serverId)).catch(e=>v(e.message||String(e),!0)):(h("#hostSkillsMount").replaceChildren(),skillModule&&skillModule.then(e=>e.syncHostSkills(skillOptions(),"")))}async function D(e="~"){if(!n.files.serverId)return;const t=++c,a=e||"~";n.files.requestedPath=a,n.files.visibleLimit=250,n.files.loading=!0,n.files.error="",h("#filePathInput").value=a,I();try{const e=new URLSearchParams({path:a}),s=await f(`/api/servers/${n.files.serverId}/files?${e}`);if(t!==c)return;n.files.path=s.path,n.files.requestedPath=s.path,n.files.parent=s.parent,n.files.entries=s.entries,n.files.total=s.total,n.files.truncated=s.truncated,n.files.unparsed=s.unparsed,n.files.latencyMs=s.latency_ms;const i=n.servers.find(e=>e.id===n.files.serverId);i&&(i.last_test_at=s.connection_checked_at,i.last_test_status="ok",i.last_test_latency_ms=s.latency_ms,i.last_test_error_code=null,i.last_test_message="SFTP connection succeeded",T(),O()),h("#filePathInput").value=s.path}catch(e){if(t!==c)return;n.files.error=e.message||String(e),v(n.files.error,!0);try{await q()}catch(e){}}finally{t===c&&(n.files.loading=!1,I())}}async function A(e){if(P("workspace"),e&&e===n.files.serverId&&n.files.path)return T(),void I();c+=1,n.files=E(e),h("#fileServerSelect").value=e,h("#filePathInput").value="~",T(),I(),e&&await D("~")}async function j(e){const t=n.servers.find(e=>e.id===n.files.serverId);t&&e&&(await navigator.clipboard.writeText(`${t.alias}:${e}`),v("Remote path copied for the Agent"))}async function F(){const e=n.servers.find(e=>e.id===n.files.serverId),t=H().slice(0,n.files.visibleLimit);e&&t.length&&(await navigator.clipboard.writeText(t.map(t=>`${e.alias}:${t.path}`).join("\n")),v(`${t.length} file reference${1===t.length?"":"s"} copied`))}function O(){h("#serverRows").innerHTML=x().map(e=>{const t=e.proxy_jumps||[],a=t.length?t.join(" → "):"Direct connection",s=n.credentials.find(t=>t.id===e.credential_id),i=e.credential_label||"OpenSSH default",r=s?.kind||"agent",o=n.skills.filter(t=>(t.servers||[]).some(t=>t.id===e.id)).length,l=o?`✦ ${o} skill${1===o?"":"s"}`:"+ Assign skills";return`<tr data-server-id="${m(e.id)}">\n <td><div class="connection-name"><span class="host-status-dot ${w(e).tone}" aria-hidden="true"></span><div><span class="alias">${m(e.alias)}</span><span class="subtle">${m(e.notes||"No notes")}</span><button type="button" class="${o?"table-context-tag":"table-context-empty"} manage-server-skills" data-id="${m(e.id)}" aria-haspopup="dialog" aria-label="Manage Agent Skills for ${m(e.alias)}, ${o} assigned">${l}</button></div></div></td>\n <td><div class="endpoint-cell"><code>${m(e.username)}@${m(e.hostname)}</code><span><b>SSH</b> Port ${e.port}</span></div></td>\n <td>${function(e){return e?.length?`<div class="tag-list">${e.map(e=>`<button type="button" class="table-context-tag tc-${tc(e)}" data-context="${m(e)}">${m(e)}</button>`).join("")}<button type="button" class="table-context-edit" data-edit-contexts aria-label="Edit tags" aria-haspopup="dialog" aria-expanded="false">+</button></div>`:'<button type="button" class="table-context-empty" data-edit-contexts aria-haspopup="dialog" aria-expanded="false">+ Add tag</button>'}(e.tags)}</td>\n <td><div class="credential-cell"><i aria-hidden="true">✓</i><span><strong>${m(i)}</strong><small>${m(r)}</small></span></div></td>\n <td><div class="route-cell"><strong>${t.length?"ProxyJump":"—"}</strong><small>${m(a)}</small></div></td>\n <td>${function(e){const t=w(e);return`<span class="status-block" title="${m(t.title)}"><span class="status-pill ${t.tone}"><i aria-hidden="true"></i>${m(t.short)}</span><small>${m(t.meta)}</small></span>`}(e)}</td>\n <td><div class="table-actions connection-actions">\n <button class="button small browse-files" data-id="${m(e.id)}"><span aria-hidden="true">›_</span> Browse</button>\n <details class="row-menu"><summary aria-label="More actions for ${m(e.alias)}">•••</summary><div><button type="button" class="test-server" data-id="${m(e.id)}">Test connection</button><button type="button" class="copy-command" data-alias="${m(e.alias)}">Copy command</button><button type="button" class="edit-server" data-id="${m(e.id)}">Edit host</button><button type="button" class="delete-server danger" data-id="${m(e.id)}">Delete host</button></div></details>\n </div></td>\n </tr>`}).join("")}function R(){const e=n.credentialQuery.trim().toLocaleLowerCase(),t=n.credentials.filter(t=>("*"===n.credentialKind||t.kind===n.credentialKind)&&(!e||[t.label,t.kind,t.key_path].some(t=>String(t||"").toLocaleLowerCase().includes(e))));h("#credentialRows").innerHTML=t.map(e=>{const t="password"===e.kind?e.has_secret?"Password stored":"Missing password":"key"===e.kind?e.has_passphrase?"Passphrase stored":"No passphrase":"Uses local agent",a="agent"===e.kind||"password"===e.kind&&e.has_secret||"key"===e.kind&&e.key_path,s=e.has_secret||e.has_passphrase,i={password:"Password",key:"Key",agent:"OpenSSH"}[e.kind]||e.kind,r=e.key_path?`<div class="key-location"><code title="${m(e.key_path)}">${m(e.key_path)}</code><button class="endpoint-copy copy-key-path" data-path="${m(e.key_path)}" aria-label="Copy key path">Copy</button></div>`:'<span class="subtle">—</span>',o=function(e){const t=n.servers.filter(t=>t.credential_id===e.id&&t.last_test_at).map(e=>Date.parse(e.last_test_at)).filter(Number.isFinite);if(!t.length)return{label:"Never",title:"No connection check has used this credential"};const a=Math.max(...t);return{label:y(Date.now()-a),title:new Date(a).toLocaleString()}}(e);return`<tr><td><span class="alias">${m(e.label)}</span></td><td><span class="credential-kind">${m(i)}</span></td><td><span class="credential-ready ${a?"ready":"incomplete"}"><i></i>${a?"Ready":"Incomplete"}</span></td><td>${m(t)}</td><td>${r}</td><td><span class="last-checked" title="${m(o.title)}">${m(o.label)}</span></td><td><div class="table-actions">${s?`<button class="button small subtle-button reveal-credential" data-id="${m(e.id)}">Reveal</button>`:""}<button class="button secondary small edit-credential" data-id="${m(e.id)}">Edit</button><button class="row-icon-button danger delete-credential" data-id="${m(e.id)}" aria-label="Delete ${m(e.label)}" title="Delete credential"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button></div></td></tr>`}).join(""),h("#emptyCredentials").hidden=t.length>0,h("#emptyCredentials").textContent=n.credentials.length?"No credentials match this search.":"No credential profiles configured."}async function q(){const e=await f("/api/bootstrap");n.csrf=e.csrf,n.servers=e.servers,n.credentials=e.credentials,n.contexts=e.contexts||[],n.skills=e.skills||[],n.auth=e.auth,h("#configPath").textContent=e.managed_config,h("#systemStatus").className="session-status ok",h("#systemStatus small").textContent="Ready on this device",function(){n.files.serverId&&!n.servers.some(e=>e.id===n.files.serverId)&&(c+=1,n.files=E()),S();const e=x();h("#serverCount").textContent=e.length,h("#credentialCount").textContent=n.credentials.length,h("#navServerCount").textContent=n.servers.length,h("#navContextCount").textContent=n.contexts.length,h("#navSkillCount").textContent=n.skills.length,h("#navCredentialCount").textContent=n.credentials.length,h("#summaryTotalHosts").textContent=n.servers.length;const t=n.servers.filter(e=>"ok"===w(e).tone).length;h("#summaryReachableHosts").textContent=t,h("#summaryReachablePercent").textContent=n.servers.length?`${(t/n.servers.length*100).toFixed(1)}% recently verified`:"No hosts yet",h("#summaryCredentialCount").textContent=n.credentials.length,h("#summaryContextCount").textContent=n.contexts.length,h("#credentialProfileCount").textContent=n.credentials.length,h("#emptyServers").hidden=e.length>0,h("#emptyServers").textContent=n.servers.length?"No hosts with this tag.":"No managed servers yet. Import your SSH config or add one.",h("#emptyWorkspaceHosts").textContent=n.servers.length?"No hosts match this view.":"No managed hosts yet.",O(),R();const a=['<option value="">OpenSSH default / agent</option>'].concat(n.credentials.map(e=>`<option value="${m(e.id)}">${m(e.label)} (${m(e.kind)})</option>`));h("#serverCredential").innerHTML=a.join("");const s=[];s.push(`${n.auth.passkeys||0} passkey${1===n.auth.passkeys?"":"s"} enrolled`),s.push(n.auth.master_password_enrolled?"fallback master password enrolled":"no fallback master password"),h("#authDescription").textContent=s.join(" · "),h("#authStatusTitle").textContent=n.auth.passkeys>0?"Passkey active":n.auth.master_password_enrolled?"Fallback password active":"Protection setup required",h("#enrollPasskeyButton").disabled=!n.auth.webauthn_available||!window.PublicKeyCredential,h("#enrollMasterButton").disabled=n.auth.master_password_enrolled,T(),function(){const e=[...n.servers].sort((e,t)=>{const n=Date.parse(e.last_test_at||e.updated_at||e.created_at||0)||0;return(Date.parse(t.last_test_at||t.updated_at||t.created_at||0)||0)-n}).slice(0,3);document.querySelector(".recent-hosts-card").hidden=0===e.length,h("#recentHostRows").innerHTML=e.map(e=>{const t=w(e),n=e.tags?.[0];return`<button type="button" class="recent-host-row" data-id="${m(e.id)}"><span class="recent-host-icon" aria-hidden="true"></span><span class="recent-host-copy"><strong>${m(e.alias)}</strong><small>${m(e.username)}@${m(e.hostname)}</small></span>${n?`<span class="recent-host-tag">${m(n)}</span>`:""}<span class="recent-host-status ${t.tone}"><b><i></i>${m(t.short)}</b><small>${m(t.meta)}</small></span><span class="recent-host-arrow" aria-hidden="true">›</span></button>`}).join("")}(),I(),P(n.view),o&&o.then(e=>e.syncContextUI($())),skillModule&&skillModule.then(e=>e.syncSkillUI(skillOptions()))}()}function K(e=null){h("#serverDialogTitle").textContent=e?"Edit server":"Add server",h("#serverId").value=e?.id||"",h("#serverAlias").value=e?.alias||"",h("#serverHostname").value=e?.hostname||"",h("#serverPort").value=e?.port||22,h("#serverUsername").value=e?.username||"",h("#serverCredential").value=e?.credential_id||"",h("#serverProxy").value=e?.proxy_jumps.join(", ")||"",h("#serverTags").value=(e?.tags||[]).join(", "),h("#serverNotes").value=e?.notes||"",V(),h("#serverDialog").showModal(),C().then(e=>e.enhanceTagPicker({input:h("#serverTags"),getContexts:()=>n.contexts,toast:v,tagColor:tc})).catch(e=>v(e.message||String(e),!0))}function U(){const e=h("#serverCredentialNewKind").value,t="password"===e,n="key"===e;h("#serverCredentialNewSecretField").hidden=!t,h("#serverCredentialNewKeyPathField").hidden=!n,h("#serverCredentialNewPassphraseField").hidden=!n,h("#serverCredentialNewSecret").required=t,h("#serverCredentialNewKeyPath").required=n}function V(){h("#serverCredentialComposer").hidden=!0,h("#serverCredentialNewLabel").value="",h("#serverCredentialNewKind").value="password",h("#serverCredentialNewSecret").value="",h("#serverCredentialNewKeyPath").value="",h("#serverCredentialNewPassphrase").value="",U()}function G(){h("#serverCredentialComposer").hidden=!1,U(),h("#serverCredentialNewLabel").focus()}async function Q(){const e=h("#serverCredentialNewKind").value,t={label:h("#serverCredentialNewLabel").value.trim(),kind:e};if(!t.label)throw new Error("Credential label is required");if("password"===e&&(t.secret=h("#serverCredentialNewSecret").value,!t.secret))throw new Error("Password is required");if("key"===e){if(t.key_path=h("#serverCredentialNewKeyPath").value.trim(),!t.key_path)throw new Error("Private-key path is required");h("#serverCredentialNewPassphrase").value&&(t.passphrase=h("#serverCredentialNewPassphrase").value)}const n=await f("/api/credentials",{method:"POST",body:t});await q(),h("#serverCredential").value=n.id,V(),v("Credential created and selected. Save the server to finish.")}function W(){const e=h("#credentialKind").value;h("#secretField").hidden="password"!==e,h("#keyPathField").hidden="key"!==e,h("#passphraseField").hidden="key"!==e,h("#clearPassphraseField").hidden="key"!==e||!h("#credentialId").value}function z(e=null){const t=Boolean(e);h("#credentialDialogTitle").textContent=t?"Edit credential":"Add credential",h("#credentialId").value=e?.id||"",h("#credentialLabel").value=e?.label||"",h("#credentialKind").value=e?.kind||"password",h("#credentialKind").disabled=t,h("#credentialSecret").value="",h("#credentialSecret").required=!t&&"password"===h("#credentialKind").value,h("#credentialKeyPath").value=e?.key_path||"",h("#credentialPassphrase").value="",h("#credentialClearPassphrase").checked=!1,h("#credentialEditHint").hidden=!t,W(),h("#credentialDialog").showModal()}async function J(){const e=await f("/api/import/preview",{method:"POST",body:{}}),t=e.items.filter(e=>"add"===e.action).length,n=e.items.filter(e=>"conflict"===e.action).length;if(!confirm(`Import ${t} new host(s)? ${n} conflict(s) will be skipped.`))return;const a=await f("/api/import/apply",{method:"POST",body:{overwrite:!1}});v(`Imported ${a.added.length} host(s); skipped ${a.skipped.length}`),await q()}async function X(){const e=h("#sidebarContextName").value.trim();if(!e)return h("#sidebarContextName").focus();await f("/api/contexts",{method:"POST",body:{name:e}}),h("#sidebarContextName").value="",h("#sidebarContextForm").hidden=!0,await q(),v(`Tag “${e}” created`)}function Y(){const e=n.servers.map(e=>({alias:e.alias,hostname:e.hostname,port:e.port,username:e.username,credential:e.credential_label||null,proxy_jumps:e.proxy_jumps||[],tags:e.tags||[],notes:e.notes||""})),t=URL.createObjectURL(new Blob([JSON.stringify(e,null,2)],{type:"application/json"})),a=document.createElement("a");a.href=t,a.download="ssh-connections.json",a.click(),setTimeout(()=>URL.revokeObjectURL(t),0),v(`Exported ${e.length} connection${1===e.length?"":"s"} without secrets`)}function Z(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),n=atob(t.padEnd(4*Math.ceil(t.length/4),"="));return Uint8Array.from(n,e=>e.charCodeAt(0))}function ee(e){const t=new Uint8Array(e);let n="";return t.forEach(e=>{n+=String.fromCharCode(e)}),btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function te(e){return e.challenge=Z(e.challenge),e.user?.id&&(e.user.id=Z(e.user.id)),e.excludeCredentials&&e.excludeCredentials.forEach(e=>{e.id=Z(e.id)}),e.allowCredentials&&e.allowCredentials.forEach(e=>{e.id=Z(e.id)}),e}function ae(e){const t={};for(const n of["clientDataJSON","attestationObject","authenticatorData","signature","userHandle"])e.response[n]&&(t[n]=ee(e.response[n]));return e.response.getTransports&&(t.transports=e.response.getTransports()),{id:e.id,rawId:ee(e.rawId),type:e.type,response:t}}async function se(){const e=te(await f("/api/auth/passkey/register/options",{method:"POST"})),t=await navigator.credentials.create({publicKey:e});await f("/api/auth/passkey/register/verify",{method:"POST",body:ae(t)}),v("Passkey enrolled"),await q()}function ne(e){return new Promise((t,n)=>{l=t,d=n,h("#masterDialogTitle").textContent=e?"Set fallback master password":"Authenticate to reveal",h("#masterConfirmField").hidden=!e,h("#masterPasswordConfirm").required=e,h("#masterPassword").autocomplete=e?"new-password":"current-password",h("#masterPassword").value="",h("#masterPasswordConfirm").value="",h("#masterDialog").dataset.enrolling=e?"true":"false",h("#masterDialog").showModal(),h("#masterPassword").focus()})}function re(){h("#masterDialog").open&&h("#masterDialog").close(),d&&d(new Error("Authentication cancelled")),l=null,d=null,h("#masterPassword").value="",h("#masterPasswordConfirm").value=""}function ie(){clearInterval(i),h("#revealedSecret").textContent="",h("#revealDialog").open&&h("#revealDialog").close()}async function oe(e){try{await e()}catch(e){v(e.message||String(e),!0)}}document.addEventListener("DOMContentLoaded",()=>{history.replaceState({},"","/"),b(n.theme),tc(n.accent),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{"system"===n.theme&&b("system")}),oe(q),setInterval(N,3e4),h(".primary-nav").addEventListener("click",e=>{const t=e.target.closest(".nav-item");if(!t)return;const n=t.dataset.view;P(n),"contexts"!==n&&"servers"!==n&&"skills"!==n||oe(async()=>{"skills"===n?(await loadSkillsModule()).openSkillManager(skillOptions()):(await C())["contexts"===n?"openContextManager":"enhanceConnections"]($())})}),h("#refreshButton").addEventListener("click",()=>oe(q)),h("#addServerButton").addEventListener("click",()=>K()),h("#workspaceAddHeaderButton").addEventListener("click",()=>K()),h("#sidebarAddServerButton").addEventListener("click",()=>K()),h("#emptyAddServerButton").addEventListener("click",()=>K()),h("#addCredentialButton").addEventListener("click",()=>z()),h("#emptyChooseHostButton").addEventListener("click",()=>oe(async()=>{!x().length&&n.servers.length&&L("*");const e=document.querySelector(".recent-host-row");e&&await A(e.dataset.id)})),h("#emptyImportButton").addEventListener("click",()=>oe(J)),h("#viewAllHostsButton").addEventListener("click",()=>{P("servers"),oe(async()=>(await C()).enhanceConnections($()))}),h("#recentHostRows").addEventListener("click",e=>{const t=e.target.closest(".recent-host-row");t&&oe(()=>A(t.dataset.id))}),h("#themeSelect").addEventListener("change",()=>b(h("#themeSelect").value,!0)),h("#accentSelect").addEventListener("change",()=>tc(h("#accentSelect").value,!0)),h("#hostContextSelect").addEventListener("change",()=>L(h("#hostContextSelect").value)),h("#hostSearchInput").addEventListener("input",()=>{n.hostQuery=h("#hostSearchInput").value,cancelAnimationFrame(p),p=requestAnimationFrame(()=>{p=0,T()})}),h("#workspaceHostList").addEventListener("click",e=>{const t=e.target.closest("[data-sidebar-filter-context]");if(t)return L(t.dataset.sidebarFilterContext);const n=e.target.closest("[data-sidebar-contexts]");if(n)return oe(async()=>{(await C()).openHostContextPicker($(),n.dataset.id)});const a=e.target.closest(".workspace-host");a&&A(a.dataset.id)}),h("#sidebarContextList").addEventListener("click",e=>{const t=e.target.closest("[data-sidebar-filter-context]");t&&L(t.dataset.sidebarFilterContext===n.hostContext?"*":t.dataset.sidebarFilterContext)}),h("#sidebarAddContextButton").addEventListener("click",()=>{h("#sidebarContextForm").hidden=!h("#sidebarContextForm").hidden,h("#sidebarContextForm").hidden||h("#sidebarContextName").focus()}),h("#sidebarContextForm").addEventListener("submit",e=>{e.preventDefault(),oe(X)}),h("#sidebarEditContextsButton").addEventListener("click",()=>oe(async()=>(await C()).openContextManager($()))),h("#createContextButton").addEventListener("click",()=>oe(async()=>{const e=await C();e.openContextManager($()),e.focusContextCreation()})),h("#fileServerSelect").addEventListener("change",()=>A(h("#fileServerSelect").value)),h("#fileHomeButton").addEventListener("click",()=>D("~")),h("#fileUpButton").addEventListener("click",()=>{n.files.parent&&D(n.files.parent)}),h("#fileRefreshButton").addEventListener("click",()=>D(n.files.path||"~")),h("#fileRetryButton").addEventListener("click",()=>D(n.files.requestedPath||"~")),h("#fileShowHidden").addEventListener("change",()=>{n.files.showHidden=h("#fileShowHidden").checked,n.files.visibleLimit=250,I()}),h("#fileSearchInput").addEventListener("input",()=>{n.files.query=h("#fileSearchInput").value,n.files.visibleLimit=250,cancelAnimationFrame(u),u=requestAnimationFrame(()=>{u=0,I()})}),h("#filePathForm").addEventListener("submit",e=>{e.preventDefault(),D(h("#filePathInput").value||"~")}),h("#fileBreadcrumbs").addEventListener("click",e=>{const t=e.target.closest(".breadcrumb-button");t&&D(t.dataset.path)}),h("#fileRows").addEventListener("click",e=>{const t=e.target.closest(".open-directory"),n=e.target.closest(".copy-file-reference");t&&D(t.dataset.path),n&&oe(()=>j(n.dataset.path))}),h("#fileTableWrap").addEventListener("click",e=>{const t=e.target.closest(".sort-button");t&&(n.files.sortBy===t.dataset.sort?n.files.sortDirection="asc"===n.files.sortDirection?"desc":"asc":(n.files.sortBy=t.dataset.sort,n.files.sortDirection="asc"),n.files.visibleLimit=250,I())}),h("#fileLoadMoreButton").addEventListener("click",()=>{n.files.visibleLimit+=250,I()}),h("#copyDirectoryReferenceButton").addEventListener("click",()=>oe(()=>j(n.files.path))),h("#copyVisibleReferencesButton").addEventListener("click",()=>oe(F)),h("#workspaceTestButton").addEventListener("click",()=>oe(async()=>{const e=n.files.serverId;if(e){h("#workspaceTestButton").disabled=!0;try{const t=await f(`/api/servers/${e}/test`,{method:"POST"});v(t.ok?`Connected in ${t.latency_ms} ms`:t.message,!t.ok),await q()}finally{h("#workspaceTestButton").disabled=!1}}})),h("#workspaceCopyCommandButton").addEventListener("click",()=>oe(async()=>{const e=n.servers.find(e=>e.id===n.files.serverId);e&&(await navigator.clipboard.writeText(`serverctl connect ${e.alias}`),v("SSH command copied"))})),h("#workspaceEditButton").addEventListener("click",()=>{const e=n.servers.find(e=>e.id===n.files.serverId);e&&K(e)}),h("#newServerCredentialButton").addEventListener("click",G),h("#cancelServerCredentialButton").addEventListener("click",V),h("#serverCredentialNewKind").addEventListener("change",U),h("#saveServerCredentialButton").addEventListener("click",()=>oe(Q)),h("#credentialKind").addEventListener("change",()=>{W(),h("#credentialSecret").required=!h("#credentialId").value&&"password"===h("#credentialKind").value}),h("#credentialSearchInput").addEventListener("input",()=>{n.credentialQuery=h("#credentialSearchInput").value,R()}),h("#credentialKindFilter").addEventListener("change",()=>{n.credentialKind=h("#credentialKindFilter").value,R()}),document.querySelectorAll(".close-dialog").forEach(e=>e.addEventListener("click",()=>e.closest("dialog").close())),document.querySelectorAll(".close-reveal").forEach(e=>e.addEventListener("click",ie)),document.querySelectorAll(".cancel-master").forEach(e=>e.addEventListener("click",re)),h("#masterDialog").addEventListener("cancel",e=>{e.preventDefault(),re()}),h("#masterForm").addEventListener("submit",e=>{e.preventDefault();const t=h("#masterPassword").value;if("true"===h("#masterDialog").dataset.enrolling&&t!==h("#masterPasswordConfirm").value)return void v("Master passwords do not match",!0);h("#masterDialog").close(),h("#masterPassword").value="",h("#masterPasswordConfirm").value="";const n=l;l=null,d=null,n(t)}),h("#serverForm").addEventListener("submit",e=>{e.preventDefault(),oe(async()=>{const e=h("#serverId").value,t={alias:h("#serverAlias").value,hostname:h("#serverHostname").value,port:Number(h("#serverPort").value),username:h("#serverUsername").value,credential_id:h("#serverCredential").value||null,proxy_jumps:h("#serverProxy").value.split(",").map(e=>e.trim()).filter(Boolean),tags:h("#serverTags").value.split(",").map(e=>e.trim()).filter(Boolean),notes:h("#serverNotes").value};await f(e?`/api/servers/${e}`:"/api/servers",{method:e?"PUT":"POST",body:t}),h("#serverDialog").close(),v(e?"Server updated":"Server added"),await q()})}),h("#credentialForm").addEventListener("submit",e=>{e.preventDefault(),oe(async()=>{const e=h("#credentialId").value,t=h("#credentialKind").value,n={label:h("#credentialLabel").value,kind:t};"password"===t&&h("#credentialSecret").value&&(n.secret=h("#credentialSecret").value),"key"===t&&(n.key_path=h("#credentialKeyPath").value,h("#credentialPassphrase").value&&(n.passphrase=h("#credentialPassphrase").value),e&&h("#credentialClearPassphrase").checked&&(n.clear_passphrase=!0)),await f(e?`/api/credentials/${e}`:"/api/credentials",{method:e?"PUT":"POST",body:n}),h("#credentialSecret").value="",h("#credentialPassphrase").value="",h("#credentialDialog").close(),v(e?"Credential updated":"Credential added"),await q()})}),h("#serverRows").addEventListener("click",e=>oe(async()=>{const t=e.target.closest("button");if(t)if(t.classList.contains("browse-files"))await A(t.dataset.id);else if(t.classList.contains("manage-server-skills"))(await loadSkillsModule()).openHostSkillPicker(skillOptions(),t.dataset.id);else if(t.classList.contains("test-server")){t.disabled=!0;const e=await f(`/api/servers/${t.dataset.id}/test`,{method:"POST"});v(e.ok?`Connected in ${e.latency_ms} ms`:e.message,!e.ok),await q()}else if(t.classList.contains("copy-command"))await navigator.clipboard.writeText(`serverctl connect ${t.dataset.alias}`),v("Connection command copied");else if(t.classList.contains("edit-server"))K(n.servers.find(e=>e.id===t.dataset.id));else if(t.classList.contains("delete-server")){const e=n.servers.find(e=>e.id===t.dataset.id);confirm(`Delete server ${e.alias}?`)&&(await f(`/api/servers/${t.dataset.id}`,{method:"DELETE"}),v("Server deleted"),await q())}})),h("#credentialRows").addEventListener("click",e=>oe(async()=>{const t=e.target.closest("button");if(t)if(t.classList.contains("copy-key-path"))await navigator.clipboard.writeText(t.dataset.path),v("Key path copied");else if(t.classList.contains("reveal-credential"))await async function(e){if(n.auth.passkeys>0&&window.PublicKeyCredential)try{const t=te(await f("/api/auth/reveal/options",{method:"POST",body:{credential_id:e}})),n=await navigator.credentials.get({publicKey:t});return void await f("/api/auth/reveal/verify",{method:"POST",body:{credential_id:e,response:ae(n)}})}catch(e){if(!n.auth.master_password_enrolled)throw e;v("Passkey was unavailable; use the fallback master password",!0)}if(!n.auth.master_password_enrolled)throw new Error("Enroll a passkey or fallback master password first");const t=await ne(!1);await f("/api/auth/master/verify",{method:"POST",body:{credential_id:e,password:t}})}(t.dataset.id),function(e){clearInterval(i);let t=15;h("#revealedSecret").textContent=e,h("#revealCountdown").textContent=t,h("#revealDialog").showModal(),i=setInterval(()=>{t-=1,h("#revealCountdown").textContent=t,t<=0&&ie()},1e3)}((await f(`/api/credentials/${t.dataset.id}/reveal`,{method:"POST"})).value);else if(t.classList.contains("edit-credential"))z(n.credentials.find(e=>e.id===t.dataset.id));else if(t.classList.contains("delete-credential")){const e=n.credentials.find(e=>e.id===t.dataset.id);confirm(`Delete credential ${e.label}? Stored secrets will also be removed.`)&&(await f(`/api/credentials/${t.dataset.id}`,{method:"DELETE"}),v("Credential deleted"),await q())}})),h("#importButton").addEventListener("click",()=>oe(J)),h("#exportConnectionsButton").addEventListener("click",Y),h("#enrollPasskeyButton").addEventListener("click",()=>oe(se)),h("#enrollMasterButton").addEventListener("click",()=>oe(async()=>{const e=await ne(!0);await f("/api/auth/master/enroll",{method:"POST",body:{password:e}}),v("Fallback master password enrolled"),await q()})),h("#copySecretButton").addEventListener("click",()=>oe(async()=>{await navigator.clipboard.writeText(h("#revealedSecret").textContent),v("Secret copied to clipboard")})),document.addEventListener("keydown",e=>{const t=e.target,a=t instanceof HTMLInputElement||t instanceof HTMLTextAreaElement||t instanceof HTMLSelectElement;"/"===e.key&&"workspace"===n.view&&n.files.serverId&&!a&&(e.preventDefault(),h("#fileSearchInput").focus())})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html lang=en><meta charset=utf-8><meta content="width=device-width,initial-scale=1" name=viewport><title>SSH Server Manager</title><link href=/assets/styles.css rel=stylesheet><script defer src=/assets/app.js></script><div class=app-shell><aside class=sidebar><div class=brand><span class=brand-mark aria-hidden=true><svg viewBox="0 0 24 24"><path d="M7 8l4 4-4 4"/><path d="M13 16h4"/></svg></span><div><strong>SSH Manager</strong><span>Local control plane</span></div></div><nav aria-label="Primary navigation" class=primary-nav><button type=button class="nav-item active" data-view=workspace aria-current=page><svg viewBox="0 0 24 24" aria-hidden=true><path d="M3 6.5h6l2 2h10v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 9h18"/></svg><span>Workspace</span></button><button type=button class=nav-item data-view=servers><svg viewBox="0 0 24 24" aria-hidden=true><rect height=6 rx=2 width=16 x=4 y=4 /><rect height=6 rx=2 width=16 x=4 y=14 /><path d="M8 7h.01M8 17h.01"/></svg><span>Connections</span><span class=nav-count id=navServerCount>0</span></button><button type=button class=nav-item data-view=contexts><svg viewBox="0 0 24 24" aria-hidden=true><path d="M4 7h10l6 5-6 5H4z"/><circle cx=8 cy=12 r=1 /></svg><span>Tags</span><span class=nav-count id=navContextCount>0</span></button><button type=button class=nav-item data-view=skills><svg viewBox="0 0 24 24" aria-hidden=true><path d="M12 3l2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7z"/><path d="M12 8v3"/></svg><span>Skill Library</span><span class=nav-count id=navSkillCount>0</span></button><button type=button class=nav-item data-view=credentials><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=8 cy=15 r=4 /><path d="M11 12l8-8M16 7l2 2M14 9l2 2"/></svg><span>Credentials</span><span class=nav-count id=navCredentialCount>0</span></button></nav><section class=host-navigation id=hostNavigation aria-labelledby=hostNavigationTitle><div class=sidebar-section-heading><span id=hostNavigationTitle>Hosts</span><button type=button class=sidebar-icon-button id=sidebarAddServerButton aria-label="Add server" title="Add server">+</button></div><label class=sidebar-search><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=hostSearchInput type=search autocomplete=off aria-label="Filter hosts" placeholder="Filter hosts"></label><div class=workspace-host-list id=workspaceHostList></div><p class=sidebar-empty id=emptyWorkspaceHosts>No hosts match this view.</section><section class=label-navigation id=labelNavigation aria-labelledby=labelNavigationTitle><div class=sidebar-section-heading><span id=labelNavigationTitle>Tags</span><span class=sidebar-heading-actions><button type=button class=sidebar-text-button id=sidebarEditContextsButton>Edit</button><button type=button class=sidebar-icon-button id=sidebarAddContextButton aria-label="Create tag" title="Create tag">+</button></span></div><form id=sidebarContextForm class=sidebar-context-form hidden><input id=sidebarContextName autocomplete=off placeholder="New tag name" aria-label="New tag name" maxlength=40><button type=submit aria-label="Create tag">Add</button></form><div class=sidebar-context-list id=sidebarContextList></div><p class=sidebar-empty id=emptySidebarContexts>No tags yet.</section><footer class=sidebar-footer><div class=session-status id=systemStatus><span></span><div><strong>Local session</strong><small>Starting…</small></div></div><button type=button class=sidebar-icon-button id=refreshButton aria-label="Refresh data" title="Refresh data"><svg viewBox="0 0 24 24" aria-hidden=true><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M6.1 9a7 7 0 0 1 11.6-2.6L20 11M4 13l2.3 4.6A7 7 0 0 0 17.9 15"/></svg></button></footer></aside><div class=app-content><header class=view-header><div class=view-heading><p id=viewEyebrow>REMOTE WORKSPACE<h1 id=viewTitle>Workspace</h1><span id=viewDescription>Browse and reference remote files without opening a shell.</span></div><div class=view-actions><label id=contextFilterControl class=context-control><span class=sr-only>Host tag</span><select id=hostContextSelect aria-label="Filter hosts by tag"><option value=*>All hosts</select></label><label class=theme-control><span class=sr-only>Color theme</span><select id=themeSelect aria-label="Color theme"><option value=system>System theme<option value=light>Light theme<option value=dark>Dark theme<option value=contrast>High contrast</select></label><label class=theme-control><span class=sr-only>Accent color</span><select id=accentSelect aria-label="Accent color"><option value=indigo>Indigo<option value=teal>Teal<option value=emerald>Emerald<option value=amber>Amber<option value=rose>Rose<option value=violet>Violet<option value=graphite>Graphite</select></label><div class=header-action-group id=workspaceHeaderActions hidden><span class=header-action-group id=workspaceActiveActions hidden><button type=button class="button subtle-button" id=workspaceTestButton>Test</button><button type=button class="button subtle-button" id=workspaceCopyCommandButton>Copy SSH command</button><button type=button class="button secondary" id=workspaceEditButton>Edit host</button></span><button type=button class="button primary-button" id=workspaceAddHeaderButton><span aria-hidden=true>+</span> Add host</button></div><div class=header-action-group id=serverHeaderActions hidden><button type=button class="button subtle-button" id=importButton>Import config</button><button type=button class="button primary-button" id=addServerButton><span aria-hidden=true>+</span> Add host</button></div><div class=header-action-group id=contextHeaderActions hidden><button type=button class="button primary-button" id=createContextButton><span aria-hidden=true>+</span> Create tag</button></div><div class=header-action-group id=credentialHeaderActions hidden><button type=button class="button primary-button" id=addCredentialButton>Add credential <span aria-hidden=true>⌄</span></button></div></div></header><main class=view-stage><section class="view-panel workspace-view" id=fileBrowserPanel data-view-panel=workspace aria-labelledby=viewTitle><div class=mobile-host-controls><label>Host<select id=fileServerSelect aria-label="Host to browse" disabled><option value="">Choose a host…</select></label></div><div class=workspace-zero-state id=fileBrowserEmpty><div class=workspace-empty-hero><span class=empty-terminal aria-hidden=true><i></i><svg viewBox="0 0 64 64"><path d="M17 20l14 12-14 12M34 44h13"/></svg></span><h2>No active host</h2><p>Select a host from the left sidebar to access its workspace.</div><div class=workspace-start-actions><button type=button class=start-card id=emptyChooseHostButton><span class=start-icon><svg viewBox="0 0 24 24"><path d="M4 6h6l2 2h8v10H4z"/><path d="M8 12h8"/></svg></span><span><strong>Open recent host</strong><small>Quickly access a recent connection</small></span><b aria-hidden=true>›</b></button><button type=button class=start-card id=emptyAddServerButton><span class="start-icon plus">+</span><span><strong>Add host</strong><small>Connect to a new server</small></span><b aria-hidden=true>›</b></button><button type=button class=start-card id=emptyImportButton><span class=start-icon><svg viewBox="0 0 24 24"><path d="M12 16V4M8 8l4-4 4 4"/><path d="M5 14v5h14v-5"/></svg></span><span><strong>Import config</strong><small>Import SSH config or a host list</small></span><b aria-hidden=true>›</b></button></div><section class=recent-hosts-card><header><h3>Recent hosts</h3></header><div class=recent-host-rows id=recentHostRows></div><button type=button class=recent-hosts-more id=viewAllHostsButton>View all hosts <span aria-hidden=true>↗</span></button></section></div><div class=remote-browser id=fileBrowserContent hidden><div class=remote-host-bar><span class=remote-host-icon aria-hidden=true><svg viewBox="0 0 24 24"><rect height=14 rx=3 width=16 x=4 y=5 /><path d="M8 10l3 2-3 2M13 15h3"/></svg></span><div class=file-connection-label id=fileConnectionLabel></div><span class="compact-status neutral" id=fileHostStatus><i></i>Not tested</span></div><div class=host-skills-mount id=hostSkillsMount></div><div class=file-command-bar><div class=file-nav-buttons><button type=button class=toolbar-button id=fileHomeButton aria-label="Home directory" title="Home directory"><svg viewBox="0 0 24 24"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10M9 20v-6h6v6"/></svg></button><button type=button class=toolbar-button id=fileUpButton aria-label="Parent directory" title="Parent directory" disabled><svg viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></svg></button></div><nav aria-label="Remote path" class=file-breadcrumbs id=fileBreadcrumbs></nav><div class=file-command-actions><button type=button class="toolbar-button text-toolbar-button" id=copyDirectoryReferenceButton disabled><svg viewBox="0 0 24 24"><rect height=11 rx=2 width=11 x=8 y=8 /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span>Copy reference</span></button><button type=button class="toolbar-button text-toolbar-button" id=fileRefreshButton aria-label="Refresh directory" title="Refresh directory"><svg viewBox="0 0 24 24"><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M6.1 9a7 7 0 0 1 11.6-2.6L20 11M4 13l2.3 4.6A7 7 0 0 0 17.9 15"/></svg><span>Refresh</span></button></div></div><form id=filePathForm class=file-path-form><label><span>Path</span><input id=filePathInput autocomplete=off placeholder=/home/user/project spellcheck=false></label><button type=submit class=button>Go</button></form><div class=file-filter-bar><label class=file-search><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=fileSearchInput type=search autocomplete=off aria-label="Filter files" placeholder="Filter this folder"></label><label class=show-hidden-toggle><input id=fileShowHidden type=checkbox> Show hidden</label><button type=button class="toolbar-button text-toolbar-button" id=copyVisibleReferencesButton disabled>Copy visible</button><span class=file-list-status id=fileListStatus aria-live=polite role=status></span></div><div class=file-browser-error id=fileBrowserError hidden role=alert><span class=error-icon aria-hidden=true>!</span><div><strong>Could not load this directory</strong><span id=fileBrowserErrorMessage></span></div><button type=button class="button secondary small" id=fileRetryButton>Retry</button></div><div class=file-loading id=fileLoading hidden aria-label="Loading remote files"><span></span><span></span><span></span></div><div class=file-table-wrap id=fileTableWrap hidden><table class=file-table><thead><tr><th><button type=button class="sort-button active" data-sort=name>Name <span aria-hidden=true>↑</span></button><th><button type=button class=sort-button data-sort=size>Size <span aria-hidden=true></span></button><th class=file-modified-column><button type=button class=sort-button data-sort=modified>Modified <span aria-hidden=true></span></button><th class=file-permissions-column>Permissions<th><span class=sr-only>Actions</span><tbody id=fileRows></table></div><div class=file-list-more id=fileListMore hidden><span id=fileListMoreStatus></span><button type=button class="button secondary small" id=fileLoadMoreButton>Show more</button></div><div class=file-list-empty id=fileListEmpty hidden><strong id=fileListEmptyTitle>This directory is empty</strong><span id=fileListEmptyMessage>There is nothing to display here.</span></div></div></section><section class="view-panel management-view" id=serversView data-view-panel=servers hidden><div class=connection-summary aria-label="Connection overview"><article><span class="summary-icon hosts"><svg viewBox="0 0 24 24"><rect height=6 rx=2 width=14 x=5 y=4 /><rect height=6 rx=2 width=14 x=5 y=14 /><path d="M9 7h.01M9 17h.01"/></svg></span><div><small>Total hosts</small><strong id=summaryTotalHosts>0</strong><p>Across all tags</div></article><article><span class="summary-icon reachable"><svg viewBox="0 0 24 24"><path d="M12 3l8 4v5c0 5-3.4 8.2-8 9-4.6-.8-8-4-8-9V7z"/><path d="M9 12l2 2 4-5"/></svg></span><div><small>Reachable hosts</small><strong id=summaryReachableHosts>0</strong><p id=summaryReachablePercent>No fresh checks</div></article><article><span class="summary-icon credentials"><svg viewBox="0 0 24 24"><circle cx=8 cy=15 r=4 /><path d="M11 12l8-8M16 7l2 2"/></svg></span><div><small>Credentials</small><strong id=summaryCredentialCount>0</strong><p>Stored securely</div></article><article><span class="summary-icon contexts"><svg viewBox="0 0 24 24"><path d="M12 3l8 4-8 4-8-4zM4 12l8 4 8-4M4 17l8 4 8-4"/></svg></span><div><small>Tags</small><strong id=summaryContextCount>0</strong><p>Host labels</div></article></div><div class=connection-toolbar-mount id=connectionToolbarMount></div><div class=management-surface><div class=surface-heading><div><h2>Managed connections <span><b id=serverCount>0</b> hosts</span></h2><p class=sr-only>Select hosts to test or organize them in bulk.</div><div class=connection-meta><span class=sr-only><b id=credentialCount>0</b> credentials</span><code id=configPath>—</code><button type=button class="button secondary small" id=exportConnectionsButton><span aria-hidden=true>⇩</span> Export</button></div></div><div class=table-wrap><table class="management-table connection-table"><thead><tr><th>Alias<th>Endpoint<th>Tags<th>Credential<th>Route / proxy<th>Status<th><span class=sr-only>Actions</span><tbody id=serverRows></table></div><p class=empty id=emptyServers>No managed servers yet. Import your SSH config or add one.</div></section><section class="view-panel context-view" id=contextsView data-view-panel=contexts hidden><div id=contextManagerMount aria-live=polite>Loading tags…</div></section><section class="view-panel skills-view" id=skillsView data-view-panel=skills hidden><div id=skillManagerMount aria-live=polite>Loading Agent Skills…</div></section><section class="view-panel management-view" id=credentialsView data-view-panel=credentials hidden><div class=security-note><span aria-hidden=true><svg viewBox="0 0 24 24"><path d="M12 3l8 4v5c0 5-3.4 8.2-8 9-4.6-.8-8-4-8-9V7z"/><path d="M9 12l2 2 4-5"/></svg></span><div class=security-copy><strong>Credentials are encrypted and never shared.</strong><p>Secrets remain in your operating-system vault and never enter browser storage.</div></div><div class=management-surface><div class="surface-heading credential-heading"><div><h2>Credential profiles</h2><p><span id=credentialProfileCount>0</span> credentials</div><div class=credential-tools><label class=surface-search><svg viewBox="0 0 24 24"><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=credentialSearchInput type=search autocomplete=off aria-label="Search credentials" placeholder="Search credentials…"></label><select id=credentialKindFilter aria-label="Filter credential type"><option value=*>All types<option value=password>Password<option value=key>Key<option value=agent>OpenSSH / agent</select></div></div><div class=table-wrap><table class="management-table credential-table"><thead><tr><th>Name<th>Type<th>Status<th>Secret state<th>Key path<th>Last checked<th><span class=sr-only>Actions</span><tbody id=credentialRows></table></div><p class=empty id=emptyCredentials>No credential profiles configured.</div><section class=reauth-card><header><div><h2>Local reauthentication</h2><p>Unlock local credentials before revealing a stored secret.</div><div class=row-actions><button class="button secondary small" id=enrollPasskeyButton>Enroll passkey</button><button class="button secondary small" id=enrollMasterButton>Set fallback password</button></div></header><div class=reauth-status><span aria-hidden=true>✓</span><div><strong id=authStatusTitle>Checking protection…</strong><small id=authDescription>Checking protection…</small></div></div></section></section></main></div></div><div id=toast aria-live=polite role=status></div><dialog id=serverDialog><form id=serverForm method=dialog><div class=dialog-heading><h2 id=serverDialogTitle>Add server</h2><button type=button class="close-dialog icon-button" aria-label=Close>×</button></div><input id=serverId type=hidden><label>Alias<input id=serverAlias required pattern=[A-Za-z0-9][A-Za-z0-9._-]{0,62}></label><div class=two-columns><label>Hostname or IP<input id=serverHostname required></label><label>Port<input id=serverPort type=number required max=65535 min=1 value=22></label></div><label>Username<input id=serverUsername required></label><label>Credential<div class=credential-picker><select id=serverCredential><option value="">OpenSSH default / agent</select><button type=button class="secondary small" id=newServerCredentialButton>+ New</button></div></label><fieldset class=inline-composer hidden id=serverCredentialComposer><legend>New credential</legend><p class=hint>Create it here and it will be selected for this server automatically.</p><label>Label<input id=serverCredentialNewLabel autocomplete=off maxlength=100></label><label>Authentication type<select id=serverCredentialNewKind><option value=password>Password<option value=key>Private key file<option value=agent>OpenSSH default / ssh-agent</select></label><label id=serverCredentialNewSecretField>Password<input id=serverCredentialNewSecret type=password autocomplete=new-password></label><label id=serverCredentialNewKeyPathField hidden>Private-key path<input id=serverCredentialNewKeyPath placeholder=~/.ssh/id_ed25519></label><label id=serverCredentialNewPassphraseField hidden>Key passphrase (optional)<input id=serverCredentialNewPassphrase type=password autocomplete=new-password></label><div class=inline-composer-actions><button type=button class=secondary id=cancelServerCredentialButton>Cancel</button><button type=button id=saveServerCredentialButton>Save and use</button></div></fieldset><label>ProxyJump aliases<input id=serverProxy placeholder="jump-one, jump-two"></label><label>Tags<input id=serverTags placeholder="production, client-a" maxlength=839></label><label>Notes<textarea id=serverNotes rows=3></textarea></label><div class=dialog-actions><button type=button class="secondary close-dialog">Cancel</button><button type=submit>Save server</button></div></form></dialog><dialog id=credentialDialog><form id=credentialForm method=dialog><div class=dialog-heading><h2 id=credentialDialogTitle>Add credential</h2><button type=button class="close-dialog icon-button" aria-label=Close>×</button></div><input id=credentialId type=hidden><label>Label<input id=credentialLabel required maxlength=100></label><label>Authentication type<select id=credentialKind><option value=password>Password<option value=key>Private key file<option value=agent>OpenSSH default / ssh-agent</select></label><label id=secretField>Password<input id=credentialSecret type=password autocomplete=new-password></label><label id=keyPathField hidden>Private-key path<input id=credentialKeyPath placeholder=~/.ssh/id_ed25519></label><label id=passphraseField hidden>Key passphrase (optional)<input id=credentialPassphrase type=password autocomplete=new-password></label><label id=clearPassphraseField hidden class=checkbox-label><input id=credentialClearPassphrase type=checkbox> Remove the stored passphrase</label><p class=hint id=credentialEditHint hidden>Leave secret fields empty to keep their existing values.<div class=dialog-actions><button type=button class="secondary close-dialog">Cancel</button><button type=submit>Save credential</button></div></form></dialog><dialog id=revealDialog><div class=dialog-heading><h2>Revealed secret</h2><button type=button class="icon-button close-reveal" aria-label=Close>×</button></div><p>This value will be cleared from the page in <strong id=revealCountdown>15</strong> seconds.<div class=secret-box><code id=revealedSecret></code><button class=secondary id=copySecretButton>Copy</button></div><div class=dialog-actions><button type=button class="secondary close-reveal">Close now</button></div></dialog><dialog id=masterDialog><form id=masterForm method=dialog><div class=dialog-heading><h2 id=masterDialogTitle>Local authentication</h2><button type=button class="icon-button cancel-master" aria-label=Close>×</button></div><label>Master password<input id=masterPassword type=password autocomplete=current-password minlength=12 required></label><label id=masterConfirmField hidden>Confirm master password<input id=masterPasswordConfirm type=password autocomplete=new-password minlength=12></label><div class=dialog-actions><button type=button class="secondary cancel-master">Cancel</button><button type=submit>Authenticate</button></div></form></dialog>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.skills-view{overflow:auto}.sk-library{width:min(1180px,100%);margin:0 auto;padding-bottom:28px}.sk-library button{background:var(--accent);min-height:38px;color:var(--on-accent);border:1px solid #0000;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:750}.sk-library button:hover{background:var(--accent-hover)}.sk-library button.secondary,.sk-dialog-actions button.secondary,.sk-discovered-row button.secondary,.sk-conflict button.secondary{border-color:var(--line-strong);color:var(--ink);background:0 0}.sk-library button.secondary:hover,.sk-dialog-actions button.secondary:hover,.sk-discovered-row button.secondary:hover,.sk-conflict button.secondary:hover{background:var(--control-hover)}.sk-intro{border:1px solid var(--accent-border);background:linear-gradient(135deg, var(--accent-soft), var(--surface));border-radius:14px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:18px;margin-bottom:16px;padding:18px 20px;display:grid}.sk-intro-icon,.sk-spark,.sk-skill-mark{color:var(--accent)}.sk-intro-icon{border:1px solid var(--accent-border);background:var(--surface);border-radius:12px;place-items:center;width:44px;height:44px;font-size:21px;display:grid}.sk-intro strong{margin-bottom:5px;font-size:15px;display:block}.sk-intro p,.sk-library-toolbar p{color:var(--muted);margin:0;line-height:1.5}.sk-intro-actions{gap:8px;display:flex}.sk-register-form{border:1px solid var(--line);background:var(--surface);border-radius:12px;grid-template-columns:minmax(240px,1fr) auto auto;align-items:end;gap:10px;margin-bottom:16px;padding:14px;display:grid}.sk-register-form label,.sk-search{gap:6px;display:grid}.sk-register-form label>span{color:var(--muted);letter-spacing:.04em;text-transform:uppercase;font-size:11px;font-weight:750}.sk-register-form input,.sk-search input{border:1px solid var(--line-strong);background:var(--surface);width:100%;min-height:40px;color:var(--ink);border-radius:8px;padding:8px 11px}.sk-discovery{border:1px solid var(--line);background:var(--surface);border-radius:12px;margin-bottom:16px;overflow:hidden}.sk-discovery>header,.sk-library-toolbar{justify-content:space-between;align-items:center;gap:16px;display:flex}.sk-discovery>header{border-bottom:1px solid var(--line);padding:13px 16px}.sk-discovery>header strong,.sk-discovery>header span{display:block}.sk-discovery>header span{color:var(--muted);margin-top:3px;font-size:12px}.sk-icon-button{place-items:center;width:34px;min-width:34px;display:grid;border-color:var(--line)!important;min-height:34px!important;color:var(--muted)!important;background:0 0!important;padding:0!important;font-size:19px!important}.sk-icon-button:hover{background:var(--control-hover)!important;color:var(--ink)!important}.sk-icon-button.danger:hover{border-color:var(--danger-border)!important;background:var(--danger-soft)!important;color:var(--danger)!important}.sk-discovered-row{border-bottom:1px solid var(--line);grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:12px;padding:12px 16px;display:grid}.sk-discovered-row:last-child{border-bottom:0}.sk-discovered-row strong,.sk-discovered-row small,.sk-discovered-row code{display:block}.sk-discovered-row small{color:var(--muted);margin:3px 0 5px}.sk-discovered-row code,.sk-conflict code,.sk-skill-copy code{color:var(--faint);overflow-wrap:anywhere;font-size:11px}.sk-conflict{border:1px solid var(--warning-border);background:var(--warning-soft);border-radius:10px;margin:12px;padding:12px}.sk-conflict header{color:var(--warning);justify-content:space-between;gap:12px;margin-bottom:8px;display:flex}.sk-conflict>div{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding-top:8px;display:grid}.sk-discovery-empty{color:var(--muted);margin:0;padding:18px}.sk-summary{grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px;display:grid}.sk-summary article{border:1px solid var(--line);background:var(--surface);border-radius:11px;padding:14px 16px}.sk-summary small,.sk-summary strong{display:block}.sk-summary small,.sk-hosts>small{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;font-size:10px;font-weight:800}.sk-summary strong{margin-top:7px;font-size:24px;line-height:1}.sk-library-surface{border:1px solid var(--line);background:var(--surface);border-radius:14px;overflow:hidden}.sk-library-toolbar{border-bottom:1px solid var(--line);padding:16px 18px}.sk-library-toolbar h2{margin-bottom:4px;font-size:16px}.sk-search{width:min(330px,42vw)}.sk-skill-card{border-bottom:1px solid var(--line);grid-template-columns:auto minmax(260px,1fr) minmax(220px,.55fr) auto;align-items:center;gap:14px;padding:16px 18px;display:grid}.sk-skill-card:hover{background:var(--row-hover)}.sk-skill-card.has-warning{box-shadow:inset 3px 0 var(--warning)}.sk-skill-mark{background:var(--accent-soft);border-radius:9px;place-items:center;width:34px;height:34px;font-size:16px;display:grid}.sk-skill-copy header{align-items:center;gap:8px;margin-bottom:5px;display:flex}.sk-skill-copy h3{margin:0;font-size:14px}.sk-skill-copy p{color:var(--muted);margin:0 0 7px;line-height:1.45}.sk-status{border:1px solid var(--line-strong);background:var(--control);width:fit-content;color:var(--muted);white-space:nowrap;border-radius:999px;padding:3px 7px;font-size:10px;font-style:normal;font-weight:800;line-height:1.2;display:inline-flex}.sk-status.ready,.sk-status.available,.sk-status.registered{border-color:var(--success-border);background:var(--success-soft);color:var(--success)}.sk-status.missing,.sk-status.name_mismatch,.sk-status.conflict{border-color:var(--warning-border);background:var(--warning-soft);color:var(--warning)}.sk-status.invalid{border-color:var(--danger-border);background:var(--danger-soft);color:var(--danger)}.sk-hosts>div,.sk-host-skill-chips{flex-wrap:wrap;gap:5px;margin:7px 0 8px;display:flex}.sk-host-chip,.sk-host-more,.sk-host-skill-chip{border:1px solid var(--accent-border);background:var(--accent-soft);color:var(--ink);border-radius:999px;padding:3px 7px;font-size:10px;font-weight:700}.sk-no-hosts,.sk-host-skill-none{color:var(--faint);font-size:11px}.sk-hosts button{min-height:auto;color:var(--accent);background:0 0;padding:0}.sk-hosts button:hover{color:var(--accent-hover);background:0 0}.sk-card-actions{justify-content:flex-end;align-items:center;gap:7px;max-width:280px;display:flex}.sk-card-actions>span{color:var(--muted);font-size:11px}.sk-card-actions button.danger:not(.sk-icon-button){border-color:var(--danger-border);background:var(--danger);color:#fff}.sk-empty{min-height:190px;color:var(--muted);text-align:center;place-items:center;gap:7px;display:grid}.sk-empty .sk-spark{font-size:25px}.sk-empty strong{color:var(--ink)}.sk-library-footer{border-top:1px solid var(--line);color:var(--muted);padding:11px 18px;font-size:11px}.host-skills-mount{border-bottom:1px solid var(--line);background:var(--surface-soft);width:100%;padding:12px}.sk-host-panel{border:1px solid var(--accent-border);background:var(--surface);border-radius:11px;padding:13px 14px}.sk-host-panel-head,.sk-host-panel-title,.sk-host-panel-actions{align-items:center;display:flex}.sk-host-panel-head{justify-content:space-between;gap:18px}.sk-host-panel-title{gap:10px;min-width:0}.sk-host-panel-title>.sk-spark{background:var(--accent-soft);border-radius:9px;flex:none;place-items:center;width:34px;height:34px;font-size:16px;display:grid}.sk-host-panel-title small,.sk-host-panel-title h2,.sk-host-panel-title p{display:block}.sk-host-panel-title small{color:var(--accent);letter-spacing:.1em;font-size:9px;font-weight:850}.sk-host-panel-title h2{margin:3px 0 2px;font-size:14px}.sk-host-panel-title p{color:var(--muted);margin:0;font-size:11px;line-height:1.35}.sk-host-panel-actions{flex:none;gap:7px}.sk-host-panel-actions button{border:1px solid var(--accent);background:var(--accent);min-height:34px;color:var(--on-accent);border-radius:8px;padding:7px 11px;font-size:11px;font-weight:750}.sk-host-panel-actions button:hover{background:var(--accent-hover)}.sk-host-panel-actions button.secondary{border-color:var(--line-strong);color:var(--ink);background:0 0}.sk-host-panel-actions button.secondary:hover{background:var(--control-hover)}.sk-host-assigned{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:11px;display:grid}.sk-host-skill-item,.sk-host-skill-more,.sk-host-panel-empty{border:1px solid var(--line);background:var(--surface-soft);border-radius:9px;min-width:0;padding:9px 10px}.sk-host-skill-item{grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:8px;display:grid}.sk-host-skill-item.missing,.sk-host-skill-item.name_mismatch,.sk-host-skill-item.conflict{border-color:var(--warning-border)}.sk-host-skill-item.invalid{border-color:var(--danger-border)}.sk-host-skill-item strong,.sk-host-skill-item p,.sk-host-skill-more strong,.sk-host-skill-more span,.sk-host-panel-empty strong,.sk-host-panel-empty span{display:block}.sk-host-skill-item strong{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.sk-host-skill-item p{color:var(--muted);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin:3px 0 0;font-size:10px;line-height:1.35;display:-webkit-box;overflow:hidden}.sk-host-skill-more,.sk-host-panel-empty{color:var(--muted);font-size:10px;line-height:1.4}.sk-host-skill-more strong,.sk-host-panel-empty strong{color:var(--ink);margin-bottom:2px;font-size:11px}.sk-host-panel-empty{text-align:center;border-style:dashed;grid-column:1/-1}.sk-binding-dialog{border:1px solid var(--line-strong);background:var(--surface);width:min(680px,100vw - 28px);max-height:94vh;color:var(--ink);box-shadow:var(--shadow-dialog);border-radius:14px;padding:0}.sk-binding-dialog::backdrop{background:var(--overlay)}.sk-binding-dialog form{grid-template-rows:auto auto minmax(100px,1fr) auto auto;max-height:94vh;display:grid}.sk-dialog-head{border-bottom:1px solid var(--line);justify-content:space-between;gap:18px;padding:18px 20px 15px;display:flex}.sk-dialog-head span{color:var(--accent);letter-spacing:.12em;font-size:9px;font-weight:850}.sk-dialog-head h2{margin:5px 0 4px;font-size:18px}.sk-dialog-head p{color:var(--muted);margin:0;line-height:1.45}.sk-dialog-close{place-items:center;width:34px;min-width:34px;height:34px;display:grid;border-color:var(--line)!important;min-height:34px!important;color:var(--muted)!important;background:0 0!important;padding:0!important;font-size:20px!important}.sk-dialog-tools{border-bottom:1px solid var(--line);background:var(--surface-soft);color:var(--muted);justify-content:space-between;align-items:center;padding:10px 20px;font-size:11px;display:flex}.sk-dialog-tools label{color:var(--ink);align-items:center;gap:7px;font-weight:700;display:flex}.sk-binding-list{padding:7px 0;overflow:auto}.sk-binding-row{cursor:pointer;grid-template-columns:auto auto minmax(0,1fr) auto;align-items:center;gap:11px;padding:10px 20px;display:grid}.sk-binding-row:hover{background:var(--row-hover)}.sk-binding-row.has-warning{box-shadow:inset 3px 0 var(--warning)}.sk-binding-row input{width:17px;height:17px;accent-color:var(--accent)}.sk-binding-row strong,.sk-binding-row small{display:block}.sk-binding-row small{color:var(--muted);margin-top:3px;line-height:1.35}.sk-host-icon{border:2px solid var(--accent);width:10px;height:10px;box-shadow:0 0 0 3px var(--accent-soft);border-radius:3px}.sk-binding-empty{color:var(--muted);text-align:center;margin:0;padding:22px}.sk-dialog-actions{border-top:1px solid var(--line);justify-content:flex-end;gap:8px;padding:13px 20px;display:flex}@media (width<=820px){.sk-intro{grid-template-columns:auto minmax(0,1fr)}.sk-intro-actions{grid-column:1/-1}.sk-skill-card{grid-template-columns:auto minmax(0,1fr) auto}.sk-hosts{grid-column:2/-1}.sk-card-actions{grid-column:2/-1;justify-content:flex-start;max-width:none}.sk-host-assigned{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width<=640px){.sk-library{padding-bottom:16px}.sk-intro,.sk-register-form,.sk-summary,.sk-skill-card{grid-template-columns:1fr}.sk-intro-icon,.sk-skill-mark{display:none}.sk-intro-actions,.sk-card-actions,.sk-hosts{grid-column:auto}.sk-intro-actions,.sk-dialog-actions{flex-wrap:wrap}.sk-intro-actions button,.sk-register-form button{width:100%}.sk-summary{grid-template-columns:repeat(3,1fr);gap:6px}.sk-summary article{padding:11px}.sk-summary strong{font-size:19px}.sk-library-toolbar,.sk-discovery>header,.sk-conflict header{flex-direction:column;align-items:stretch}.sk-search{width:100%}.sk-discovered-row,.sk-conflict>div{grid-template-columns:minmax(0,1fr) auto}.sk-discovered-row>.sk-spark{display:none}.sk-discovered-row>span{grid-column:1/-1}.sk-skill-card{align-items:start}.sk-binding-row{grid-template-columns:auto auto minmax(0,1fr);padding-inline:14px}.sk-binding-row .sk-status{grid-column:3}.sk-dialog-actions button{flex:1}.host-skills-mount{padding:8px}.sk-host-panel-head,.sk-host-panel-actions{flex-direction:column;align-items:stretch}.sk-host-panel-actions button{width:100%}.sk-host-assigned{grid-template-columns:1fr}}@media (prefers-reduced-motion:reduce){.sk-library *,.sk-binding-dialog *{scroll-behavior:auto!important;transition:none!important}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let manager=null,bindingDialog=null,bindingState=null,activeHost=null,activeOptions=null;function loadStyles(){if(document.querySelector("link[data-skill-styles]"))return;const e=document.createElement("link");e.rel="stylesheet",e.href="/assets/skills.css",e.dataset.skillStyles="true",document.head.append(e)}function escapeHtml(e){return String(e??"").replace(/[&<>'"]/g,e=>({"&":"&","<":"<",">":">","'":"'",'"':"""}[e]))}function skills(e){return e.getState().skills||[]}function servers(e){return e.getState().servers||[]}function skillPath(e){return e.path||e.skill_path||e.source_path||""}function skillDescription(e){return e.description||"No description provided by this skill."}function skillStatus(e){return e.status||"ready"}function statusLabel(e){return{ready:"Ready",missing:"Missing",invalid:"Invalid",name_mismatch:"Name mismatch",available:"Available",registered:"Registered",conflict:"Conflict"}[e]||e.replaceAll("_"," ")}function statusClass(e){return["ready","missing","invalid","name_mismatch","available","registered","conflict"].includes(e)?e:"invalid"}function serverIdsForSkill(e){const t=e.server_ids||e.host_ids;return Array.isArray(t)?t.map(String):(e.servers||e.hosts||[]).map(e=>"string"==typeof e?e:e?.id).filter(Boolean).map(String)}function serverAliasesForSkill(e,t){const s=new Map(servers(e).map(e=>[String(e.id),e.alias])),a=new Map((t.servers||[]).filter(e=>e&&"object"==typeof e).map(e=>[String(e.id),e.alias]));return serverIdsForSkill(t).map(e=>a.get(e)||s.get(e)||e)}function skillsForHost(e,t){const s=String(t||""),a=servers(e).find(e=>String(e.id)===s),n=new Set((a?.skills||a?.skill_ids||[]).map(e=>"string"==typeof e?e:e?.id).filter(Boolean).map(String));return skills(e).filter(e=>serverIdsForSkill(e).includes(s)||n.has(String(e.id)))}function setBusy(e,t){t?e.setAttribute("aria-busy","true"):e.removeAttribute("aria-busy")}async function perform(e,t,s){if("true"!==t.getAttribute("aria-busy")){setBusy(t,!0);try{await s()}catch(t){e.toast(t.message||String(t),!0)}finally{setBusy(t,!1)}}}function ensureBindingDialog(){if(bindingDialog)return bindingDialog;const e=document.createElement("dialog");return e.className="sk-binding-dialog",e.setAttribute("aria-labelledby","sk-binding-title"),e.setAttribute("aria-describedby","sk-binding-copy"),e.innerHTML='\n <form data-sk-bind-form>\n <header class="sk-dialog-head">\n <div><span>HOST-SCOPED GUIDANCE</span><h2 id="sk-binding-title" data-sk-dialog-title>Manage relationships</h2><p id="sk-binding-copy" data-sk-dialog-copy></p></div>\n <button type="button" class="sk-dialog-close" data-sk-dialog-close aria-label="Close">×</button>\n </header>\n <div class="sk-dialog-tools"><label><input type="checkbox" data-sk-select-all> Select all</label><span data-sk-dialog-count></span></div>\n <div class="sk-binding-list" data-sk-binding-list></div>\n <p class="sk-binding-empty" data-sk-binding-empty hidden></p>\n <footer class="sk-dialog-actions"><button type="button" class="secondary" data-sk-dialog-close>Cancel</button><button type="submit">Save relationships</button></footer>\n </form>',document.body.append(e),e.querySelectorAll("[data-sk-dialog-close]").forEach(t=>{t.addEventListener("click",()=>e.close())}),e.addEventListener("click",t=>{t.target===e&&e.close()}),e.querySelector("[data-sk-select-all]").addEventListener("change",t=>{e.querySelectorAll('[data-sk-binding-list] input[type="checkbox"]:not(:disabled)').forEach(e=>{e.checked=t.target.checked}),updateBindingCount()}),e.querySelector("[data-sk-binding-list]").addEventListener("change",()=>{updateBindingCount()}),e.querySelector("[data-sk-bind-form]").addEventListener("submit",t=>{t.preventDefault(),bindingState&&perform(bindingState.options,e,saveBindings)}),bindingDialog=e,e}function updateBindingCount(){if(!bindingDialog)return;const e=[...bindingDialog.querySelectorAll('[data-sk-binding-list] input[type="checkbox"]')],t=e.filter(e=>!e.disabled),s=e.filter(e=>e.checked),a=bindingDialog.querySelector("[data-sk-select-all]");a.disabled=0===t.length,a.checked=t.length>0&&t.every(e=>e.checked),a.indeterminate=s.length>0&&!a.checked,bindingDialog.querySelector("[data-sk-dialog-count]").textContent=`${s.length} selected`}function openSkillBindings(e,t){loadStyles();const s=skills(e).find(e=>String(e.id)===String(t));if(!s)return;const a=ensureBindingDialog();bindingState={kind:"skill",options:e,skillId:String(s.id)},a.querySelector("[data-sk-dialog-title]").textContent=s.name,a.querySelector("[data-sk-dialog-copy]").textContent="Choose every host where this skill's instructions are relevant.";const n=new Set(serverIdsForSkill(s)),i=servers(e);a.querySelector("[data-sk-binding-list]").innerHTML=i.map(e=>`<label class="sk-binding-row">\n <input type="checkbox" value="${escapeHtml(e.id)}" ${n.has(String(e.id))?"checked":""}>\n <span class="sk-host-icon" aria-hidden="true"></span>\n <span><strong>${escapeHtml(e.alias)}</strong><small>${escapeHtml(e.username)}@${escapeHtml(e.hostname)}:${escapeHtml(e.port)}</small></span>\n </label>`).join("");const r=a.querySelector("[data-sk-binding-empty]");r.hidden=i.length>0,r.textContent="Add a host before assigning this skill.",updateBindingCount(),a.showModal()}export function openHostSkillPicker(e,t){loadStyles();const s=servers(e).find(e=>String(e.id)===String(t));if(!s)return;const a=ensureBindingDialog();bindingState={kind:"host",options:e,serverId:String(s.id)},a.querySelector("[data-sk-dialog-title]").textContent=`Skills for ${s.alias}`,a.querySelector("[data-sk-dialog-copy]").textContent="Attached skills become eligible for this host; normal trigger rules still decide which one the Agent uses.";const n=new Set(skillsForHost(e,s.id).map(e=>String(e.id))),i=skills(e);a.querySelector("[data-sk-binding-list]").innerHTML=i.map(e=>{const t=skillStatus(e),s=n.has(String(e.id));return`<label class="sk-binding-row${"ready"!==t?" has-warning":""}">\n <input type="checkbox" value="${escapeHtml(e.id)}" ${s?"checked":""}>\n <span class="sk-spark" aria-hidden="true">✦</span>\n <span><strong>${escapeHtml(e.name)}</strong><small>${escapeHtml(skillDescription(e))}</small></span>\n <em class="sk-status ${statusClass(t)}">${escapeHtml(statusLabel(t))}</em>\n </label>`}).join("");const r=a.querySelector("[data-sk-binding-empty]");r.hidden=i.length>0,r.textContent="Register an Agent Skill before assigning one to this host.",updateBindingCount(),a.showModal()}async function saveBindings(){const{options:e}=bindingState,t=new Set([...bindingDialog.querySelectorAll('[data-sk-binding-list] input[type="checkbox"]:checked')].map(e=>String(e.value)));"skill"===bindingState.kind?await e.api(`/api/skills/${bindingState.skillId}/servers`,{method:"PUT",body:{server_ids:[...t]}}):await e.api(`/api/servers/${bindingState.serverId}/skills`,{method:"PUT",body:{skill_ids:[...t]}}),await e.refresh(),bindingDialog.close(),e.toast("Host–Skill relationships saved"),syncSkillUI(e)}function registeredPath(e,t){return skills(e).some(e=>skillPath(e)===t)}function discoveryCandidates(e){return Array.isArray(e)?{candidates:e,conflicts:[]}:{candidates:e?.candidates||e?.skills||[],conflicts:e?.conflicts||[]}}function renderDiscovery(){if(!manager)return;const e=manager.container.querySelector("[data-sk-discovery]");if(!manager.discovery)return e.hidden=!0,void e.replaceChildren();e.hidden=!1;const t=manager.discovery.candidates||[],s=manager.discovery.conflicts||[],a=t.map(e=>{const t=registeredPath(manager.options,e.path),s=e.status||"ready",a="invalid"===s,n=e.description||e.error||e.path;return`<div class="sk-discovered-row">\n <span class="sk-spark" aria-hidden="true">✦</span>\n <span><strong>${escapeHtml(e.name||"Unreadable skill")}</strong><small>${escapeHtml(n)}</small><code title="${escapeHtml(e.path)}">${escapeHtml(e.path)}</code></span>\n <em class="sk-status ${statusClass(s)}">${escapeHtml(statusLabel(s))}</em>\n <button type="button" class="secondary" data-sk-register-path="${escapeHtml(e.path)}" ${t||a?"disabled":""}>${t?"Registered":a?"Fix SKILL.md first":"Register"}</button>\n </div>`}).join(""),n=s.map(e=>{const t=e.paths||(e.path?[e.path]:[]),s=e.name||e.names?.join(" ↔ ")||"Conflicting skill",a="path"===e.type?"Registered name no longer matches this file":"Choose the copy you trust";return`<article class="sk-conflict"><header><strong>${escapeHtml(s)}</strong><span>${escapeHtml(a)}</span></header>${t.map(e=>`<div><code title="${escapeHtml(e)}">${escapeHtml(e)}</code><button type="button" class="secondary" data-sk-register-path="${escapeHtml(e)}" ${registeredPath(manager.options,e)?"disabled":""}>${registeredPath(manager.options,e)?"Registered":"Register this copy"}</button></div>`).join("")}</article>`}).join("");e.innerHTML=`<header><div><strong>Skills found on this device</strong><span>${t.length} candidate${1===t.length?"":"s"}${s.length?` · ${s.length} conflict${1===s.length?"":"s"}`:""}</span></div><button type="button" class="sk-icon-button" data-sk-dismiss-discovery aria-label="Close discovery">×</button></header>\n ${a||(n?"":'<p class="sk-discovery-empty">No unregistered skill directories were found.</p>')}\n ${n}`}function hostChips(e,t){const s=serverAliasesForSkill(e,t);return s.length?`${s.slice(0,3).map(e=>`<span class="sk-host-chip">${escapeHtml(e)}</span>`).join("")}${s.length>3?`<span class="sk-host-more">+${s.length-3}</span>`:""}`:'<span class="sk-no-hosts">No hosts</span>'}function renderManager(){if(!manager)return;const e=manager.query.trim().toLocaleLowerCase(),t=skills(manager.options),s=t.filter(t=>!e||[t.name,t.description,skillPath(t),...serverAliasesForSkill(manager.options,t)].some(t=>String(t||"").toLocaleLowerCase().includes(e))),a=t.filter(e=>"ready"===skillStatus(e)).length,n=new Set(t.flatMap(serverIdsForSkill)).size;manager.container.querySelector("[data-sk-total]").textContent=t.length,manager.container.querySelector("[data-sk-ready]").textContent=a,manager.container.querySelector("[data-sk-attached]").textContent=n,manager.container.querySelector("[data-sk-count]").textContent=`${s.length} of ${t.length} skill${1===t.length?"":"s"}`,manager.container.querySelector("[data-sk-rows]").innerHTML=s.map(e=>{const t=skillStatus(e),s=manager.confirmDelete===String(e.id);return`<article class="sk-skill-card${"ready"===t?"":" has-warning"}" data-skill-id="${escapeHtml(e.id)}">\n <div class="sk-skill-mark" aria-hidden="true">✦</div>\n <div class="sk-skill-copy"><header><h3>${escapeHtml(e.name)}</h3><em class="sk-status ${statusClass(t)}">${escapeHtml(statusLabel(t))}</em></header><p>${escapeHtml(skillDescription(e))}</p><code title="${escapeHtml(skillPath(e))}">${escapeHtml(skillPath(e)||"Path unavailable")}</code></div>\n <div class="sk-hosts"><small>ASSIGNED HOSTS</small><div>${hostChips(manager.options,e)}</div><button type="button" data-sk-manage="${escapeHtml(e.id)}">Manage hosts</button></div>\n <div class="sk-card-actions">${s?`<span>Unregister this skill?</span><button type="button" class="danger" data-sk-confirm-unregister="${escapeHtml(e.id)}">Unregister</button><button type="button" class="secondary" data-sk-cancel-unregister>Cancel</button>`:`<button type="button" class="secondary" data-sk-refresh="${escapeHtml(e.id)}">Refresh</button><button type="button" class="sk-icon-button danger" data-sk-unregister="${escapeHtml(e.id)}" aria-label="Unregister ${escapeHtml(e.name)}" title="Unregister">×</button>`}</div>\n </article>`}).join("");const i=manager.container.querySelector("[data-sk-empty]");i.hidden=s.length>0,i.querySelector("strong").textContent=t.length?"No skills match this search":"No Agent Skills registered",i.querySelector("[data-sk-empty-copy]").textContent=t.length?"Try another name, path, or host.":"Discover installed skills or register a SKILL.md directory by path.",renderDiscovery()}async function discoverSkills(){const e=await manager.options.api("/api/skills/discover");manager.discovery=discoveryCandidates(e),renderDiscovery()}async function registerSkill(e){if(!e)return;const t=await manager.options.api("/api/skills",{method:"POST",body:{path:e}});await manager.options.refresh(),manager.container.querySelector("[data-sk-path]").value="",manager.container.querySelector("[data-sk-register-form]").hidden=!0,renderManager(),manager.options.toast(`Skill “${t.name||"Agent Skill"}” registered`)}async function refreshSkill(e){const t=skills(manager.options).find(t=>String(t.id)===String(e));if(!t)return;const s=skillPath(t);await manager.options.api(`/api/skills/${t.id}/refresh`,{method:"POST",body:s?{path:s}:{}}),await manager.options.refresh(),renderManager(),manager.options.toast(`Skill “${t.name}” refreshed`)}async function unregisterSkill(e){const t=skills(manager.options).find(t=>String(t.id)===String(e));t&&(await manager.options.api(`/api/skills/${t.id}`,{method:"DELETE"}),manager.confirmDelete="",await manager.options.refresh(),renderManager(),manager.options.toast(`Skill “${t.name}” unregistered`))}function initializeManager(e){const t=document.querySelector("#skillManagerMount");t.className="sk-library",t.innerHTML='\n <section class="sk-intro">\n <div class="sk-intro-icon" aria-hidden="true">✦</div>\n <div><strong>Operational knowledge, scoped to the right hosts.</strong><p>Attach each skill only where it applies. When a task targets a host, the Agent resolves that host\'s skills before it acts.</p></div>\n <div class="sk-intro-actions"><button type="button" data-sk-discover>Discover local skills</button><button type="button" class="secondary" data-sk-show-register>Register path</button></div>\n </section>\n <form class="sk-register-form" data-sk-register-form hidden>\n <label><span>Skill directory or SKILL.md path</span><input data-sk-path autocomplete="off" placeholder="~/.codex/skills/my-host-skill" aria-label="Skill directory or SKILL.md path" required></label>\n <button type="submit">Register</button><button type="button" class="secondary" data-sk-cancel-register>Cancel</button>\n </form>\n <section class="sk-discovery" data-sk-discovery hidden></section>\n <section class="sk-summary" aria-label="Agent Skill overview"><article><small>Registered skills</small><strong data-sk-total>0</strong></article><article><small>Ready</small><strong data-sk-ready>0</strong></article><article><small>Hosts with skills</small><strong data-sk-attached>0</strong></article></section>\n <section class="sk-library-surface">\n <header class="sk-library-toolbar"><div><h2>Skill library</h2><p>One skill can serve several related hosts.</p></div><label class="sk-search"><span class="sr-only">Search Agent Skills</span><input type="search" data-sk-search placeholder="Search name, path, or host…" aria-label="Search Agent Skills"></label></header>\n <div class="sk-skill-list" data-sk-rows></div>\n <div class="sk-empty" data-sk-empty hidden><span class="sk-spark" aria-hidden="true">✦</span><strong></strong><span data-sk-empty-copy></span></div>\n <footer class="sk-library-footer" data-sk-count></footer>\n </section>',manager={container:t,options:e,query:"",discovery:null,confirmDelete:""},t.querySelector("[data-sk-search]").addEventListener("input",e=>{manager.query=e.target.value,renderManager()}),t.querySelector("[data-sk-register-form]").addEventListener("submit",s=>{s.preventDefault(),perform(e,t,()=>registerSkill(t.querySelector("[data-sk-path]").value.trim()))}),t.addEventListener("click",e=>{const s=e.target.closest("button");s&&(s.matches("[data-sk-discover]")?perform(manager.options,t,discoverSkills):s.matches("[data-sk-show-register]")?(t.querySelector("[data-sk-register-form]").hidden=!1,t.querySelector("[data-sk-path]").focus()):s.matches("[data-sk-cancel-register]")?(t.querySelector("[data-sk-register-form]").hidden=!0,t.querySelector("[data-sk-path]").value=""):s.matches("[data-sk-dismiss-discovery]")?(manager.discovery=null,renderDiscovery()):s.dataset.skRegisterPath?perform(manager.options,t,()=>registerSkill(s.dataset.skRegisterPath)):s.dataset.skRefresh?perform(manager.options,t,()=>refreshSkill(s.dataset.skRefresh)):s.dataset.skManage?openSkillBindings(manager.options,s.dataset.skManage):s.dataset.skUnregister?(manager.confirmDelete=s.dataset.skUnregister,renderManager()):s.matches("[data-sk-cancel-unregister]")?(manager.confirmDelete="",renderManager()):s.dataset.skConfirmUnregister&&perform(manager.options,t,()=>unregisterSkill(s.dataset.skConfirmUnregister)))})}export function openSkillManager(e){loadStyles(),e.showView("skills"),manager||initializeManager(e),manager.options=e,renderManager()}export function syncHostSkills(e,t){loadStyles(),activeOptions=e,activeHost=String(t||"");const s=document.querySelector("#hostSkillsMount");if(!s)return;if(!t)return void s.replaceChildren();const a=servers(e).find(e=>String(e.id)===String(t));if(!a)return void s.replaceChildren();const n=skillsForHost(e,t),i=skills(e),r=n.filter(e=>"ready"!==skillStatus(e)).length,l=n.slice(0,3),o=n.length?`${n.length} attached${r?` · ${r} needs attention`:""}. Attached skills are eligible only for this host; normal trigger rules still apply. This is local guidance; nothing is installed on the remote host.`:"No host-specific guidance is attached yet. This is local guidance; nothing is installed on the remote host.",d=l.length?`${l.map(e=>{const t=skillStatus(e);return`<article class="sk-host-skill-item ${statusClass(t)}"><div><strong>${escapeHtml(e.name)}</strong><p>${escapeHtml(skillDescription(e))}</p></div><em class="sk-status ${statusClass(t)}">${escapeHtml(statusLabel(t))}</em></article>`}).join("")}${n.length>l.length?`<div class="sk-host-skill-more"><strong>+${n.length-l.length} more</strong><span>Open the manager to see every attached skill.</span></div>`:""}`:`<div class="sk-host-panel-empty"><strong>${i.length?"No skills assigned to this host":"No skills registered yet"}</strong><span>${i.length?`Choose from ${i.length} registered skill${1===i.length?"":"s"}.`:"Register local guidance in the Skill Library first."}</span></div>`,c=n.length?`Manage ${n.length} skill${1===n.length?"":"s"}`:"Assign skills";s.innerHTML=`<section class="sk-host-panel" aria-labelledby="hostSkillPanelTitle">\n <header class="sk-host-panel-head">\n <div class="sk-host-panel-title"><span class="sk-spark" aria-hidden="true">✦</span><div><small>HOST-SCOPED GUIDANCE</small><h2 id="hostSkillPanelTitle">Agent Skills for ${escapeHtml(a.alias)}</h2><p>${escapeHtml(o)}</p></div></div>\n <div class="sk-host-panel-actions"><button type="button" class="secondary sk-open-skill-library">Skill Library</button>${i.length?`<button type="button" class="sk-manage-host-skills" aria-haspopup="dialog" aria-label="Manage Agent Skills for ${escapeHtml(a.alias)}, ${n.length} assigned">${c}</button>`:""}</div>\n </header>\n <div class="sk-host-assigned">${d}</div>\n </section>`,s.querySelector(".sk-open-skill-library").addEventListener("click",()=>{openSkillManager(e)}),s.querySelector(".sk-manage-host-skills")?.addEventListener("click",()=>{openHostSkillPicker(e,t)})}export function syncSkillUI(e){manager&&(manager.options=e,renderManager()),activeHost&&syncHostSkills(e||activeOptions,activeHost)}
|
|
@@ -16,7 +16,7 @@ from . import __version__
|
|
|
16
16
|
from .db import ConflictError, Database, DatabaseError, NotFoundError
|
|
17
17
|
from .importer import apply_import, preview_import
|
|
18
18
|
from .paths import database_path, managed_ssh_config_path, original_ssh_config_path
|
|
19
|
-
from .service import CredentialService
|
|
19
|
+
from .service import CredentialService, SkillService
|
|
20
20
|
from .ssh_config import SSHConfigError, render_config
|
|
21
21
|
from .ssh_runner import SSHError, SSHRunner
|
|
22
22
|
from .validation import ValidationError
|
|
@@ -51,6 +51,11 @@ def format_item(item: Any) -> str:
|
|
|
51
51
|
if "kind" in item:
|
|
52
52
|
detail = item.get("key_path") or ("stored" if item.get("has_secret") else "not stored")
|
|
53
53
|
return f"{item['label']:<22} {item['kind']:<8} {detail}"
|
|
54
|
+
if "name" in item and "path" in item:
|
|
55
|
+
servers = ",".join(server["alias"] for server in item.get("servers", [])) or "-"
|
|
56
|
+
status = item.get("status")
|
|
57
|
+
suffix = f" status={status}" if status else ""
|
|
58
|
+
return f"{item['name']:<28} {item['path']} servers={servers}{suffix}"
|
|
54
59
|
return json.dumps(item, ensure_ascii=False)
|
|
55
60
|
|
|
56
61
|
|
|
@@ -148,6 +153,42 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
148
153
|
server_note.add_argument("--append", action="store_true", help="append text after the existing note")
|
|
149
154
|
add_json_option(server_note)
|
|
150
155
|
|
|
156
|
+
skill = commands.add_parser("skill", help="manage local Agent Skills attached to hosts")
|
|
157
|
+
skill_commands = skill.add_subparsers(dest="skill_command", required=True)
|
|
158
|
+
skill_discover = skill_commands.add_parser("discover", help="find local Skills without registering them")
|
|
159
|
+
add_json_option(skill_discover)
|
|
160
|
+
skill_list = skill_commands.add_parser("list", help="list registered Skills")
|
|
161
|
+
skill_list.add_argument("--server", help="only Skills attached to this server alias/id")
|
|
162
|
+
add_json_option(skill_list)
|
|
163
|
+
skill_show = skill_commands.add_parser("show", help="show one registered Skill")
|
|
164
|
+
skill_show.add_argument("skill", help="Skill name or id")
|
|
165
|
+
add_json_option(skill_show)
|
|
166
|
+
skill_add = skill_commands.add_parser("add", help="register a local Skill and optionally attach hosts")
|
|
167
|
+
skill_add.add_argument("path", help="Skill directory or SKILL.md path")
|
|
168
|
+
skill_add.add_argument("--server", action="append", default=[], help="server alias/id; repeat as needed")
|
|
169
|
+
add_json_option(skill_add)
|
|
170
|
+
skill_refresh = skill_commands.add_parser("refresh", help="refresh metadata from SKILL.md")
|
|
171
|
+
skill_refresh.add_argument("skill", help="Skill name or id")
|
|
172
|
+
skill_refresh.add_argument("--path", help="new Skill directory or SKILL.md path")
|
|
173
|
+
add_json_option(skill_refresh)
|
|
174
|
+
skill_attach = skill_commands.add_parser("attach", help="attach a Skill to one or more hosts")
|
|
175
|
+
skill_attach.add_argument("skill", help="Skill name or id")
|
|
176
|
+
skill_attach.add_argument("servers", nargs="+", metavar="SERVER", help="server alias or id")
|
|
177
|
+
add_json_option(skill_attach)
|
|
178
|
+
skill_detach = skill_commands.add_parser("detach", help="detach a Skill from one or more hosts")
|
|
179
|
+
skill_detach.add_argument("skill", help="Skill name or id")
|
|
180
|
+
skill_detach.add_argument("servers", nargs="+", metavar="SERVER", help="server alias or id")
|
|
181
|
+
add_json_option(skill_detach)
|
|
182
|
+
skill_resolve = skill_commands.add_parser(
|
|
183
|
+
"resolve", help="resolve the local Skills that apply to one or more target hosts"
|
|
184
|
+
)
|
|
185
|
+
skill_resolve.add_argument("servers", nargs="+", metavar="SERVER", help="server alias or id")
|
|
186
|
+
add_json_option(skill_resolve)
|
|
187
|
+
skill_remove = skill_commands.add_parser("remove", help="unregister a Skill without deleting local files")
|
|
188
|
+
skill_remove.add_argument("skill", help="Skill name or id")
|
|
189
|
+
skill_remove.add_argument("--yes", action="store_true")
|
|
190
|
+
add_json_option(skill_remove)
|
|
191
|
+
|
|
151
192
|
credential = commands.add_parser("credential", help="manage reusable credentials")
|
|
152
193
|
credential_commands = credential.add_subparsers(dest="credential_command", required=True)
|
|
153
194
|
credential_list = credential_commands.add_parser("list")
|
|
@@ -399,6 +440,34 @@ def handle(args: argparse.Namespace) -> int:
|
|
|
399
440
|
emit(result, as_json=args.json)
|
|
400
441
|
return 0
|
|
401
442
|
return 0
|
|
443
|
+
if args.command == "skill":
|
|
444
|
+
service = SkillService(database)
|
|
445
|
+
if args.skill_command == "discover":
|
|
446
|
+
result = service.discover()
|
|
447
|
+
elif args.skill_command == "list":
|
|
448
|
+
result = service.list(server_identifier=args.server)
|
|
449
|
+
elif args.skill_command == "show":
|
|
450
|
+
result = database.get_skill(args.skill)
|
|
451
|
+
elif args.skill_command == "add":
|
|
452
|
+
result = service.register(args.path, server_identifiers=args.server)
|
|
453
|
+
elif args.skill_command == "refresh":
|
|
454
|
+
result = service.refresh(args.skill, path=args.path)
|
|
455
|
+
elif args.skill_command == "attach":
|
|
456
|
+
result = database.attach_skill(args.skill, args.servers)
|
|
457
|
+
elif args.skill_command == "detach":
|
|
458
|
+
result = database.detach_skill(args.skill, args.servers)
|
|
459
|
+
elif args.skill_command == "resolve":
|
|
460
|
+
result = service.resolve(args.servers)
|
|
461
|
+
emit(result, as_json=args.json)
|
|
462
|
+
return 0 if result["ok"] else 1
|
|
463
|
+
elif args.skill_command == "remove":
|
|
464
|
+
if not confirm(f"Unregister Skill {args.skill}?", args.yes):
|
|
465
|
+
raise DatabaseError("removal cancelled; use --yes for non-interactive removal")
|
|
466
|
+
result = service.delete(args.skill)
|
|
467
|
+
else:
|
|
468
|
+
return 2
|
|
469
|
+
emit(result, as_json=args.json)
|
|
470
|
+
return 0
|
|
402
471
|
if args.command == "credential":
|
|
403
472
|
if args.credential_command == "list":
|
|
404
473
|
emit(database.list_credentials(), as_json=args.json)
|