shadok-ai 0.17.35 → 0.17.37
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.
- package/README.md +11 -0
- package/package.json +1 -1
- package/public/index.html +199 -31
package/README.md
CHANGED
|
@@ -254,6 +254,9 @@ Enter to skip; you can add it later from the web UI).
|
|
|
254
254
|
with a command goes to the agent bare, without shadok's context line or
|
|
255
255
|
ledger update ahead of it, since Claude Code reads a command only when `/`
|
|
256
256
|
comes first.
|
|
257
|
+
The ✦ button beside the paperclip opens the same menu without typing, like
|
|
258
|
+
an emoji picker: the chosen skill goes in **front** of what you already
|
|
259
|
+
wrote (which becomes its argument), replacing a command already there.
|
|
257
260
|
- **Files you hand an agent** — paste or drop a file into the composer, or pick
|
|
258
261
|
one with the 📎 button beside it (an image, a PDF, a CSV…), and it becomes a **chip** above the text box: a
|
|
259
262
|
thumbnail for an image, a named tag for anything else, each with a ✕ to
|
|
@@ -301,6 +304,14 @@ Enter to skip; you can add it later from the web UI).
|
|
|
301
304
|
- **The detail panel.** Its numbers, where it runs (group by group: each
|
|
302
305
|
agent, its schedule, its runs), the secrets agents running it get, and its
|
|
303
306
|
feedback.
|
|
307
|
+
- **Creating.** The new-skill form asks for a **name** (what people see) and
|
|
308
|
+
a **technical name** (what agents call it, `/name:name`), proposed from the
|
|
309
|
+
name until you type your own. Choose it then: it is the skill's repository
|
|
310
|
+
and plugin name, so it cannot be changed afterwards. A name already taken
|
|
311
|
+
gets a number (`-2`, `-3`…), and the form says so before you create it.
|
|
312
|
+
- **Renaming.** Click a skill's name at the top of its editor to rename it
|
|
313
|
+
(Enter saves, Escape cancels). Only the displayed name changes: agents keep
|
|
314
|
+
calling it by its technical name, so attached agents are not disturbed.
|
|
304
315
|
- **Sharing.** A skill is shared with **only me**, with **one or more
|
|
305
316
|
groups**, or with **everyone**. Sharing keeps lists short; it is not a
|
|
306
317
|
lock. "Only me" hides the skill from everyone else. A group skill is offered
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -1086,8 +1086,28 @@
|
|
|
1086
1086
|
.sec-meta { flex-basis: 100%; font-size: 11px; color: var(--text-dim); margin-top: 2px; }
|
|
1087
1087
|
#secretAttach[data-grouped="1"] { display: none; }
|
|
1088
1088
|
.sec-from { margin-left: 8px; font-size: 11px; color: var(--text-dim); font-family: var(--sans, inherit); }
|
|
1089
|
-
|
|
1090
|
-
|
|
1089
|
+
/* Grouped mode: each own secret is a card — name + actions, one meta line, then
|
|
1090
|
+
the "Shared with" / "Injected into" chip rows and ONE Save. */
|
|
1091
|
+
#secretsList li.sec-row { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; background: none; }
|
|
1092
|
+
#secretsList li.sec-row:nth-child(odd) { background: none; }
|
|
1093
|
+
.sec-ctl { flex-basis: 100%; display: flex; flex-direction: column; gap: 8px; font-size: 12px; margin-top: 10px; }
|
|
1094
|
+
.sec-field { display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: start; }
|
|
1095
|
+
.sec-field-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; padding-top: 5px; }
|
|
1096
|
+
.sec-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
1097
|
+
.sec-chip { position: relative; display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 3px 10px; border: 1px solid var(--line); border-radius: 14px; cursor: pointer; user-select: none; }
|
|
1098
|
+
.sec-chip:hover { border-color: var(--amber); }
|
|
1099
|
+
.sec-chip input { position: absolute; opacity: 0; pointer-events: none; }
|
|
1100
|
+
.sec-chip:has(input:checked) { border-color: var(--amber); background: color-mix(in srgb, var(--amber) 16%, transparent); color: var(--text); }
|
|
1101
|
+
.sec-chip:has(input:checked)::before { content: "✓"; font-size: 11px; color: var(--amber); }
|
|
1102
|
+
.sec-chip:has(input:focus-visible) { outline: 2px solid var(--amber); outline-offset: 1px; }
|
|
1103
|
+
.sec-foot { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
|
|
1104
|
+
.sec-status { flex: 1; font-size: 12px; color: var(--text-dim); }
|
|
1105
|
+
.sec-status.err { color: #e2564a; }
|
|
1106
|
+
#secretsList button.sec-save { font-size: 12px; padding: 4px 14px; border-radius: 6px; border: 1px solid var(--amber); color: var(--amber); }
|
|
1107
|
+
#secretsList button.sec-save:hover:not(:disabled) { background: color-mix(in srgb, var(--amber) 15%, transparent); }
|
|
1108
|
+
#secretsList button.sec-save:disabled { opacity: .35; cursor: default; border-color: var(--line); color: var(--text-dim); }
|
|
1109
|
+
.sec-err { margin: 0 0 10px; padding: 8px 11px; border: 1px solid #e2564a; border-radius: 8px; color: #e2564a; background: color-mix(in srgb, #e2564a 8%, transparent); font-size: 12.5px; }
|
|
1110
|
+
@media (max-width: 520px) { .sec-field { grid-template-columns: 1fr; gap: 4px; } .sec-field-label { padding-top: 0; } }
|
|
1091
1111
|
.group-head .gkey { flex: none; font-size: 12px; padding: 0 4px; cursor: pointer; visibility: hidden; }
|
|
1092
1112
|
.group-head:hover .gkey { visibility: visible; }
|
|
1093
1113
|
/* A touch screen has no hover, so the group-head ⚙/🔑 (the single-column /
|
|
@@ -1751,6 +1771,7 @@
|
|
|
1751
1771
|
.slash-menu[hidden] { display: none; }
|
|
1752
1772
|
.slash-item { display: flex; gap: 10px; align-items: baseline; width: 100%; padding: 7px 10px; border: 0; border-radius: 6px; background: transparent; color: var(--text); text-align: left; cursor: pointer; font: inherit; }
|
|
1753
1773
|
.slash-item[aria-selected="true"] { background: var(--amber-soft); }
|
|
1774
|
+
.slash-empty { padding: 8px 10px; color: var(--text-dim); font-size: 13px; }
|
|
1754
1775
|
.slash-name { font-family: var(--mono); font-size: 13px; flex: none; }
|
|
1755
1776
|
.slash-desc { color: var(--text-dim); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
1756
1777
|
.cmp-attach {
|
|
@@ -2469,6 +2490,11 @@
|
|
|
2469
2490
|
.fp-col.fp-doc { padding: 22px 26px; background: var(--bg); }
|
|
2470
2491
|
.fp-doc-head { display: flex; gap: 14px; align-items: center; margin-bottom: 6px; }
|
|
2471
2492
|
.fp-doc-head h1 { font-size: 20px; font-weight: 650; }
|
|
2493
|
+
/* The skill's name, renamed in place: reads as a title until hovered/focused. */
|
|
2494
|
+
.fp-main .fp-doc-head input.fp-title-in { width: 100%; box-sizing: border-box; font: inherit; font-size: 20px; font-weight: 650; color: var(--text);
|
|
2495
|
+
background: transparent; border: 1px solid transparent; border-radius: 6px; padding: 2px 6px; margin-left: -7px; }
|
|
2496
|
+
.fp-main .fp-doc-head input.fp-title-in:hover { border-color: var(--line); }
|
|
2497
|
+
.fp-main .fp-doc-head input.fp-title-in:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
|
|
2472
2498
|
.fp-doc-head .fp-sub { margin-top: 4px; }
|
|
2473
2499
|
.fp-doc textarea { border-radius: 10px; padding: 10px 12px; background: var(--bg-raised); border: 1px solid var(--line); color: var(--text); font-family: var(--sans); }
|
|
2474
2500
|
.fp-doc textarea:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
|
|
@@ -3017,6 +3043,7 @@
|
|
|
3017
3043
|
</div>
|
|
3018
3044
|
<div class="pin-body">
|
|
3019
3045
|
<div id="secretsScope" class="sec-scope" hidden><span>Group</span><select id="secretsGroup" aria-label="Group whose secrets are shown"></select></div>
|
|
3046
|
+
<p id="secretsErr" class="sec-err" role="alert" hidden></p>
|
|
3020
3047
|
<ul id="secretsList"></ul>
|
|
3021
3048
|
<form id="secretsAdd">
|
|
3022
3049
|
<div class="sec-add-row">
|
|
@@ -3731,6 +3758,11 @@
|
|
|
3731
3758
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M21 11.5l-8.6 8.6a5.5 5.5 0 0 1-7.8-7.8l8.6-8.6a3.7 3.7 0 0 1 5.2 5.2l-8.6 8.6a1.8 1.8 0 0 1-2.6-2.6l7.9-7.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
3732
3759
|
</button>
|
|
3733
3760
|
<input type="file" id="attachInput" multiple hidden>
|
|
3761
|
+
<!-- Pick a skill like an emoji: opens the `/` menu with every skill attached
|
|
3762
|
+
to this agent; the choice goes in at the head of the prompt. -->
|
|
3763
|
+
<button type="button" id="skillBtn" class="cmp-attach" title="Add a skill to this prompt" aria-label="Add a skill" aria-haspopup="listbox">
|
|
3764
|
+
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
|
3765
|
+
</button>
|
|
3734
3766
|
<div id="slashMenu" class="slash-menu" role="listbox" aria-label="Commands" hidden></div>
|
|
3735
3767
|
<textarea id="promptInput" rows="1"
|
|
3736
3768
|
placeholder="Write your prompt — Enter to send, Shift+Enter for a new line"></textarea>
|
|
@@ -9931,9 +9963,19 @@
|
|
|
9931
9963
|
slash.cache.set(sessionId, { at: Date.now(), list });
|
|
9932
9964
|
return list;
|
|
9933
9965
|
}
|
|
9934
|
-
function closeSlash() { slashMenu.hidden = true; slash.list = []; }
|
|
9966
|
+
function closeSlash() { slashMenu.hidden = true; slash.list = []; slash.picker = false; }
|
|
9935
9967
|
function paintSlash() {
|
|
9936
9968
|
slashMenu.textContent = "";
|
|
9969
|
+
if (slash.picker && !slash.list.length) {
|
|
9970
|
+
// Only ATTACHED skills run (they load as a plugin at spawn), so an empty
|
|
9971
|
+
// menu says where to attach one rather than staying silently shut.
|
|
9972
|
+
const d = document.createElement("div");
|
|
9973
|
+
d.className = "slash-empty";
|
|
9974
|
+
d.textContent = "No skill attached to this agent yet. Attach one from the Skills page, then reload the agent.";
|
|
9975
|
+
slashMenu.appendChild(d);
|
|
9976
|
+
slashMenu.hidden = false;
|
|
9977
|
+
return;
|
|
9978
|
+
}
|
|
9937
9979
|
slash.list.forEach((c, i) => {
|
|
9938
9980
|
const b = document.createElement("button");
|
|
9939
9981
|
b.type = "button";
|
|
@@ -9964,16 +10006,34 @@
|
|
|
9964
10006
|
function pickSlash(i) {
|
|
9965
10007
|
const c = slash.list[i];
|
|
9966
10008
|
if (!c) return;
|
|
9967
|
-
|
|
10009
|
+
// From the picker the prompt may already hold text: a `/command` only runs at
|
|
10010
|
+
// the very start (isSlashCommand), so the skill goes in FRONT and the text
|
|
10011
|
+
// becomes its argument; a command already there is replaced, not stacked.
|
|
10012
|
+
const rest = slash.picker ? promptInput.value.replace(/^\/[A-Za-z][\w:.-]*\s*/, "") : "";
|
|
10013
|
+
promptInput.value = "/" + c.name + " " + rest;
|
|
9968
10014
|
promptInput.setSelectionRange(promptInput.value.length, promptInput.value.length);
|
|
9969
10015
|
closeSlash();
|
|
9970
10016
|
promptInput.dispatchEvent(new Event("input"));
|
|
9971
10017
|
promptInput.focus();
|
|
9972
10018
|
}
|
|
9973
10019
|
promptInput.addEventListener("input", updateSlash);
|
|
10020
|
+
const skillBtn = $("skillBtn");
|
|
10021
|
+
// mousedown would blur the textarea, whose blur handler closes the menu.
|
|
10022
|
+
skillBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
10023
|
+
skillBtn.addEventListener("click", async () => {
|
|
10024
|
+
if (!active) return;
|
|
10025
|
+
if (!slashMenu.hidden && slash.picker) { closeSlash(); return; }
|
|
10026
|
+
const all = await slashCommands(active.sessionId);
|
|
10027
|
+
slash.list = all.slice(0, 40);
|
|
10028
|
+
slash.at = 0;
|
|
10029
|
+
slash.picker = true;
|
|
10030
|
+
paintSlash();
|
|
10031
|
+
promptInput.focus(); // arrows / Enter / Escape go through its keydown
|
|
10032
|
+
});
|
|
9974
10033
|
promptInput.addEventListener("blur", () => setTimeout(closeSlash, 100));
|
|
9975
10034
|
|
|
9976
10035
|
promptInput.addEventListener("keydown", (e) => {
|
|
10036
|
+
if (!slashMenu.hidden && slash.picker && !slash.list.length && e.key === "Escape") { e.preventDefault(); closeSlash(); return; }
|
|
9977
10037
|
if (!slashMenu.hidden && slash.list.length) {
|
|
9978
10038
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
9979
10039
|
e.preventDefault();
|
|
@@ -10504,8 +10564,11 @@
|
|
|
10504
10564
|
actions.append(
|
|
10505
10565
|
actionBtn("s-edit", "✎", "Update this secret's value / the profiles it feeds", () => startEditSecret(k)),
|
|
10506
10566
|
actionBtn("s-del", "✕", "Remove from vault", async () => {
|
|
10507
|
-
|
|
10508
|
-
|
|
10567
|
+
// The row's OWNER group, not the panel's write target (see secretControls).
|
|
10568
|
+
const g = grouped && sec.ownerGroup != null ? String(sec.ownerGroup) : secretWriteGroup();
|
|
10569
|
+
const r = await fetch("/secrets?name=" + encodeURIComponent(k) + (g ? "&group=" + encodeURIComponent(g) : ""), { method: "DELETE" });
|
|
10570
|
+
const e = await vaultError(r);
|
|
10571
|
+
if (e) { showVaultError("Could not remove " + k + ": " + e); return; }
|
|
10509
10572
|
if (editingSecret === k) resetSecretForm();
|
|
10510
10573
|
await loadVault(); renderSecrets();
|
|
10511
10574
|
}),
|
|
@@ -10519,36 +10582,89 @@
|
|
|
10519
10582
|
}
|
|
10520
10583
|
return li;
|
|
10521
10584
|
}
|
|
10585
|
+
/** Read a refused vault write's message: the server answers `{error}` JSON;
|
|
10586
|
+
* a proxy may answer HTML, so fall back to the status line. */
|
|
10587
|
+
async function vaultError(r) {
|
|
10588
|
+
if (r.ok) return null;
|
|
10589
|
+
try { const j = await r.json(); if (j && j.error) return String(j.error); } catch {}
|
|
10590
|
+
return "refused (HTTP " + r.status + ")";
|
|
10591
|
+
}
|
|
10522
10592
|
/** Own secret, grouped mode: who it is lent to (PUT /secrets/share) and which
|
|
10523
|
-
* profiles inject it (PUT /secrets/profiles)
|
|
10524
|
-
*
|
|
10593
|
+
* profiles inject it (PUT /secrets/profiles), edited as chips and saved by ONE
|
|
10594
|
+
* button that only lights up once something changed. Both calls target the
|
|
10595
|
+
* secret's OWNER group, never the panel's selection: in the "Public groups"
|
|
10596
|
+
* view the panel's write target is the first public group, which sent every
|
|
10597
|
+
* row there (a 403 or a 404 for a secret living in another public group). A
|
|
10598
|
+
* refusal is shown in the card, not swallowed. */
|
|
10525
10599
|
function secretControls(sec) {
|
|
10526
10600
|
const box = document.createElement("div"); box.className = "sec-ctl";
|
|
10527
10601
|
const targets = window.shareTargets ? window.shareTargets(vaultGroups, sec.ownerGroup) : [];
|
|
10528
10602
|
const profs = profileCache.map((p) => p.name).filter((n) => !(window.isManagedProfile && window.isManagedProfile(n))).sort();
|
|
10529
|
-
const
|
|
10530
|
-
|
|
10531
|
-
|
|
10532
|
-
|
|
10603
|
+
const same = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
|
|
10604
|
+
const fields = [];
|
|
10605
|
+
const field = (label, hint, options, current) => {
|
|
10606
|
+
if (!options.length) return null;
|
|
10607
|
+
const row = document.createElement("div"); row.className = "sec-field";
|
|
10608
|
+
const l = document.createElement("span"); l.className = "sec-field-label"; l.textContent = label; l.title = hint;
|
|
10609
|
+
const chips = document.createElement("div"); chips.className = "sec-chips";
|
|
10610
|
+
const initial = (current || []).map(String);
|
|
10533
10611
|
const cbs = options.map((o) => {
|
|
10534
|
-
const
|
|
10535
|
-
const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = o.value; cb.checked =
|
|
10612
|
+
const lab = document.createElement("label"); lab.className = "sec-chip";
|
|
10613
|
+
const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = o.value; cb.checked = initial.includes(o.value);
|
|
10536
10614
|
const t = document.createElement("span"); t.textContent = o.label;
|
|
10537
|
-
|
|
10615
|
+
lab.append(cb, t); chips.appendChild(lab); return cb;
|
|
10538
10616
|
});
|
|
10539
|
-
|
|
10540
|
-
|
|
10541
|
-
|
|
10542
|
-
|
|
10543
|
-
box.appendChild(wrap);
|
|
10617
|
+
row.append(l, chips); box.appendChild(row);
|
|
10618
|
+
const f = { cbs, initial: initial.filter((v) => options.some((o) => o.value === v)), value: () => cbs.filter((c) => c.checked).map((c) => c.value) };
|
|
10619
|
+
fields.push(f);
|
|
10620
|
+
return f;
|
|
10544
10621
|
};
|
|
10545
|
-
|
|
10546
|
-
|
|
10547
|
-
|
|
10548
|
-
|
|
10549
|
-
|
|
10622
|
+
const share = field("Shared with", "Groups that may READ this secret (it stays owned by its group)",
|
|
10623
|
+
targets.map((g) => ({ value: String(g.id), label: g.name })), sec.sharedWith);
|
|
10624
|
+
const inject = field("Injected into", "Profiles whose agents receive it as an environment variable at spawn",
|
|
10625
|
+
profs.map((n) => ({ value: n, label: n })), sec.profiles);
|
|
10626
|
+
if (!fields.length) return box;
|
|
10627
|
+
const foot = document.createElement("div"); foot.className = "sec-foot";
|
|
10628
|
+
const status = document.createElement("span"); status.className = "sec-status"; status.setAttribute("role", "status");
|
|
10629
|
+
const save = document.createElement("button"); save.type = "button"; save.className = "sec-save"; save.textContent = "Save"; save.disabled = true;
|
|
10630
|
+
const dirty = () => fields.some((f) => !same(f.value(), f.initial));
|
|
10631
|
+
box.addEventListener("change", () => {
|
|
10632
|
+
save.disabled = !dirty();
|
|
10633
|
+
if (status.classList.contains("ok")) { status.textContent = ""; status.className = "sec-status"; }
|
|
10634
|
+
});
|
|
10635
|
+
save.addEventListener("click", async () => {
|
|
10636
|
+
save.disabled = true; status.className = "sec-status"; status.textContent = "Saving…";
|
|
10637
|
+
const put = (url, body) => fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" },
|
|
10638
|
+
body: JSON.stringify({ name: sec.name, group: String(sec.ownerGroup), ...body }) });
|
|
10639
|
+
const errors = [];
|
|
10640
|
+
try {
|
|
10641
|
+
if (share && !same(share.value(), share.initial)) {
|
|
10642
|
+
const e = await vaultError(await put("/secrets/share", { sharedWith: share.value() }));
|
|
10643
|
+
if (e) errors.push("Sharing not saved: " + e);
|
|
10644
|
+
}
|
|
10645
|
+
if (inject && !same(inject.value(), inject.initial)) {
|
|
10646
|
+
const e = await vaultError(await put("/secrets/profiles", { profiles: inject.value() }));
|
|
10647
|
+
if (e) errors.push("Profiles not saved: " + e);
|
|
10648
|
+
}
|
|
10649
|
+
} catch (err) { errors.push("Network error: " + (err && err.message || err)); }
|
|
10650
|
+
if (errors.length) {
|
|
10651
|
+
// Keep the user's ticks on screen so they can retry or undo — a reload
|
|
10652
|
+
// here would silently reset them to the server's state.
|
|
10653
|
+
status.className = "sec-status err"; status.textContent = errors.join(" · ");
|
|
10654
|
+
save.disabled = false;
|
|
10655
|
+
return;
|
|
10656
|
+
}
|
|
10657
|
+
await loadVault(); renderSecrets();
|
|
10658
|
+
});
|
|
10659
|
+
foot.append(status, save); box.appendChild(foot);
|
|
10550
10660
|
return box;
|
|
10551
10661
|
}
|
|
10662
|
+
/** Panel-level refusal (add / update / delete): shown above the list until the
|
|
10663
|
+
* next successful write or the panel is reopened. */
|
|
10664
|
+
function showVaultError(msg) {
|
|
10665
|
+
const el = $("secretsErr");
|
|
10666
|
+
el.textContent = msg || ""; el.hidden = !msg;
|
|
10667
|
+
}
|
|
10552
10668
|
function renderSecrets() {
|
|
10553
10669
|
const ul = $("secretsList");
|
|
10554
10670
|
ul.innerHTML = "";
|
|
@@ -10689,6 +10805,7 @@
|
|
|
10689
10805
|
await Promise.all([loadVault(), loadProfilesInto()]);
|
|
10690
10806
|
renderSecrets();
|
|
10691
10807
|
resetSecretForm();
|
|
10808
|
+
showVaultError("");
|
|
10692
10809
|
$("secretsReload").hidden = true;
|
|
10693
10810
|
$("secretsOverlay").hidden = false;
|
|
10694
10811
|
$("secretKey").focus();
|
|
@@ -11607,8 +11724,31 @@
|
|
|
11607
11724
|
const scroll = facEl("div", { class: "fp-scroll" });
|
|
11608
11725
|
m.appendChild(scroll);
|
|
11609
11726
|
const title = facEl("input", { type: "text", id: "fpTitle", placeholder: "Daily marketing summary", autocomplete: "off" });
|
|
11610
|
-
|
|
11611
|
-
|
|
11727
|
+
// The technical name agents call it by (`/<name>:<name>`): proposed from the
|
|
11728
|
+
// name above until the person types their own, then left alone. Unlike the
|
|
11729
|
+
// displayed name it can NOT be changed later (it is the repo, the plugin and
|
|
11730
|
+
// every attachment's key), so it is chosen here or never.
|
|
11731
|
+
const slug = facEl("input", { type: "text", id: "fpSlug", placeholder: "daily-marketing-summary", autocomplete: "off", spellcheck: "false", maxlength: "40", style: "font-family:var(--mono)" });
|
|
11732
|
+
let slugTouched = false;
|
|
11733
|
+
const slugHint = facEl("div", { class: "fp-hint" });
|
|
11734
|
+
const paintSlug = () => {
|
|
11735
|
+
const sl = facSlug(slug.value);
|
|
11736
|
+
const taken = sl && fac.data && fac.data.skills.some((x) => x.name === sl);
|
|
11737
|
+
slugHint.textContent = !sl ? "Lowercase letters, digits and dashes. It cannot be changed later."
|
|
11738
|
+
: sl.length < 2 ? "At least 2 characters."
|
|
11739
|
+
: taken ? `${sl} is already used: it will be saved as ${sl}-2 (or the next free number).`
|
|
11740
|
+
: `Agents will call it /${sl}:${sl}. It cannot be changed later.`;
|
|
11741
|
+
};
|
|
11742
|
+
title.addEventListener("input", () => { if (!slugTouched) slug.value = facSlug(title.value); paintSlug(); });
|
|
11743
|
+
slug.addEventListener("input", () => {
|
|
11744
|
+
// Light clean-up while typing (a trailing dash must survive, "my-" is on its
|
|
11745
|
+
// way to "my-skill"); the full facSlug runs when it is sent.
|
|
11746
|
+
const v = slug.value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+/, "").replace(/--+/g, "-");
|
|
11747
|
+
if (v !== slug.value) slug.value = v;
|
|
11748
|
+
slugTouched = !!v; // emptied → follows the name again (at its next edit, or on Create)
|
|
11749
|
+
paintSlug();
|
|
11750
|
+
});
|
|
11751
|
+
paintSlug();
|
|
11612
11752
|
const desc = facEl("textarea", { rows: "4", id: "fpDescIn",
|
|
11613
11753
|
placeholder: "Writes a short summary of yesterday's marketing numbers (traffic, signups, campaigns) and posts it in #marketing. Use it every morning, or when someone asks for the marketing summary." });
|
|
11614
11754
|
const tags = facEl("input", { type: "text", placeholder: "reporting, slack" });
|
|
@@ -11624,7 +11764,7 @@
|
|
|
11624
11764
|
const v = (scroll.querySelector('input[name="fpScope"]:checked') || {}).value || "me";
|
|
11625
11765
|
const scope = v === "groups" ? { groups: [...ids] } : v;
|
|
11626
11766
|
try {
|
|
11627
|
-
const out = await facCall("POST", "/factory/skills", { title: title.value.trim(), description: desc.value.trim(), scope, tags: tags.value });
|
|
11767
|
+
const out = await facCall("POST", "/factory/skills", { title: title.value.trim(), name: facSlug(slug.value) || facSlug(title.value) || undefined, description: desc.value.trim(), scope, tags: tags.value });
|
|
11628
11768
|
await facLoad();
|
|
11629
11769
|
facEdit(out.skill.id, true);
|
|
11630
11770
|
} catch (e) { facMsg(e.message, true); }
|
|
@@ -11633,7 +11773,9 @@
|
|
|
11633
11773
|
facEl("div", { class: "fp-bar" }, [facEl("button", { class: "fp-link", text: "← Skills", onclick: () => facGo("list") })]),
|
|
11634
11774
|
facEl("h1", { text: "New skill" }),
|
|
11635
11775
|
facEl("div", { class: "fp-sub", text: "A short set of instructions an agent follows to do one job." }),
|
|
11636
|
-
facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpTitle", text: "Name" }), title,
|
|
11776
|
+
facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpTitle", text: "Name" }), title,
|
|
11777
|
+
facEl("div", { class: "fp-hint", text: "What people see. You can rename it any time." })]),
|
|
11778
|
+
facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpSlug", text: "Technical name" }), slug, slugHint]),
|
|
11637
11779
|
facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpDescIn", text: "What does it do, and when should it be used?" }), desc,
|
|
11638
11780
|
facEl("div", { class: "fp-hint", text: "An agent reads this to decide to use the skill. You can refine it later." })]),
|
|
11639
11781
|
facEl("div", { class: "fp-field" }, [facEl("span", { class: "fp-label", text: "Shared with" }),
|
|
@@ -11752,7 +11894,29 @@
|
|
|
11752
11894
|
const ed = fac.ed;
|
|
11753
11895
|
const dirty = () => ed.desc !== ed.saved.desc || ed.body !== ed.saved.body;
|
|
11754
11896
|
// Center content per tab
|
|
11755
|
-
|
|
11897
|
+
// The name is edited in place: it is only the DISPLAY title (PUT …/meta), the
|
|
11898
|
+
// technical name agents call it by (`<name>:<name>`) stays put, so attached
|
|
11899
|
+
// agents and typed `/` commands keep working after a rename.
|
|
11900
|
+
const nameIn = facEl("input", { type: "text", class: "fp-title-in", value: facTitle(s), maxlength: "80",
|
|
11901
|
+
"aria-label": "Skill name", title: `Click to rename. Agents still call it ${s.name}:${s.name}.` });
|
|
11902
|
+
const saveName = async () => {
|
|
11903
|
+
const v = nameIn.value.trim();
|
|
11904
|
+
if (!v) { nameIn.value = facTitle(s); return; }
|
|
11905
|
+
if (v === facTitle(s)) return;
|
|
11906
|
+
try {
|
|
11907
|
+
await facCall("PUT", `/factory/skills/${s.id}/meta`, { title: v });
|
|
11908
|
+
await facLoad();
|
|
11909
|
+
// A blur caused by clicking another skill in the list: that click already
|
|
11910
|
+
// rendered the other one — repainting here would race it.
|
|
11911
|
+
if (fac.view === "edit" && fac.sel && fac.sel.id === s.id) facRender();
|
|
11912
|
+
} catch (e) { nameIn.value = facTitle(s); facMsg(e.message, true); }
|
|
11913
|
+
};
|
|
11914
|
+
nameIn.addEventListener("keydown", (e) => {
|
|
11915
|
+
if (e.key === "Enter") { e.preventDefault(); nameIn.blur(); }
|
|
11916
|
+
if (e.key === "Escape") { nameIn.value = facTitle(s); nameIn.blur(); }
|
|
11917
|
+
});
|
|
11918
|
+
nameIn.addEventListener("blur", saveName);
|
|
11919
|
+
center.appendChild(facEl("div", { class: "fp-doc-head" }, [facAvatar(s, "lg"), facEl("div", { style: "flex:1;min-width:0" }, [nameIn,
|
|
11756
11920
|
facEl("div", { class: "fp-sub" }, [
|
|
11757
11921
|
facStatus(s).includes("draft") ? facEl("span", { class: "fp-ver draft", text: "draft changes" }) : facEl("span", { class: "fp-ver live", text: "saved" }),
|
|
11758
11922
|
" ", ...facSharedLabels(s.owner).map((x) => facEl("span", { class: "fp-gp", text: x }))])])]));
|
|
@@ -12024,8 +12188,12 @@
|
|
|
12024
12188
|
// AGENT replacing a credential silently).
|
|
12025
12189
|
const keepValue = editingSecret && value === "";
|
|
12026
12190
|
if (!keepValue) {
|
|
12027
|
-
await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name, value, overwrite: true })) });
|
|
12191
|
+
const r = await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name, value, overwrite: true })) });
|
|
12192
|
+
const err = await vaultError(r);
|
|
12193
|
+
// Refused: say so and keep the form filled, rather than clearing it as if saved.
|
|
12194
|
+
if (err) { showVaultError("Could not save " + name + ": " + err); return; }
|
|
12028
12195
|
}
|
|
12196
|
+
showVaultError("");
|
|
12029
12197
|
// Grouped mode binds profiles per row (PUT /secrets/profiles) and hides the
|
|
12030
12198
|
// ticks: syncing here would read an empty selection and detach everything.
|
|
12031
12199
|
if (window.secretsPanelMode(vaultGroups) === "flat") await syncSecretToProfiles(name, wanted); // refreshes profileCache
|