thepopebot 1.2.72-beta.21 → 1.2.72-beta.22

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.
@@ -202,6 +202,21 @@ export async function triggerUpgrade() {
202
202
  return { success: true };
203
203
  }
204
204
 
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+ // Feature flags
207
+ // ─────────────────────────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Get feature flags based on environment configuration.
211
+ * Features gated behind CLAUDE_CODE_OAUTH_TOKEN (Claude Pro/Max subscription features).
212
+ * @returns {Promise<{ claudeWorkspace: boolean }>}
213
+ */
214
+ export async function getFeatureFlags() {
215
+ await requireAuth();
216
+ const claudeWorkspace = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
217
+ return { claudeWorkspace };
218
+ }
219
+
205
220
  // ─────────────────────────────────────────────────────────────────────────────
206
221
  // API Key actions
207
222
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState, useEffect } from "react";
4
4
  import { CirclePlusIcon, PanelLeftIcon, MessageIcon, BellIcon, SwarmIcon, ArrowUpCircleIcon, LifeBuoyIcon, GitPullRequestIcon } from "./icons.js";
5
- import { getUnreadNotificationCount, getPullRequestCount, getAppVersion } from "../actions.js";
5
+ import { getUnreadNotificationCount, getPullRequestCount, getAppVersion, getFeatureFlags } from "../actions.js";
6
6
  import { SidebarHistory } from "./sidebar-history.js";
7
7
  import { SidebarUserNav } from "./sidebar-user-nav.js";
8
8
  import { UpgradeDialog } from "./upgrade-dialog.js";
