@frockbot/plugin-bot-template 0.0.0 → 0.1.1

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.
@@ -0,0 +1,325 @@
1
+ <script setup lang="ts">
2
+ // Importing a Bot from a shared template.
3
+ //
4
+ // The review card is the whole point of this surface. A template is prose from
5
+ // a stranger, and it names Packages, Skills and Routines that are about to
6
+ // become durable state in this User's account — so it is shown as four honest
7
+ // sections before anything happens, and `Create the Bot` is the only control
8
+ // that writes. Planning is a read; the record stays `planned` until it is
9
+ // pressed.
10
+ //
11
+ // What the card never offers is a way to connect anything. An import creates no
12
+ // Connection and no Assignment, so "Needs your own Connection" is a list of
13
+ // things the User must go and do themselves, not a checkbox.
14
+ import { UiButton, UiField, UiIcon } from "@frockbot/client-ui";
15
+ import { computed, inject, ref } from "vue";
16
+ import { botTemplateStateKey } from "./state.js";
17
+
18
+ const providedState = inject(botTemplateStateKey);
19
+ if (!providedState) {
20
+ throw new Error("Bot template client services were not provided");
21
+ }
22
+ const templates = providedState;
23
+ const shareLink = ref("");
24
+
25
+ const review = computed(() => templates.value.reviewing);
26
+
27
+ const willInstall = computed(() =>
28
+ (review.value?.packages ?? []).filter(
29
+ (entry) => entry.status === "will-install",
30
+ ),
31
+ );
32
+ const alreadyInstalled = computed(() =>
33
+ (review.value?.packages ?? []).filter(
34
+ (entry) => entry.status === "already-installed",
35
+ ),
36
+ );
37
+ const missing = computed(() =>
38
+ (review.value?.packages ?? []).filter((entry) => entry.status === "missing"),
39
+ );
40
+
41
+ const failedStep = computed(() =>
42
+ review.value?.steps.find((step) => step.status === "failed"),
43
+ );
44
+
45
+ /**
46
+ * A pasted link carries the share id in its last path segment. Anything else is
47
+ * handed over untouched, so a bare share id works too.
48
+ */
49
+ function shareIdFrom(value: string): string {
50
+ const trimmed = value.trim();
51
+ if (!trimmed) return "";
52
+ const url = URL.parse(trimmed);
53
+ if (!url) return trimmed;
54
+ const last = url.pathname.split("/").filter(Boolean).at(-1);
55
+ return last ? decodeURIComponent(last) : trimmed;
56
+ }
57
+
58
+ async function plan(): Promise<void> {
59
+ const shareId = shareIdFrom(shareLink.value);
60
+ if (!shareId) return;
61
+ await templates.value.planImport(shareId);
62
+ }
63
+ </script>
64
+
65
+ <template>
66
+ <section class="import">
67
+ <header class="import__header">
68
+ <span class="import__icon" aria-hidden="true"
69
+ ><UiIcon name="plus"
70
+ /></span>
71
+ <span class="import__intro">
72
+ <strong>Import a Bot template</strong>
73
+ <small>
74
+ Paste a template link to see exactly what it would create before
75
+ anything happens. Importing never brings across Memory, credentials,
76
+ Connections or Assignments.
77
+ </small>
78
+ </span>
79
+ </header>
80
+
81
+ <p v-if="templates.importError" class="import__error" role="alert">
82
+ {{ templates.importError }}
83
+ </p>
84
+
85
+ <div v-if="!review" class="import__form">
86
+ <UiField label="Template link">
87
+ <input
88
+ v-model="shareLink"
89
+ maxlength="500"
90
+ placeholder="https://…/templates/v1/…"
91
+ />
92
+ </UiField>
93
+ <UiButton
94
+ type="button"
95
+ :disabled="templates.busy || !shareLink.trim()"
96
+ @click="plan"
97
+ >
98
+ {{ templates.busy ? "Reading…" : "Review the template" }}
99
+ </UiButton>
100
+ </div>
101
+
102
+ <article v-else class="review">
103
+ <div class="review__head">
104
+ <span class="review__text">
105
+ <strong>{{ review.botName }}</strong>
106
+ <small>
107
+ {{
108
+ review.status === "planned"
109
+ ? "Nothing has been created yet."
110
+ : `Import ${review.status}.`
111
+ }}
112
+ </small>
113
+ </span>
114
+ </div>
115
+
116
+ <section class="review__section">
117
+ <h4>Will create</h4>
118
+ <ul>
119
+ <li>The Bot “{{ review.botName }}”</li>
120
+ <li v-if="review.skills.length > 0">
121
+ {{ review.skills.length }} Skill(s):
122
+ {{ review.skills.join(", ") }}
123
+ </li>
124
+ <li v-for="routine in review.routines" :key="routine.slug">
125
+ Routine “{{ routine.slug }}”{{
126
+ routine.disabled ? " — created paused, with no webhook key" : ""
127
+ }}
128
+ </li>
129
+ </ul>
130
+ </section>
131
+
132
+ <section v-if="willInstall.length > 0" class="review__section">
133
+ <h4>Will install</h4>
134
+ <ul>
135
+ <li v-for="entry in willInstall" :key="entry.catalogId">
136
+ {{ entry.displayName }} ({{ entry.version }})
137
+ </li>
138
+ </ul>
139
+ </section>
140
+
141
+ <section v-if="alreadyInstalled.length > 0" class="review__section">
142
+ <h4>Already installed</h4>
143
+ <ul>
144
+ <li v-for="entry in alreadyInstalled" :key="entry.catalogId">
145
+ {{ entry.displayName }}
146
+ </li>
147
+ </ul>
148
+ </section>
149
+
150
+ <section v-if="missing.length > 0" class="review__section">
151
+ <h4>Missing from your catalog</h4>
152
+ <ul>
153
+ <li v-for="entry in missing" :key="entry.catalogId">
154
+ {{ entry.displayName }} — not in the Catalog generation you are
155
+ pinned to, so it will be skipped.
156
+ </li>
157
+ </ul>
158
+ </section>
159
+
160
+ <section v-if="review.connections.length > 0" class="review__section">
161
+ <h4>Needs your own Connection</h4>
162
+ <ul>
163
+ <li v-for="entry in review.connections" :key="entry.name">
164
+ {{ entry.name
165
+ }}<template v-if="entry.url"> — {{ entry.url }}</template>
166
+ <small v-if="entry.hint"> {{ entry.hint }}</small>
167
+ </li>
168
+ </ul>
169
+ <p class="import__note">
170
+ None of these is created for you. Add each one yourself, then assign
171
+ it to the Bot.
172
+ </p>
173
+ </section>
174
+
175
+ <p v-if="review.failure" class="import__error" role="alert">
176
+ {{ review.failure }}
177
+ </p>
178
+ <p v-if="failedStep" class="import__note">
179
+ The import stopped at “{{ failedStep.key }}”. Everything before it is
180
+ already in place; confirming again retries from there.
181
+ </p>
182
+
183
+ <div class="review__actions">
184
+ <UiButton
185
+ v-if="review.status !== 'applied'"
186
+ type="button"
187
+ variant="primary"
188
+ :disabled="templates.busy"
189
+ @click="templates.applyImport(review.importId)"
190
+ >
191
+ {{
192
+ templates.busy
193
+ ? "Creating…"
194
+ : review.status === "failed"
195
+ ? "Retry the import"
196
+ : "Create the Bot"
197
+ }}
198
+ </UiButton>
199
+ <UiButton type="button" @click="templates.dismissReview()">
200
+ {{ review.status === "applied" ? "Done" : "Cancel" }}
201
+ </UiButton>
202
+ </div>
203
+ </article>
204
+ </section>
205
+ </template>
206
+
207
+ <style scoped>
208
+ .import {
209
+ display: flex;
210
+ flex-direction: column;
211
+ gap: 12px;
212
+ }
213
+
214
+ .import__header {
215
+ display: flex;
216
+ align-items: center;
217
+ gap: 10px;
218
+ }
219
+
220
+ .import__icon {
221
+ display: grid;
222
+ width: var(--frock-avatar-sm);
223
+ height: var(--frock-avatar-sm);
224
+ flex: 0 0 auto;
225
+ place-items: center;
226
+ border-radius: 8px;
227
+ color: var(--frock-action-primary);
228
+ background: var(--frock-surface);
229
+ box-shadow: inset 0 0 0 1px var(--frock-border);
230
+ }
231
+
232
+ .import__intro {
233
+ display: flex;
234
+ min-width: 0;
235
+ flex: 1 1 auto;
236
+ flex-direction: column;
237
+ }
238
+
239
+ .import__intro strong {
240
+ color: var(--frock-text);
241
+ font-size: var(--frock-text-md);
242
+ font-weight: 600;
243
+ }
244
+
245
+ .import__intro small,
246
+ .import__note {
247
+ color: var(--frock-text-muted);
248
+ font-size: var(--frock-text-sm);
249
+ }
250
+
251
+ .import__error {
252
+ color: var(--frock-danger-text);
253
+ font-size: var(--frock-text-sm);
254
+ }
255
+
256
+ .import__form {
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 8px;
260
+ }
261
+
262
+ .review {
263
+ display: flex;
264
+ flex-direction: column;
265
+ gap: 10px;
266
+ border: 1px solid var(--frock-border);
267
+ border-radius: var(--frock-radius-card);
268
+ padding: 12px;
269
+ background: var(--frock-surface-subtle);
270
+ }
271
+
272
+ .review__head {
273
+ display: flex;
274
+ align-items: center;
275
+ gap: 10px;
276
+ }
277
+
278
+ .review__text {
279
+ display: flex;
280
+ min-width: 0;
281
+ flex: 1 1 auto;
282
+ flex-direction: column;
283
+ }
284
+
285
+ .review__text strong {
286
+ color: var(--frock-text);
287
+ font-size: var(--frock-text-md);
288
+ font-weight: 600;
289
+ }
290
+
291
+ .review__text small {
292
+ color: var(--frock-text-muted);
293
+ font-size: var(--frock-text-sm);
294
+ }
295
+
296
+ .review__section h4 {
297
+ margin: 0 0 4px;
298
+ color: var(--frock-text-subtle);
299
+ font-size: var(--frock-text-sm);
300
+ font-weight: 600;
301
+ }
302
+
303
+ .review__section ul {
304
+ display: flex;
305
+ flex-direction: column;
306
+ gap: 4px;
307
+ margin: 0;
308
+ padding-left: 18px;
309
+ }
310
+
311
+ .review__section li {
312
+ color: var(--frock-text);
313
+ font-size: var(--frock-text-sm);
314
+ }
315
+
316
+ .review__section li small {
317
+ color: var(--frock-text-muted);
318
+ }
319
+
320
+ .review__actions {
321
+ display: flex;
322
+ flex-wrap: wrap;
323
+ gap: 8px;
324
+ }
325
+ </style>
@@ -0,0 +1,338 @@
1
+ <script setup lang="ts">
2
+ // Bot templates, in Bot settings.
3
+ //
4
+ // The User half of the export: staging packs a recipe privately, and choosing
5
+ // a visibility is a separate, deliberate click here — "Publication beyond the
6
+ // authoring User is a User action." The link is only shown once a share is
7
+ // actually `link` or `public`, so a copied URL always resolves.
8
+ import { UiButton } from "@frockbot/client-ui";
9
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
10
+ import type {
11
+ TemplateShareRecordV1,
12
+ TemplateVisibilityV1,
13
+ } from "@frockbot/template-core";
14
+ import { computed, inject, ref, watch } from "vue";
15
+ import { templateSharePathV1 } from "../shared.js";
16
+ import { botTemplateStateKey } from "./state.js";
17
+
18
+ const providedWeb = inject(frockBotWebDataKey);
19
+ const providedState = inject(botTemplateStateKey);
20
+ if (!providedWeb || !providedState) {
21
+ throw new Error("Bot template client services were not provided");
22
+ }
23
+ const web = providedWeb;
24
+ const templates = providedState;
25
+ const copied = ref<string>();
26
+ const flowOpen = ref(false);
27
+
28
+ const botId = computed(() => web.value.activeBotId);
29
+
30
+ const shares = computed(() =>
31
+ templates.value.shares.filter((share) => share.botId === botId.value),
32
+ );
33
+
34
+ const visibilities: {
35
+ value: TemplateVisibilityV1;
36
+ label: string;
37
+ hint: string;
38
+ }[] = [
39
+ {
40
+ value: "private",
41
+ label: "Private",
42
+ hint: "Only you. The recipe is staged and shared with nobody.",
43
+ },
44
+ {
45
+ value: "link",
46
+ label: "Anyone with the link",
47
+ hint: "Unlisted. Anyone holding the link can read the recipe.",
48
+ },
49
+ {
50
+ value: "public",
51
+ label: "Public",
52
+ hint: "Readable by anyone, and listable.",
53
+ },
54
+ ];
55
+
56
+ watch(
57
+ botId,
58
+ (id) => {
59
+ if (id && !templates.value.loaded) void templates.value.load();
60
+ },
61
+ { immediate: true },
62
+ );
63
+
64
+ function shareUrl(share: TemplateShareRecordV1): string {
65
+ return `${globalThis.location?.origin ?? ""}${templateSharePathV1(share.shareId)}`;
66
+ }
67
+
68
+ function describe(share: TemplateShareRecordV1): string {
69
+ if (share.revokedAt) return `Revoked ${share.revokedAt}`;
70
+ return (
71
+ visibilities.find((option) => option.value === share.visibility)?.label ??
72
+ share.visibility
73
+ );
74
+ }
75
+
76
+ async function copyLink(share: TemplateShareRecordV1): Promise<void> {
77
+ try {
78
+ await navigator.clipboard.writeText(shareUrl(share));
79
+ copied.value = share.shareId;
80
+ } catch {
81
+ // Clipboard access is a progressive enhancement; the link is on screen
82
+ // either way, so a refusal costs the convenience and nothing else.
83
+ copied.value = undefined;
84
+ }
85
+ }
86
+ </script>
87
+
88
+ <template>
89
+ <section class="templates">
90
+ <UiButton
91
+ class="templates__open"
92
+ type="button"
93
+ :aria-expanded="flowOpen"
94
+ @click="flowOpen = !flowOpen"
95
+ >
96
+ Share as template
97
+ </UiButton>
98
+
99
+ <div v-if="flowOpen" class="templates__flow">
100
+ <header class="templates__header">
101
+ <span class="templates__intro">
102
+ <strong>Share this Bot</strong>
103
+ <small>
104
+ Packs the profile, Bot-authored Skills, Routines, and required
105
+ Packages. Memory, credentials, Connections, Assignments, and
106
+ Computer files stay private.
107
+ </small>
108
+ </span>
109
+ <UiButton
110
+ type="button"
111
+ :disabled="!botId || templates.busy"
112
+ @click="botId && templates.stage(botId)"
113
+ >
114
+ {{ templates.busy ? "Packing…" : "Pack template" }}
115
+ </UiButton>
116
+ </header>
117
+
118
+ <p v-if="templates.error" class="templates__error" role="alert">
119
+ {{ templates.error }}
120
+ </p>
121
+
122
+ <p
123
+ v-if="templates.summary && templates.openShareId"
124
+ class="templates__summary"
125
+ >
126
+ Packed {{ templates.summary.skills }} Skill(s),
127
+ {{ templates.summary.routines }} Routine(s),
128
+ {{ templates.summary.packages }} Package(s) and
129
+ {{ templates.summary.publicServers }} public MCP server(s).
130
+ <template v-if="templates.summary.needsConnection > 0">
131
+ {{ templates.summary.needsConnection }} server(s) are left as a
132
+ placeholder the importer fills with their own Connection.
133
+ </template>
134
+ Memory, credentials, Connections and Assignments were not included.
135
+ </p>
136
+
137
+ <p
138
+ v-if="templates.loaded && shares.length === 0"
139
+ class="templates__empty"
140
+ >
141
+ No template has been shared from this Bot yet.
142
+ </p>
143
+
144
+ <article
145
+ v-for="share in shares"
146
+ :key="share.shareId"
147
+ class="template-card"
148
+ >
149
+ <div class="template-card__head">
150
+ <span class="template-card__text">
151
+ <strong>{{ describe(share) }}</strong>
152
+ <small>Packed {{ share.createdAt }}</small>
153
+ </span>
154
+ </div>
155
+
156
+ <fieldset v-if="!share.revokedAt" class="template-card__visibility">
157
+ <legend>Who can read it</legend>
158
+ <label v-for="option in visibilities" :key="option.value">
159
+ <input
160
+ type="radio"
161
+ :name="`visibility-${share.shareId}`"
162
+ :value="option.value"
163
+ :checked="share.visibility === option.value"
164
+ :disabled="templates.busy"
165
+ @change="templates.setVisibility(share.shareId, option.value)"
166
+ />
167
+ <span>
168
+ <strong>{{ option.label }}</strong>
169
+ <small>{{ option.hint }}</small>
170
+ </span>
171
+ </label>
172
+ </fieldset>
173
+
174
+ <p
175
+ v-if="!share.revokedAt && share.visibility !== 'private'"
176
+ class="template-card__link"
177
+ >
178
+ <code>{{ shareUrl(share) }}</code>
179
+ </p>
180
+
181
+ <div class="template-card__actions">
182
+ <UiButton
183
+ v-if="!share.revokedAt && share.visibility !== 'private'"
184
+ type="button"
185
+ @click="copyLink(share)"
186
+ >
187
+ {{ copied === share.shareId ? "Copied" : "Copy link" }}
188
+ </UiButton>
189
+ <UiButton
190
+ v-if="!share.revokedAt"
191
+ type="button"
192
+ variant="danger"
193
+ :disabled="templates.busy"
194
+ @click="templates.revoke(share.shareId)"
195
+ >
196
+ Revoke
197
+ </UiButton>
198
+ </div>
199
+ </article>
200
+ </div>
201
+ </section>
202
+ </template>
203
+
204
+ <style scoped>
205
+ .templates {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 12px;
209
+ }
210
+
211
+ .templates__open {
212
+ width: 100%;
213
+ }
214
+
215
+ .templates__flow {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 12px;
219
+ padding-top: 4px;
220
+ }
221
+
222
+ .templates__header {
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 10px;
226
+ }
227
+
228
+ .templates__intro {
229
+ display: flex;
230
+ min-width: 0;
231
+ flex: 1 1 auto;
232
+ flex-direction: column;
233
+ }
234
+
235
+ .templates__intro strong {
236
+ color: var(--frock-text);
237
+ font-size: var(--frock-text-md);
238
+ font-weight: 600;
239
+ }
240
+
241
+ .templates__intro small,
242
+ .templates__empty,
243
+ .templates__summary {
244
+ color: var(--frock-text-muted);
245
+ font-size: var(--frock-text-sm);
246
+ }
247
+
248
+ .templates__error {
249
+ color: var(--frock-danger-text);
250
+ font-size: var(--frock-text-sm);
251
+ }
252
+
253
+ .template-card {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 10px;
257
+ border: 1px solid var(--frock-border);
258
+ border-radius: var(--frock-radius-card);
259
+ padding: 12px;
260
+ background: var(--frock-surface-subtle);
261
+ }
262
+
263
+ .template-card__head {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 10px;
267
+ }
268
+
269
+ .template-card__text {
270
+ display: flex;
271
+ min-width: 0;
272
+ flex: 1 1 auto;
273
+ flex-direction: column;
274
+ }
275
+
276
+ .template-card__text strong {
277
+ color: var(--frock-text);
278
+ font-size: var(--frock-text-md);
279
+ font-weight: 600;
280
+ }
281
+
282
+ .template-card__text small {
283
+ color: var(--frock-text-muted);
284
+ font-size: var(--frock-text-sm);
285
+ }
286
+
287
+ .template-card__visibility {
288
+ display: flex;
289
+ flex-direction: column;
290
+ gap: 6px;
291
+ border: 0;
292
+ margin: 0;
293
+ padding: 0;
294
+ }
295
+
296
+ .template-card__visibility legend {
297
+ color: var(--frock-text-subtle);
298
+ font-size: var(--frock-text-sm);
299
+ }
300
+
301
+ .template-card__visibility label {
302
+ display: flex;
303
+ align-items: flex-start;
304
+ gap: 8px;
305
+ }
306
+
307
+ .template-card__visibility label span {
308
+ display: flex;
309
+ flex-direction: column;
310
+ }
311
+
312
+ .template-card__visibility label strong {
313
+ color: var(--frock-text);
314
+ font-size: var(--frock-text-sm);
315
+ font-weight: 600;
316
+ }
317
+
318
+ .template-card__visibility label small {
319
+ color: var(--frock-text-muted);
320
+ font-size: var(--frock-text-sm);
321
+ }
322
+
323
+ .template-card__link {
324
+ overflow-x: auto;
325
+ margin: 0;
326
+ }
327
+
328
+ .template-card__link code {
329
+ color: var(--frock-text-muted);
330
+ font-size: var(--frock-text-sm);
331
+ }
332
+
333
+ .template-card__actions {
334
+ display: flex;
335
+ flex-wrap: wrap;
336
+ gap: 8px;
337
+ }
338
+ </style>