@@ -30,6 +30,7 @@ function AppSidebar({ user }) {
30
30
  const [updateAvailable, setUpdateAvailable] = useState(null);
31
31
  const [changelog, setChangelog] = useState(null);
32
32
  const [upgradeOpen, setUpgradeOpen] = useState(false);
33
+ const [features, setFeatures] = useState({ claudeWorkspace: false });
33
34
  useEffect(() => {
34
35
  function fetchCounts() {
35
36
  getUnreadNotificationCount().then((count) => setUnreadCount(count)).catch(() => {
@@ -41,6 +42,10 @@ function AppSidebar({ user }) {
41
42
  const interval = setInterval(fetchCounts, 10 * 60 * 1e3);
42
43
  return () => clearInterval(interval);
43
44
  }, []);
45
+ useEffect(() => {
46
+ getFeatureFlags().then((flags) => setFeatures(flags)).catch(() => {
47
+ });
48
+ }, []);
44
49
  useEffect(() => {
45
50
  getAppVersion().then(({ version: version2, updateAvailable: updateAvailable2, changelog: changelog2 }) => {
46
51
  setVersion(version2);
@@ -77,8 +82,10 @@ function AppSidebar({ user }) {
77
82
  /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
78
83
  SidebarMenuButton,
79
84
  {
85
+ href: "/",
80
86
  className: collapsed ? "justify-center" : "",
81
- onClick: () => {
87
+ onClick: (e) => {
88
+ e.preventDefault();
82
89
  navigateToChat(null);
83
90
  setOpenMobile(false);
84
91
  },
@@ -94,10 +101,8 @@ function AppSidebar({ user }) {
94
101
  /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
95
102
  SidebarMenuButton,
96
103
  {
104
+ href: "/chats",
97
105
  className: collapsed ? "justify-center" : "",
98
- onClick: () => {
99
- window.location.href = "/chats";
100
- },
101
106
  children: [
102
107
  /* @__PURE__ */ jsx(MessageIcon, { size: 16 }),
103
108
  !collapsed && /* @__PURE__ */ jsx("span", { children: "Chats" })
@@ -110,10 +115,8 @@ function AppSidebar({ user }) {
110
115
  /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
111
116
  SidebarMenuButton,
112
117
  {
118
+ href: "/swarm",
113
119
  className: collapsed ? "justify-center" : "",
114
- onClick: () => {
115
- window.location.href = "/swarm";
116
- },
117
120
  children: [
118
121
  /* @__PURE__ */ jsx(SwarmIcon, { size: 16 }),
119
122
  !collapsed && /* @__PURE__ */ jsx("span", { children: "Swarm" })
@@ -126,10 +129,8 @@ function AppSidebar({ user }) {
126
129
  /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
127
130
  SidebarMenuButton,
128
131
  {
132
+ href: "/notifications",
129
133
  className: collapsed ? "justify-center" : "",
130
- onClick: () => {
131
- window.location.href = "/notifications";
132
- },
133
134
  children: [
134
135
  /* @__PURE__ */ jsx(BellIcon, { size: 16 }),
135
136
  !collapsed && /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
@@ -146,10 +147,8 @@ function AppSidebar({ user }) {
146
147
  /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
147
148
  SidebarMenuButton,
148
149
  {
150
+ href: "/pull-requests",
149
151
  className: collapsed ? "justify-center" : "",
150
- onClick: () => {
151
- window.location.href = "/pull-requests";
152
- },
153
152
  children: [
154
153
  /* @__PURE__ */ jsx(GitPullRequestIcon, { size: 16 }),
155
154
  !collapsed && /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
@@ -192,8 +191,10 @@ function AppSidebar({ user }) {
192
191
  /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
193
192
  SidebarMenuButton,
194
193
  {
194
+ href: "https://www.skool.com/ai-architects",
195
+ target: "_blank",
196
+ rel: "noopener noreferrer",
195
197
  className: collapsed ? "justify-center" : "",
196
- onClick: () => window.open("https://www.skool.com/ai-architects", "_blank"),
197
198
  children: [
198
199
  /* @__PURE__ */ jsx(LifeBuoyIcon, { size: 16 }),
199
200
  !collapsed && /* @__PURE__ */ jsx("span", { children: "Support" })
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
4
  import { CirclePlusIcon, PanelLeftIcon, MessageIcon, BellIcon, SwarmIcon, ArrowUpCircleIcon, LifeBuoyIcon, GitPullRequestIcon } from './icons.js';
5
- import { getUnreadNotificationCount, getPullRequestCount, getAppVersion } from '../actions.js';
5
+ import { getUnreadNotificationCount, getPullRequestCount, getAppVersion, getFeatureFlags } from '../actions.js';
6
6
  import { SidebarHistory } from './sidebar-history.js';
7
7
  import { SidebarUserNav } from './sidebar-user-nav.js';
8
8
  import { UpgradeDialog } from './upgrade-dialog.js';
@@ -31,6 +31,7 @@ export function AppSidebar({ user }) {
31
31
  const [updateAvailable, setUpdateAvailable] = useState(null);
32
32
  const [changelog, setChangelog] = useState(null);
33
33
  const [upgradeOpen, setUpgradeOpen] = useState(false);
34
+ const [features, setFeatures] = useState({ claudeWorkspace: false });
34
35
 
35
36
  // Fetch badge counts (notifications + PRs) — run immediately, then every 10 minutes
36
37
  useEffect(() => {
@@ -47,6 +48,13 @@ export function AppSidebar({ user }) {
47
48
  return () => clearInterval(interval);
48
49
  }, []);
49
50
 
51
+ // Feature flags — one-time on mount
52
+ useEffect(() => {
53
+ getFeatureFlags()
54
+ .then((flags) => setFeatures(flags))
55
+ .catch(() => {});
56
+ }, []);
57
+
50
58
  // Version check — one-time on mount
51
59
  useEffect(() => {
52
60
  getAppVersion()
@@ -88,8 +96,10 @@ export function AppSidebar({ user }) {
88
96
  <Tooltip>
89
97
  <TooltipTrigger asChild>
90
98
  <SidebarMenuButton
99
+ href="/"
91
100
  className={collapsed ? 'justify-center' : ''}
92
- onClick={() => {
101
+ onClick={(e) => {
102
+ e.preventDefault();
93
103
  navigateToChat(null);
94
104
  setOpenMobile(false);
95
105
  }}
@@ -109,10 +119,8 @@ export function AppSidebar({ user }) {
109
119
  <Tooltip>
110
120
  <TooltipTrigger asChild>
111
121
  <SidebarMenuButton
122
+ href="/chats"
112
123
  className={collapsed ? 'justify-center' : ''}
113
- onClick={() => {
114
- window.location.href = '/chats';
115
- }}
116
124
  >
117
125
  <MessageIcon size={16} />
118
126
  {!collapsed && <span>Chats</span>}
@@ -129,10 +137,8 @@ export function AppSidebar({ user }) {
129
137
  <Tooltip>
130
138
  <TooltipTrigger asChild>
131
139
  <SidebarMenuButton
140
+ href="/swarm"
132
141
  className={collapsed ? 'justify-center' : ''}
133
- onClick={() => {
134
- window.location.href = '/swarm';
135
- }}
136
142
  >
137
143
  <SwarmIcon size={16} />
138
144
  {!collapsed && <span>Swarm</span>}
@@ -149,10 +155,8 @@ export function AppSidebar({ user }) {
149
155
  <Tooltip>
150
156
  <TooltipTrigger asChild>
151
157
  <SidebarMenuButton
158
+ href="/notifications"
152
159
  className={collapsed ? 'justify-center' : ''}
153
- onClick={() => {
154
- window.location.href = '/notifications';
155
- }}
156
160
  >
157
161
  <BellIcon size={16} />
158
162
  {!collapsed && (
@@ -183,10 +187,8 @@ export function AppSidebar({ user }) {
183
187
  <Tooltip>
184
188
  <TooltipTrigger asChild>
185
189
  <SidebarMenuButton
190
+ href="/pull-requests"
186
191
  className={collapsed ? 'justify-center' : ''}
187
- onClick={() => {
188
- window.location.href = '/pull-requests';
189
- }}
190
192
  >
191
193
  <GitPullRequestIcon size={16} />
192
194
  {!collapsed && (
@@ -249,8 +251,10 @@ export function AppSidebar({ user }) {
249
251
  <Tooltip>
250
252
  <TooltipTrigger asChild>
251
253
  <SidebarMenuButton
254
+ href="https://www.skool.com/ai-architects"
255
+ target="_blank"
256
+ rel="noopener noreferrer"
252
257
  className={collapsed ? 'justify-center' : ''}
253
- onClick={() => window.open('https://www.skool.com/ai-architects', '_blank')}
254
258
  >
255
259
  <LifeBuoyIcon size={16} />
256
260
  {!collapsed && <span>Support</span>}
@@ -103,10 +103,15 @@ function ChatsPage({ session }) {
103
103
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-6", children: [
104
104
  /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold", children: "Chats" }),
105
105
  /* @__PURE__ */ jsxs(
106
- "button",
106
+ "a",
107
107
  {
108
- onClick: () => navigateToChat(null),
108
+ href: "/",
109
+ onClick: (e) => {
110
+ e.preventDefault();
111
+ navigateToChat(null);
112
+ },
109
113
  className: "inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium bg-foreground text-background hover:bg-foreground/90",
114
+ style: { textDecoration: "inherit" },
110
115
  children: [
111
116
  /* @__PURE__ */ jsx(PlusIcon, { size: 14 }),
112
117
  "New chat"
@@ -180,12 +185,21 @@ function ChatRow({ chat, onNavigate, onDelete, onStar, onRename }) {
180
185
  setEditTitle(chat.title || "");
181
186
  };
182
187
  return /* @__PURE__ */ jsxs(
183
- "div",
188
+ "a",
184
189
  {
190
+ href: `/chat/${chat.id}`,
185
191
  className: "relative group flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-muted/50 rounded-md",
192
+ style: { textDecoration: "inherit", color: "inherit" },
186
193
  onMouseEnter: () => setHovered(true),
187
194
  onMouseLeave: () => setHovered(false),
188
- onClick: () => !editing && onNavigate(chat.id),
195
+ onClick: (e) => {
196
+ if (editing) {
197
+ e.preventDefault();
198
+ return;
199
+ }
200
+ e.preventDefault();
201
+ onNavigate(chat.id);
202
+ },
189
203
  children: [
190
204
  /* @__PURE__ */ jsx(MessageIcon, { size: 16 }),
191
205
  /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0", children: [
@@ -122,13 +122,15 @@ export function ChatsPage({ session }) {
122
122
  {/* Header */}
123
123
  <div className="flex items-center justify-between mb-6">
124
124
  <h1 className="text-2xl font-semibold">Chats</h1>
125
- <button
126
- onClick={() => navigateToChat(null)}
125
+ <a
126
+ href="/"
127
+ onClick={(e) => { e.preventDefault(); navigateToChat(null); }}
127
128
  className="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium bg-foreground text-background hover:bg-foreground/90"
129
+ style={{ textDecoration: 'inherit' }}
128
130
  >
129
131
  <PlusIcon size={14} />
130
132
  New chat
131
- </button>
133
+ </a>
132
134
  </div>
133
135
 
134
136
  {/* Search */}
@@ -226,11 +228,17 @@ function ChatRow({ chat, onNavigate, onDelete, onStar, onRename }) {
226
228
  };
227
229
 
228
230
  return (
229
- <div
231
+ <a
232
+ href={`/chat/${chat.id}`}
230
233
  className="relative group flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-muted/50 rounded-md"
234
+ style={{ textDecoration: 'inherit', color: 'inherit' }}
231
235
  onMouseEnter={() => setHovered(true)}
232
236
  onMouseLeave={() => setHovered(false)}
233
- onClick={() => !editing && onNavigate(chat.id)}
237
+ onClick={(e) => {
238
+ if (editing) { e.preventDefault(); return; }
239
+ e.preventDefault();
240
+ onNavigate(chat.id);
241
+ }}
234
242
  >
235
243
  <MessageIcon size={16} />
236
244
  <div className="flex-1 min-w-0">
@@ -325,6 +333,6 @@ function ChatRow({ chat, onNavigate, onDelete, onStar, onRename }) {
325
333
  }}
326
334
  onCancel={() => setConfirmDelete(false)}
327
335
  />
328
- </div>
336
+ </a>
329
337
  );
330
338
  }
@@ -27,9 +27,11 @@ function SidebarHistoryItem({ chat, isActive, onDelete, onStar, onRename }) {
27
27
  /* @__PURE__ */ jsxs(
28
28
  SidebarMenuButton,
29
29
  {
30
+ href: `/chat/${chat.id}`,
30
31
  className: "pr-8",
31
32
  isActive,
32
- onClick: () => {
33
+ onClick: (e) => {
34
+ e.preventDefault();
33
35
  navigateToChat(chat.id);
34
36
  setOpenMobile(false);
35
37
  },
@@ -27,9 +27,11 @@ export function SidebarHistoryItem({ chat, isActive, onDelete, onStar, onRename
27
27
  onMouseLeave={() => setHovered(false)}
28
28
  >
29
29
  <SidebarMenuButton
30
+ href={`/chat/${chat.id}`}
30
31
  className="pr-8"
31
32
  isActive={isActive}
32
- onClick={() => {
33
+ onClick={(e) => {
34
+ e.preventDefault();
33
35
  navigateToChat(chat.id);
34
36
  setOpenMobile(false);
35
37
  }}
@@ -29,20 +29,18 @@ function SidebarUserNav({ user, collapsed }) {
29
29
  ] })
30
30
  ] }) }),
31
31
  /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "start", side: "top", className: "w-56", children: [
32
- /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => {
33
- window.location.href = "/settings";
34
- }, children: [
32
+ /* @__PURE__ */ jsx(DropdownMenuItem, { asChild: true, children: /* @__PURE__ */ jsxs("a", { href: "/settings", style: { textDecoration: "inherit", color: "inherit" }, children: [
35
33
  /* @__PURE__ */ jsx(SettingsIcon, { size: 14 }),
36
34
  /* @__PURE__ */ jsx("span", { className: "ml-2", children: "Settings" })
37
- ] }),
35
+ ] }) }),
38
36
  mounted && /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => setTheme(theme === "dark" ? "light" : "dark"), children: [
39
37
  theme === "dark" ? /* @__PURE__ */ jsx(SunIcon, { size: 14 }) : /* @__PURE__ */ jsx(MoonIcon, { size: 14 }),
40
38
  /* @__PURE__ */ jsx("span", { className: "ml-2", children: theme === "dark" ? "Light Mode" : "Dark Mode" })
41
39
  ] }),
42
- /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => window.open("https://github.com/stephengpope/thepopebot/issues", "_blank"), children: [
40
+ /* @__PURE__ */ jsx(DropdownMenuItem, { asChild: true, children: /* @__PURE__ */ jsxs("a", { href: "https://github.com/stephengpope/thepopebot/issues", target: "_blank", rel: "noopener noreferrer", style: { textDecoration: "inherit", color: "inherit" }, children: [
43
41
  /* @__PURE__ */ jsx(BugIcon, { size: 14 }),
44
42
  /* @__PURE__ */ jsx("span", { className: "ml-2", children: "Report Issues" })
45
- ] }),
43
+ ] }) }),
46
44
  /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
47
45
  /* @__PURE__ */ jsxs(
48
46
  DropdownMenuItem,
@@ -43,9 +43,11 @@ export function SidebarUserNav({ user, collapsed }) {
43
43
  </SidebarMenuButton>
44
44
  </DropdownMenuTrigger>
45
45
  <DropdownMenuContent align="start" side="top" className="w-56">
46
- <DropdownMenuItem onClick={() => { window.location.href = '/settings'; }}>
47
- <SettingsIcon size={14} />
48
- <span className="ml-2">Settings</span>
46
+ <DropdownMenuItem asChild>
47
+ <a href="/settings" style={{ textDecoration: 'inherit', color: 'inherit' }}>
48
+ <SettingsIcon size={14} />
49
+ <span className="ml-2">Settings</span>
50
+ </a>
49
51
  </DropdownMenuItem>
50
52
  {mounted && (
51
53
  <DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
@@ -53,9 +55,11 @@ export function SidebarUserNav({ user, collapsed }) {
53
55
  <span className="ml-2">{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
54
56
  </DropdownMenuItem>
55
57
  )}
56
- <DropdownMenuItem onClick={() => window.open('https://github.com/stephengpope/thepopebot/issues', '_blank')}>
57
- <BugIcon size={14} />
58
- <span className="ml-2">Report Issues</span>
58
+ <DropdownMenuItem asChild>
59
+ <a href="https://github.com/stephengpope/thepopebot/issues" target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'inherit', color: 'inherit' }}>
60
+ <BugIcon size={14} />
61
+ <span className="ml-2">Report Issues</span>
62
+ </a>
59
63
  </DropdownMenuItem>
60
64
  <DropdownMenuSeparator />
61
65
  <DropdownMenuItem
@@ -30,6 +30,13 @@ export function attachCodeProxy(server) {
30
30
  const match = req.url.match(/^\/code\/([^/]+)\/ws$/);
31
31
  if (!match) return;
32
32
 
33
+ if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
34
+ console.log('[ws-proxy] rejected: CLAUDE_CODE_OAUTH_TOKEN not set');
35
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
36
+ socket.destroy();
37
+ return;
38
+ }
39
+
33
40
  if (!await isAuthenticated(req)) {
34
41
  console.log('[ws-proxy] rejected: unauthenticated upgrade');
35
42
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.72-beta.21",
3
+ "version": "1.2.72-beta.22",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -1,8 +1,12 @@
1
1
  import { auth } from 'thepopebot/auth';
2
+ import { redirect } from 'next/navigation';
2
3
  import { CodePage } from 'thepopebot/code';
4
+ import { getFeatureFlags } from 'thepopebot/chat/actions';
3
5
 
4
6
  export default async function CodeRoute({ params }) {
5
7
  const session = await auth();
8
+ const { claudeWorkspace } = await getFeatureFlags();
9
+ if (!claudeWorkspace) redirect('/');
6
10
  const { claudeWorkspaceId } = await params;
7
11
  return <CodePage session={session} claudeWorkspaceId={claudeWorkspaceId} />;
8
12
  }
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: google-docs
3
+ description: "Create and manage Google Docs on a shared drive via service account."
4
+ ---
5
+
6
+ # Google Docs
7
+
8
+ Create and manage Google Docs on a shared drive using a service account.
9
+
10
+ ## Environment Variables
11
+
12
+ - `GOOGLE_SERVICE_ACCOUNT_JSON` — Service account credentials JSON
13
+ - `GOOGLE_SHARED_DRIVE_ID` — Shared drive ID
14
+
15
+ ## Commands
16
+
17
+ ### Create a Google Doc
18
+
19
+ ```bash
20
+ skills/google-docs/create.sh <title> <content> <parent_folder_id>
21
+ ```
22
+
23
+ Creates a new Google Doc with the given title and text content in the specified folder. Returns the document ID and URL.
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ TITLE="${1:?Usage: create.sh <title> <content> <parent_folder_id>}"
5
+ CONTENT="${2:?Usage: create.sh <title> <content> <parent_folder_id>}"
6
+ PARENT_FOLDER_ID="${3:?Usage: create.sh <title> <content> <parent_folder_id>}"
7
+
8
+ if [ -z "${GOOGLE_SERVICE_ACCOUNT_JSON:-}" ]; then
9
+ echo "Error: GOOGLE_SERVICE_ACCOUNT_JSON is not set" >&2
10
+ exit 1
11
+ fi
12
+
13
+ # Extract service account email and private key
14
+ SA_EMAIL=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')
15
+ SA_KEY=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.private_key')
16
+ TOKEN_URI=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.token_uri')
17
+
18
+ # Build JWT with both drive and docs scopes
19
+ NOW=$(date +%s)
20
+ EXP=$((NOW + 3600))
21
+ HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
22
+ CLAIMS=$(printf '{"iss":"%s","scope":"https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/documents","aud":"%s","iat":%d,"exp":%d}' \
23
+ "$SA_EMAIL" "$TOKEN_URI" "$NOW" "$EXP" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
24
+ SIGNING_INPUT="${HEADER}.${CLAIMS}"
25
+ SIGNATURE=$(printf '%s' "$SIGNING_INPUT" | openssl dgst -sha256 -sign <(echo "$SA_KEY") | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
26
+ JWT="${SIGNING_INPUT}.${SIGNATURE}"
27
+
28
+ # Exchange JWT for access token
29
+ ACCESS_TOKEN=$(curl -s -X POST "$TOKEN_URI" \
30
+ -H "Content-Type: application/x-www-form-urlencoded" \
31
+ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${JWT}" | jq -r '.access_token')
32
+
33
+ if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
34
+ echo "Error: Failed to obtain access token" >&2
35
+ exit 1
36
+ fi
37
+
38
+ # Create Google Doc via Drive API (creates empty doc in the shared drive)
39
+ METADATA=$(jq -n --arg title "$TITLE" --arg parent "$PARENT_FOLDER_ID" \
40
+ '{name: $title, mimeType: "application/vnd.google-apps.document", parents: [$parent]}')
41
+
42
+ CREATE_RESPONSE=$(curl -s -X POST \
43
+ "https://www.googleapis.com/drive/v3/files?supportsAllDrives=true&includeItemsFromAllDrives=true" \
44
+ -H "Authorization: Bearer ${ACCESS_TOKEN}" \
45
+ -H "Content-Type: application/json" \
46
+ -d "$METADATA")
47
+
48
+ DOC_ID=$(echo "$CREATE_RESPONSE" | jq -r '.id')
49
+ if [ -z "$DOC_ID" ] || [ "$DOC_ID" = "null" ]; then
50
+ echo "Error: Failed to create document" >&2
51
+ echo "$CREATE_RESPONSE" >&2
52
+ exit 1
53
+ fi
54
+
55
+ # Insert content using Docs API batchUpdate
56
+ if [ -n "$CONTENT" ]; then
57
+ UPDATE_BODY=$(jq -n --arg text "$CONTENT" \
58
+ '{requests: [{insertText: {location: {index: 1}, text: $text}}]}')
59
+
60
+ curl -s -X POST \
61
+ "https://docs.googleapis.com/v1/documents/${DOC_ID}:batchUpdate" \
62
+ -H "Authorization: Bearer ${ACCESS_TOKEN}" \
63
+ -H "Content-Type: application/json" \
64
+ -d "$UPDATE_BODY" > /dev/null
65
+ fi
66
+
67
+ echo "Created Google Doc: $TITLE"
68
+ echo "Doc ID: $DOC_ID"
69
+ echo "URL: https://docs.google.com/document/d/${DOC_ID}/edit"
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: google-drive
3
+ description: "Interact with Google Drive shared drives via service account. Supports list, upload, download, delete operations."
4
+ ---
5
+
6
+ # Google Drive
7
+
8
+ Interact with Google Drive shared drives using a service account.
9
+
10
+ ## Environment Variables
11
+
12
+ - `GOOGLE_SERVICE_ACCOUNT_JSON` — Service account credentials JSON
13
+ - `GOOGLE_SHARED_DRIVE_ID` — Shared drive ID
14
+
15
+ ## Commands
16
+
17
+ ### List files in a folder
18
+
19
+ ```bash
20
+ skills/google-drive/list.sh <folder_id>
21
+ ```
22
+
23
+ Lists files and folders in the given folder. Use `$GOOGLE_SHARED_DRIVE_ID` for the root.
24
+
25
+ ### Upload a file
26
+
27
+ ```bash
28
+ skills/google-drive/upload.sh <local_file> <parent_folder_id> <filename>
29
+ ```
30
+
31
+ Uploads a local file to the specified folder with the given name.
32
+
33
+ ### Download a file
34
+
35
+ ```bash
36
+ skills/google-drive/download.sh <file_id> <local_path>
37
+ ```
38
+
39
+ Downloads a file by ID to a local path.
40
+
41
+ ### Delete a file
42
+
43
+ ```bash
44
+ skills/google-drive/delete.sh <file_id>
45
+ ```
46
+
47
+ Deletes a file by ID.
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ FILE_ID="${1:?Usage: delete.sh <file_id>}"
5
+
6
+ if [ -z "${GOOGLE_SERVICE_ACCOUNT_JSON:-}" ]; then
7
+ echo "Error: GOOGLE_SERVICE_ACCOUNT_JSON is not set" >&2
8
+ exit 1
9
+ fi
10
+
11
+ # Extract service account email and private key
12
+ SA_EMAIL=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')
13
+ SA_KEY=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.private_key')
14
+ TOKEN_URI=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.token_uri')
15
+
16
+ # Build JWT
17
+ NOW=$(date +%s)
18
+ EXP=$((NOW + 3600))
19
+ HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
20
+ CLAIMS=$(printf '{"iss":"%s","scope":"https://www.googleapis.com/auth/drive","aud":"%s","iat":%d,"exp":%d}' \
21
+ "$SA_EMAIL" "$TOKEN_URI" "$NOW" "$EXP" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
22
+ SIGNING_INPUT="${HEADER}.${CLAIMS}"
23
+ SIGNATURE=$(printf '%s' "$SIGNING_INPUT" | openssl dgst -sha256 -sign <(echo "$SA_KEY") | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
24
+ JWT="${SIGNING_INPUT}.${SIGNATURE}"
25
+
26
+ # Exchange JWT for access token
27
+ ACCESS_TOKEN=$(curl -s -X POST "$TOKEN_URI" \
28
+ -H "Content-Type: application/x-www-form-urlencoded" \
29
+ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${JWT}" | jq -r '.access_token')
30
+
31
+ if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
32
+ echo "Error: Failed to obtain access token" >&2
33
+ exit 1
34
+ fi
35
+
36
+ # Delete file
37
+ HTTP_CODE=$(curl -s -w "%{http_code}" -o /dev/null \
38
+ -X DELETE \
39
+ "https://www.googleapis.com/drive/v3/files/${FILE_ID}?supportsAllDrives=true" \
40
+ -H "Authorization: Bearer ${ACCESS_TOKEN}")
41
+
42
+ if [ "$HTTP_CODE" -ne 204 ] && [ "$HTTP_CODE" -ne 200 ]; then
43
+ echo "Error: Delete failed with HTTP $HTTP_CODE" >&2
44
+ exit 1
45
+ fi
46
+
47
+ echo "Deleted file: $FILE_ID"
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ FILE_ID="${1:?Usage: download.sh <file_id> <local_path>}"
5
+ LOCAL_PATH="${2:?Usage: download.sh <file_id> <local_path>}"
6
+
7
+ if [ -z "${GOOGLE_SERVICE_ACCOUNT_JSON:-}" ]; then
8
+ echo "Error: GOOGLE_SERVICE_ACCOUNT_JSON is not set" >&2
9
+ exit 1
10
+ fi
11
+
12
+ # Extract service account email and private key
13
+ SA_EMAIL=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')
14
+ SA_KEY=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.private_key')
15
+ TOKEN_URI=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.token_uri')
16
+
17
+ # Build JWT
18
+ NOW=$(date +%s)
19
+ EXP=$((NOW + 3600))
20
+ HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
21
+ CLAIMS=$(printf '{"iss":"%s","scope":"https://www.googleapis.com/auth/drive","aud":"%s","iat":%d,"exp":%d}' \
22
+ "$SA_EMAIL" "$TOKEN_URI" "$NOW" "$EXP" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
23
+ SIGNING_INPUT="${HEADER}.${CLAIMS}"
24
+ SIGNATURE=$(printf '%s' "$SIGNING_INPUT" | openssl dgst -sha256 -sign <(echo "$SA_KEY") | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
25
+ JWT="${SIGNING_INPUT}.${SIGNATURE}"
26
+
27
+ # Exchange JWT for access token
28
+ ACCESS_TOKEN=$(curl -s -X POST "$TOKEN_URI" \
29
+ -H "Content-Type: application/x-www-form-urlencoded" \
30
+ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${JWT}" | jq -r '.access_token')
31
+
32
+ if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
33
+ echo "Error: Failed to obtain access token" >&2
34
+ exit 1
35
+ fi
36
+
37
+ # Download file
38
+ HTTP_CODE=$(curl -s -w "%{http_code}" -o "$LOCAL_PATH" \
39
+ "https://www.googleapis.com/drive/v3/files/${FILE_ID}?alt=media&supportsAllDrives=true&includeItemsFromAllDrives=true" \
40
+ -H "Authorization: Bearer ${ACCESS_TOKEN}")
41
+
42
+ if [ "$HTTP_CODE" -ne 200 ]; then
43
+ echo "Error: Download failed with HTTP $HTTP_CODE" >&2
44
+ cat "$LOCAL_PATH" >&2
45
+ rm -f "$LOCAL_PATH"
46
+ exit 1
47
+ fi
48
+
49
+ FILE_SIZE=$(stat -c%s "$LOCAL_PATH" 2>/dev/null || stat -f%z "$LOCAL_PATH" 2>/dev/null)
50
+ echo "Downloaded to: $LOCAL_PATH ($FILE_SIZE bytes)"
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ FOLDER_ID="${1:?Usage: list.sh <folder_id>}"
5
+
6
+ if [ -z "${GOOGLE_SERVICE_ACCOUNT_JSON:-}" ]; then
7
+ echo "Error: GOOGLE_SERVICE_ACCOUNT_JSON is not set" >&2
8
+ exit 1
9
+ fi
10
+
11
+ # Extract service account email and private key
12
+ SA_EMAIL=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')
13
+ SA_KEY=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.private_key')
14
+ TOKEN_URI=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.token_uri')
15
+
16
+ # Build JWT
17
+ NOW=$(date +%s)
18
+ EXP=$((NOW + 3600))
19
+ HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
20
+ CLAIMS=$(printf '{"iss":"%s","scope":"https://www.googleapis.com/auth/drive","aud":"%s","iat":%d,"exp":%d}' \
21
+ "$SA_EMAIL" "$TOKEN_URI" "$NOW" "$EXP" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
22
+ SIGNING_INPUT="${HEADER}.${CLAIMS}"
23
+ SIGNATURE=$(printf '%s' "$SIGNING_INPUT" | openssl dgst -sha256 -sign <(echo "$SA_KEY") | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
24
+ JWT="${SIGNING_INPUT}.${SIGNATURE}"
25
+
26
+ # Exchange JWT for access token
27
+ ACCESS_TOKEN=$(curl -s -X POST "$TOKEN_URI" \
28
+ -H "Content-Type: application/x-www-form-urlencoded" \
29
+ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${JWT}" | jq -r '.access_token')
30
+
31
+ if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
32
+ echo "Error: Failed to obtain access token" >&2
33
+ exit 1
34
+ fi
35
+
36
+ # List files
37
+ RESPONSE=$(curl -s -X GET \
38
+ "https://www.googleapis.com/drive/v3/files?q='${FOLDER_ID}'+in+parents&supportsAllDrives=true&includeItemsFromAllDrives=true&corpora=allDrives&fields=files(id,name,mimeType,size,modifiedTime)" \
39
+ -H "Authorization: Bearer ${ACCESS_TOKEN}")
40
+
41
+ echo "$RESPONSE" | jq -r '.files[] | "\(.id)\t\(.name)\t\(.mimeType)\t\(.size // "N/A")\t\(.modifiedTime)"' 2>/dev/null || echo "$RESPONSE"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ LOCAL_FILE="${1:?Usage: upload.sh <local_file> <parent_folder_id> <filename>}"
5
+ PARENT_FOLDER_ID="${2:?Usage: upload.sh <local_file> <parent_folder_id> <filename>}"
6
+ FILENAME="${3:?Usage: upload.sh <local_file> <parent_folder_id> <filename>}"
7
+
8
+ if [ ! -f "$LOCAL_FILE" ]; then
9
+ echo "Error: File not found: $LOCAL_FILE" >&2
10
+ exit 1
11
+ fi
12
+
13
+ if [ -z "${GOOGLE_SERVICE_ACCOUNT_JSON:-}" ]; then
14
+ echo "Error: GOOGLE_SERVICE_ACCOUNT_JSON is not set" >&2
15
+ exit 1
16
+ fi
17
+
18
+ # Extract service account email and private key
19
+ SA_EMAIL=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')
20
+ SA_KEY=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.private_key')
21
+ TOKEN_URI=$(echo "$GOOGLE_SERVICE_ACCOUNT_JSON" | jq -r '.token_uri')
22
+
23
+ # Build JWT
24
+ NOW=$(date +%s)
25
+ EXP=$((NOW + 3600))
26
+ HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
27
+ CLAIMS=$(printf '{"iss":"%s","scope":"https://www.googleapis.com/auth/drive","aud":"%s","iat":%d,"exp":%d}' \
28
+ "$SA_EMAIL" "$TOKEN_URI" "$NOW" "$EXP" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
29
+ SIGNING_INPUT="${HEADER}.${CLAIMS}"
30
+ SIGNATURE=$(printf '%s' "$SIGNING_INPUT" | openssl dgst -sha256 -sign <(echo "$SA_KEY") | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
31
+ JWT="${SIGNING_INPUT}.${SIGNATURE}"
32
+
33
+ # Exchange JWT for access token
34
+ ACCESS_TOKEN=$(curl -s -X POST "$TOKEN_URI" \
35
+ -H "Content-Type: application/x-www-form-urlencoded" \
36
+ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${JWT}" | jq -r '.access_token')
37
+
38
+ if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
39
+ echo "Error: Failed to obtain access token" >&2
40
+ exit 1
41
+ fi
42
+
43
+ # Detect MIME type from extension
44
+ EXT="${LOCAL_FILE##*.}"
45
+ case "${EXT,,}" in
46
+ jpg|jpeg) MIME_TYPE="image/jpeg" ;;
47
+ png) MIME_TYPE="image/png" ;;
48
+ gif) MIME_TYPE="image/gif" ;;
49
+ webp) MIME_TYPE="image/webp" ;;
50
+ mp4) MIME_TYPE="video/mp4" ;;
51
+ webm) MIME_TYPE="video/webm" ;;
52
+ pdf) MIME_TYPE="application/pdf" ;;
53
+ txt) MIME_TYPE="text/plain" ;;
54
+ json) MIME_TYPE="application/json" ;;
55
+ csv) MIME_TYPE="text/csv" ;;
56
+ *) MIME_TYPE="application/octet-stream" ;;
57
+ esac
58
+
59
+ # Upload file using multipart upload
60
+ METADATA=$(printf '{"name":"%s","parents":["%s"]}' "$FILENAME" "$PARENT_FOLDER_ID")
61
+
62
+ RESPONSE=$(curl -s -X POST \
63
+ "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&includeItemsFromAllDrives=true" \
64
+ -H "Authorization: Bearer ${ACCESS_TOKEN}" \
65
+ -F "metadata=${METADATA};type=application/json" \
66
+ -F "file=@${LOCAL_FILE};type=${MIME_TYPE}")
67
+
68
+ FILE_ID=$(echo "$RESPONSE" | jq -r '.id')
69
+ if [ -z "$FILE_ID" ] || [ "$FILE_ID" = "null" ]; then
70
+ echo "Error: Upload failed" >&2
71
+ echo "$RESPONSE" >&2
72
+ exit 1
73
+ fi
74
+
75
+ echo "Uploaded: $FILENAME"
76
+ echo "File ID: $FILE_ID"
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: kie-ai
3
+ description: "Generate images and videos using kie.ai API. Use for AI image and video generation tasks."
4
+ ---
5
+
6
+ # KIE AI
7
+
8
+ Generate images and videos using the kie.ai API.
9
+
10
+ ## Environment Variables
11
+
12
+ - `KIE_AI_API_KEY` — API key for kie.ai
13
+
14
+ ## Commands
15
+
16
+ ### Generate an image
17
+
18
+ ```bash
19
+ skills/kie-ai/generate-image.sh <prompt> [aspect_ratio] [resolution] [output_format]
20
+ ```
21
+
22
+ - `prompt` (required) — Text prompt for image generation
23
+ - `aspect_ratio` (optional, default: `auto`) — One of: `auto, 1:1, 1:4, 16:9, 1:8, 21:9, 2:3, 3:2, 3:4, 4:1, 4:3, 4:5, 5:4, 8:1, 9:16`
24
+ - `resolution` (optional, default: `1K`) — One of: `1K, 2K, 4K`
25
+ - `output_format` (optional, default: `jpg`) — One of: `jpg, png`
26
+
27
+ Downloads the generated image to a local file and prints the path.
28
+
29
+ ### Generate a video
30
+
31
+ ```bash
32
+ skills/kie-ai/generate-video.sh <prompt> [aspect_ratio]
33
+ ```
34
+
35
+ - `prompt` (required) — Text prompt for video generation
36
+ - `aspect_ratio` (optional, default: `16:9`) — One of: `16:9, 9:16, Auto`
37
+
38
+ Downloads the generated video to a local file and prints the path.
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PROMPT="${1:?Usage: generate-image.sh <prompt> [aspect_ratio] [resolution] [output_format]}"
5
+ ASPECT_RATIO="${2:-auto}"
6
+ RESOLUTION="${3:-1K}"
7
+ OUTPUT_FORMAT="${4:-jpg}"
8
+
9
+ if [ -z "${KIE_AI_API_KEY:-}" ]; then
10
+ echo "Error: KIE_AI_API_KEY is not set" >&2
11
+ exit 1
12
+ fi
13
+
14
+ # Create task
15
+ BODY=$(jq -n \
16
+ --arg prompt "$PROMPT" \
17
+ --arg ar "$ASPECT_RATIO" \
18
+ --arg res "$RESOLUTION" \
19
+ --arg fmt "$OUTPUT_FORMAT" \
20
+ '{
21
+ model: "nano-banana-2",
22
+ input: {
23
+ prompt: $prompt,
24
+ aspect_ratio: $ar,
25
+ resolution: $res,
26
+ output_format: $fmt
27
+ }
28
+ }')
29
+
30
+ echo "Creating image generation task..." >&2
31
+ CREATE_RESPONSE=$(curl -s -X POST \
32
+ "https://api.kie.ai/api/v1/jobs/createTask" \
33
+ -H "Authorization: Bearer ${KIE_AI_API_KEY}" \
34
+ -H "Content-Type: application/json" \
35
+ -d "$BODY")
36
+
37
+ TASK_ID=$(echo "$CREATE_RESPONSE" | jq -r '.data.taskId // .taskId // empty')
38
+ if [ -z "$TASK_ID" ]; then
39
+ echo "Error: Failed to create task" >&2
40
+ echo "$CREATE_RESPONSE" >&2
41
+ exit 1
42
+ fi
43
+
44
+ echo "Task ID: $TASK_ID — polling for completion..." >&2
45
+
46
+ # Poll for completion
47
+ while true; do
48
+ sleep 15
49
+ STATUS_RESPONSE=$(curl -s -X GET \
50
+ "https://api.kie.ai/api/v1/jobs/recordInfo?taskId=${TASK_ID}" \
51
+ -H "Authorization: Bearer ${KIE_AI_API_KEY}" \
52
+ -H "Content-Type: application/json")
53
+
54
+ STATE=$(echo "$STATUS_RESPONSE" | jq -r '.data.state // empty')
55
+ echo "State: $STATE" >&2
56
+
57
+ if [ "$STATE" = "success" ]; then
58
+ # .data.resultJson is a JSON string — parse it, then get .resultUrls[0]
59
+ IMAGE_URL=$(echo "$STATUS_RESPONSE" | jq -r '.data.resultJson' | jq -r '.resultUrls[0]')
60
+ if [ -z "$IMAGE_URL" ] || [ "$IMAGE_URL" = "null" ]; then
61
+ echo "Error: Could not extract image URL" >&2
62
+ echo "$STATUS_RESPONSE" >&2
63
+ exit 1
64
+ fi
65
+
66
+ # Download
67
+ OUTPUT_FILE="/tmp/kie-image-${TASK_ID}.${OUTPUT_FORMAT}"
68
+ curl -s -o "$OUTPUT_FILE" "$IMAGE_URL"
69
+ FILE_SIZE=$(stat -c%s "$OUTPUT_FILE" 2>/dev/null || stat -f%z "$OUTPUT_FILE" 2>/dev/null)
70
+ echo "Image downloaded: $OUTPUT_FILE ($FILE_SIZE bytes)"
71
+ exit 0
72
+ elif [ "$STATE" = "failed" ] || [ "$STATE" = "error" ]; then
73
+ echo "Error: Image generation failed" >&2
74
+ echo "$STATUS_RESPONSE" >&2
75
+ exit 1
76
+ fi
77
+ done
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PROMPT="${1:?Usage: generate-video.sh <prompt> [aspect_ratio]}"
5
+ ASPECT_RATIO="${2:-16:9}"
6
+
7
+ if [ -z "${KIE_AI_API_KEY:-}" ]; then
8
+ echo "Error: KIE_AI_API_KEY is not set" >&2
9
+ exit 1
10
+ fi
11
+
12
+ # Create task
13
+ BODY=$(jq -n \
14
+ --arg prompt "$PROMPT" \
15
+ --arg ar "$ASPECT_RATIO" \
16
+ '{
17
+ prompt: $prompt,
18
+ model: "veo3_fast",
19
+ generationType: "TEXT_2_VIDEO",
20
+ aspect_ratio: $ar
21
+ }')
22
+
23
+ echo "Creating video generation task..." >&2
24
+ CREATE_RESPONSE=$(curl -s -X POST \
25
+ "https://api.kie.ai/api/v1/veo/generate" \
26
+ -H "Authorization: Bearer ${KIE_AI_API_KEY}" \
27
+ -H "Content-Type: application/json" \
28
+ -d "$BODY")
29
+
30
+ TASK_ID=$(echo "$CREATE_RESPONSE" | jq -r '.data.taskId // .taskId // empty')
31
+ if [ -z "$TASK_ID" ]; then
32
+ echo "Error: Failed to create task" >&2
33
+ echo "$CREATE_RESPONSE" >&2
34
+ exit 1
35
+ fi
36
+
37
+ echo "Task ID: $TASK_ID — polling for completion..." >&2
38
+
39
+ # Poll for completion
40
+ while true; do
41
+ sleep 15
42
+ STATUS_RESPONSE=$(curl -s -X GET \
43
+ "https://api.kie.ai/api/v1/veo/record-info?taskId=${TASK_ID}" \
44
+ -H "Authorization: Bearer ${KIE_AI_API_KEY}" \
45
+ -H "Content-Type: application/json")
46
+
47
+ SUCCESS_FLAG=$(echo "$STATUS_RESPONSE" | jq -r '.data.successFlag // empty')
48
+ echo "Status check... successFlag=$SUCCESS_FLAG" >&2
49
+
50
+ if [ "$SUCCESS_FLAG" = "1" ]; then
51
+ VIDEO_URL=$(echo "$STATUS_RESPONSE" | jq -r '.data.response.resultUrls[0]')
52
+ if [ -z "$VIDEO_URL" ] || [ "$VIDEO_URL" = "null" ]; then
53
+ echo "Error: Could not extract video URL" >&2
54
+ echo "$STATUS_RESPONSE" >&2
55
+ exit 1
56
+ fi
57
+
58
+ # Download
59
+ OUTPUT_FILE="/tmp/kie-video-${TASK_ID}.mp4"
60
+ curl -s -o "$OUTPUT_FILE" "$VIDEO_URL"
61
+ FILE_SIZE=$(stat -c%s "$OUTPUT_FILE" 2>/dev/null || stat -f%z "$OUTPUT_FILE" 2>/dev/null)
62
+ echo "Video downloaded: $OUTPUT_FILE ($FILE_SIZE bytes)"
63
+ exit 0
64
+ elif [ "$SUCCESS_FLAG" = "-1" ]; then
65
+ echo "Error: Video generation failed" >&2
66
+ echo "$STATUS_RESPONSE" >&2
67
+ exit 1
68
+ fi
69
+ done
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: youtube-transcript
3
+ description: Fetch transcripts from YouTube videos for summarization and analysis.
4
+ ---
5
+
6
+ # YouTube Transcript
7
+
8
+ Fetch transcripts from YouTube videos.
9
+
10
+ ## Setup
11
+
12
+ ```bash
13
+ cd skills/youtube-transcript
14
+ npm install
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ skills/youtube-transcript/transcript.js <video-id-or-url>
21
+ ```
22
+
23
+ Accepts video ID or full URL:
24
+ - `EBw7gsDPAYQ`
25
+ - `https://www.youtube.com/watch?v=EBw7gsDPAYQ`
26
+ - `https://youtu.be/EBw7gsDPAYQ`
27
+
28
+ ## Output
29
+
30
+ Timestamped transcript entries:
31
+
32
+ ```
33
+ [0:00] All right. So, I got this UniFi Theta
34
+ [0:15] I took the camera out, painted it
35
+ [1:23] And here's the final result
36
+ ```
37
+
38
+ ## Notes
39
+
40
+ - Requires the video to have captions/transcripts available
41
+ - Works with auto-generated and manual transcripts
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "youtube-transcript-skill",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "youtube-transcript-skill",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "youtube-transcript-plus": "^1.2.0"
12
+ }
13
+ },
14
+ "node_modules/youtube-transcript-plus": {
15
+ "version": "1.2.0",
16
+ "resolved": "https://registry.npmjs.org/youtube-transcript-plus/-/youtube-transcript-plus-1.2.0.tgz",
17
+ "integrity": "sha512-SRjVft8V+vUulMKgakgfzC+pnFLSy4tolX7xGnSvp9juUNocikMFmUx5GlhzLDILzxYrijcYtmNqz0qyklnPmA==",
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "youtube-transcript-skill",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "dependencies": {
6
+ "youtube-transcript-plus": "^1.2.0"
7
+ }
8
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { YoutubeTranscript } from 'youtube-transcript-plus';
4
+
5
+ function extractVideoId(input) {
6
+ if (!input) return null;
7
+
8
+ // Already a plain video ID (11 characters, alphanumeric + dash/underscore)
9
+ if (/^[A-Za-z0-9_-]{11}$/.test(input)) {
10
+ return input;
11
+ }
12
+
13
+ try {
14
+ const url = new URL(input);
15
+
16
+ // youtu.be/VIDEO_ID
17
+ if (url.hostname === 'youtu.be') {
18
+ return url.pathname.slice(1);
19
+ }
20
+
21
+ // youtube.com/watch?v=VIDEO_ID
22
+ if (url.hostname === 'www.youtube.com' || url.hostname === 'youtube.com') {
23
+ return url.searchParams.get('v');
24
+ }
25
+ } catch {
26
+ // Not a URL — treat as video ID anyway
27
+ return input;
28
+ }
29
+
30
+ return null;
31
+ }
32
+
33
+ function decodeHtmlEntities(str) {
34
+ return str
35
+ .replace(/&#39;/g, "'")
36
+ .replace(/&amp;/g, '&')
37
+ .replace(/&lt;/g, '<')
38
+ .replace(/&gt;/g, '>')
39
+ .replace(/&quot;/g, '"')
40
+ .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)));
41
+ }
42
+
43
+ function formatTime(seconds) {
44
+ const totalSeconds = Math.floor(seconds);
45
+ const mins = Math.floor(totalSeconds / 60);
46
+ const secs = totalSeconds % 60;
47
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
48
+ }
49
+
50
+ async function main() {
51
+ const input = process.argv[2];
52
+
53
+ if (!input) {
54
+ console.error('Usage: transcript.js <video-id-or-url>');
55
+ process.exit(1);
56
+ }
57
+
58
+ const videoId = extractVideoId(input);
59
+
60
+ if (!videoId) {
61
+ console.error(`Error: Could not extract video ID from "${input}"`);
62
+ process.exit(1);
63
+ }
64
+
65
+ try {
66
+ const transcript = await YoutubeTranscript.fetchTranscript(videoId);
67
+
68
+ if (!transcript || transcript.length === 0) {
69
+ console.error('No transcript available for this video.');
70
+ process.exit(1);
71
+ }
72
+
73
+ for (const entry of transcript) {
74
+ const time = formatTime(entry.offset);
75
+ const text = decodeHtmlEntities(entry.text.replace(/\n/g, ' ')).trim();
76
+ console.log(`[${time}] ${text}`);
77
+ }
78
+ } catch (err) {
79
+ console.error(`Error fetching transcript: ${err.message}`);
80
+ process.exit(1);
81
+ }
82
+ }
83
+
84
+ main();