@sulala/agent-os 0.1.23 → 0.1.24

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/dist/index.js CHANGED
@@ -26537,8 +26537,8 @@ function formatLLMErrorForUser(err) {
26537
26537
  }
26538
26538
  function runAgentInner(options) {
26539
26539
  return (async () => {
26540
- const { task, agent, conversationHistory = [] } = options;
26541
- const maxTurns = agent.limits?.max_turns ?? 10;
26540
+ const { task, agent, conversationHistory = [], maxTurnsOverride } = options;
26541
+ const maxTurns = maxTurnsOverride ?? agent.limits?.max_turns ?? 10;
26542
26542
  const maxTokens = agent.limits?.max_tokens;
26543
26543
  await ensureWorkspace(agent.id);
26544
26544
  await loadSkillsForAgent(agent);
@@ -26772,8 +26772,8 @@ async function runAgent(options) {
26772
26772
  return runAgentInner(options);
26773
26773
  }
26774
26774
  async function runAgentStream(options, onEvent) {
26775
- const { task, agent, conversationHistory = [] } = options;
26776
- const maxTurns = agent.limits?.max_turns ?? 10;
26775
+ const { task, agent, conversationHistory = [], maxTurnsOverride } = options;
26776
+ const maxTurns = maxTurnsOverride ?? agent.limits?.max_turns ?? 10;
26777
26777
  const maxTokens = agent.limits?.max_tokens;
26778
26778
  await ensureWorkspace(agent.id);
26779
26779
  await loadSkillsForAgent(agent);
@@ -27214,45 +27214,88 @@ import { readFile as readFile8, appendFile as appendFile2, mkdir as mkdir6 } fro
27214
27214
  import { join as join10 } from "path";
27215
27215
 
27216
27216
  // src/core/graphs.ts
27217
- import { readFile as readFile7, readdir as readdir4, writeFile as writeFile4, mkdir as mkdir5 } from "fs/promises";
27217
+ import { readFile as readFile7, readdir as readdir4, writeFile as writeFile4, mkdir as mkdir5, copyFile, unlink as unlink2 } from "fs/promises";
27218
27218
  import { join as join9 } from "path";
27219
+ import { existsSync as existsSync3 } from "fs";
27219
27220
  var DEFAULT_GRAPHS_DIR = join9(process.env.HOME || process.env.USERPROFILE || "~", ".agent-os", "graphs");
27220
27221
  function getGraphsDir() {
27221
27222
  return process.env.AGENT_OS_GRAPHS_DIR || DEFAULT_GRAPHS_DIR;
27222
27223
  }
27224
+ function getSeedGraphsDir() {
27225
+ if (process.env.AGENT_OS_SEED_GRAPHS_DIR)
27226
+ return process.env.AGENT_OS_SEED_GRAPHS_DIR;
27227
+ const fromDist = join9(import.meta.dir, "..", "data", "graphs");
27228
+ const fromSrc = join9(import.meta.dir, "..", "..", "data", "graphs");
27229
+ if (existsSync3(fromDist))
27230
+ return fromDist;
27231
+ if (existsSync3(fromSrc))
27232
+ return fromSrc;
27233
+ return join9(process.cwd(), "data", "graphs");
27234
+ }
27223
27235
  async function listGraphs() {
27224
27236
  const dir = getGraphsDir();
27237
+ let entries;
27225
27238
  try {
27226
- const entries = await readdir4(dir);
27227
- const summaries = [];
27228
- for (const name of entries) {
27229
- if (name.endsWith(".json")) {
27230
- const id = name.replace(/\.graph\.json$/, "").replace(/\.json$/, "");
27231
- if (id)
27232
- summaries.push({ id });
27239
+ entries = await readdir4(dir);
27240
+ } catch (err) {
27241
+ if (err.code !== "ENOENT")
27242
+ throw err;
27243
+ entries = [];
27244
+ }
27245
+ if (entries.length === 0) {
27246
+ const seedDir = getSeedGraphsDir();
27247
+ try {
27248
+ const seedEntries = await readdir4(seedDir);
27249
+ await mkdir5(dir, { recursive: true });
27250
+ for (const name of seedEntries) {
27251
+ if (name.endsWith(".json")) {
27252
+ await copyFile(join9(seedDir, name), join9(dir, name));
27253
+ }
27233
27254
  }
27255
+ entries = await readdir4(dir);
27256
+ } catch {}
27257
+ }
27258
+ const summaries = [];
27259
+ for (const name of entries) {
27260
+ if (name.endsWith(".json")) {
27261
+ const id = name.replace(/\.graph\.json$/, "").replace(/\.json$/, "");
27262
+ if (id)
27263
+ summaries.push({ id });
27234
27264
  }
27235
- return summaries;
27236
- } catch (err) {
27237
- if (err.code === "ENOENT")
27238
- return [];
27239
- throw err;
27240
27265
  }
27266
+ return summaries;
27241
27267
  }
27242
27268
  async function loadGraph(id) {
27243
27269
  const dir = getGraphsDir();
27244
27270
  try {
27245
- const entries = await readdir4(dir);
27271
+ let entries;
27272
+ try {
27273
+ entries = await readdir4(dir);
27274
+ } catch (e) {
27275
+ if (e.code !== "ENOENT")
27276
+ throw e;
27277
+ entries = [];
27278
+ }
27246
27279
  const file = entries.find((name) => name === `${id}.json` || name === `${id}.graph.json`);
27247
- if (!file)
27280
+ if (file) {
27281
+ const raw = await readFile7(join9(dir, file), "utf-8");
27282
+ const parsed = JSON.parse(raw);
27283
+ validateGraph(parsed);
27284
+ return parsed;
27285
+ }
27286
+ const seedDir = getSeedGraphsDir();
27287
+ const seedFile = `${id}.json`;
27288
+ try {
27289
+ const raw = await readFile7(join9(seedDir, seedFile), "utf-8");
27290
+ const parsed = JSON.parse(raw);
27291
+ validateGraph(parsed);
27292
+ await mkdir5(dir, { recursive: true });
27293
+ await writeFile4(join9(dir, seedFile), raw, "utf-8");
27294
+ return parsed;
27295
+ } catch {
27248
27296
  return null;
27249
- const raw = await readFile7(join9(dir, file), "utf-8");
27250
- const parsed = JSON.parse(raw);
27251
- validateGraph(parsed);
27252
- return parsed;
27297
+ }
27253
27298
  } catch (err) {
27254
- if (err.code === "ENOENT")
27255
- return null;
27256
27299
  console.error("[graphs] Failed to load graph:", err);
27257
27300
  return null;
27258
27301
  }
@@ -27264,6 +27307,22 @@ async function saveGraph(graph) {
27264
27307
  const path = join9(dir, `${graph.id}.json`);
27265
27308
  await writeFile4(path, JSON.stringify(graph, null, 2), "utf-8");
27266
27309
  }
27310
+ async function deleteGraph(id) {
27311
+ if (!id || typeof id !== "string")
27312
+ throw new Error("Graph id required");
27313
+ const dir = getGraphsDir();
27314
+ let entries;
27315
+ try {
27316
+ entries = await readdir4(dir);
27317
+ } catch (err) {
27318
+ if (err.code !== "ENOENT")
27319
+ throw err;
27320
+ return;
27321
+ }
27322
+ const file = entries.find((name) => name === `${id}.json` || name === `${id}.graph.json`);
27323
+ if (file)
27324
+ await unlink2(join9(dir, file));
27325
+ }
27267
27326
  function validateGraph(graph) {
27268
27327
  if (!graph || typeof graph !== "object") {
27269
27328
  throw new Error("Graph must be an object");
@@ -27278,6 +27337,7 @@ function validateGraph(graph) {
27278
27337
  throw new Error("Graph.edges must be an array");
27279
27338
  }
27280
27339
  }
27340
+ var DEFAULT_GRAPH_MAX_TURNS_PER_NODE = 5;
27281
27341
  function topologicalLevels(graph) {
27282
27342
  const nodes = graph.nodes.map((n) => n.id);
27283
27343
  const incoming = new Map;
@@ -27330,7 +27390,7 @@ function getPredecessors(graph) {
27330
27390
  return pred;
27331
27391
  }
27332
27392
  async function runGraph(options) {
27333
- const { graph, input } = options;
27393
+ const { graph, input, max_turns_per_node = DEFAULT_GRAPH_MAX_TURNS_PER_NODE } = options;
27334
27394
  const levels = topologicalLevels(graph);
27335
27395
  const predecessors = getPredecessors(graph);
27336
27396
  const outputs = new Map;
@@ -27355,7 +27415,11 @@ async function runGraph(options) {
27355
27415
  const taskInput = preds.length === 0 ? input : preds.map((p) => outputs.get(p) ?? "").filter(Boolean).join(`
27356
27416
 
27357
27417
  `) || input;
27358
- const result = await runAgent({ agent, task: taskInput });
27418
+ const result = await runAgent({
27419
+ agent,
27420
+ task: taskInput,
27421
+ maxTurnsOverride: max_turns_per_node
27422
+ });
27359
27423
  outputs.set(node.id, result.output || "");
27360
27424
  return {
27361
27425
  node_id: node.id,
@@ -27378,7 +27442,7 @@ async function runGraph(options) {
27378
27442
  };
27379
27443
  }
27380
27444
  async function runGraphStream(options, onEvent) {
27381
- const { graph, input } = options;
27445
+ const { graph, input, max_turns_per_node = DEFAULT_GRAPH_MAX_TURNS_PER_NODE } = options;
27382
27446
  const levels = topologicalLevels(graph);
27383
27447
  const predecessors = getPredecessors(graph);
27384
27448
  const outputs = new Map;
@@ -27404,7 +27468,11 @@ async function runGraphStream(options, onEvent) {
27404
27468
  const taskInput = preds.length === 0 ? input : preds.map((p) => outputs.get(p) ?? "").filter(Boolean).join(`
27405
27469
 
27406
27470
  `) || input;
27407
- const result = await runAgent({ agent, task: taskInput });
27471
+ const result = await runAgent({
27472
+ agent,
27473
+ task: taskInput,
27474
+ maxTurnsOverride: max_turns_per_node
27475
+ });
27408
27476
  outputs.set(node.id, result.output || "");
27409
27477
  const payload = {
27410
27478
  node_id: node.id,
@@ -29099,14 +29167,14 @@ async function loadPlugins() {
29099
29167
  // src/server.ts
29100
29168
  init_config();
29101
29169
  import { join as join13, dirname as dirname2, resolve as resolve4 } from "path";
29102
- import { mkdirSync, existsSync as existsSync3 } from "fs";
29170
+ import { mkdirSync, existsSync as existsSync4 } from "fs";
29103
29171
  import { mkdir as mkdir7 } from "fs/promises";
29104
29172
  var PORT = parseInt(process.env.PORT ?? "3010", 10);
29105
29173
  var DASHBOARD_DIST = (() => {
29106
29174
  const root = join13(import.meta.dir, "..");
29107
29175
  const a = resolve4(join13(root, "dashboard-dist"));
29108
29176
  const b = resolve4(join13(root, "dashboard", "dist"));
29109
- if (existsSync3(a) && existsSync3(join13(a, "index.html")))
29177
+ if (existsSync4(a) && existsSync4(join13(a, "index.html")))
29110
29178
  return a;
29111
29179
  return b;
29112
29180
  })();
@@ -29198,7 +29266,7 @@ function broadcastEvent(event) {
29198
29266
  }
29199
29267
  }
29200
29268
  function serveDashboard(pathname) {
29201
- if (!existsSync3(DASHBOARD_DIST) || !existsSync3(join13(DASHBOARD_DIST, "index.html"))) {
29269
+ if (!existsSync4(DASHBOARD_DIST) || !existsSync4(join13(DASHBOARD_DIST, "index.html"))) {
29202
29270
  return null;
29203
29271
  }
29204
29272
  const decoded = decodeURIComponent(pathname);
@@ -29207,7 +29275,7 @@ function serveDashboard(pathname) {
29207
29275
  }
29208
29276
  const subpath = decoded === "/" ? "index.html" : decoded.slice(1);
29209
29277
  const filePath = join13(DASHBOARD_DIST, subpath);
29210
- if (existsSync3(filePath)) {
29278
+ if (existsSync4(filePath)) {
29211
29279
  const file = Bun.file(filePath);
29212
29280
  const ext = subpath.split(".").pop() ?? "";
29213
29281
  const mime = {
@@ -29228,7 +29296,7 @@ function serveDashboard(pathname) {
29228
29296
  });
29229
29297
  }
29230
29298
  const indexPath = join13(DASHBOARD_DIST, "index.html");
29231
- if (existsSync3(indexPath)) {
29299
+ if (existsSync4(indexPath)) {
29232
29300
  return new Response(Bun.file(indexPath), {
29233
29301
  headers: { "Content-Type": "text/html" }
29234
29302
  });
@@ -29448,6 +29516,16 @@ function createRoutes() {
29448
29516
  const msg = e instanceof Error ? e.message : String(e);
29449
29517
  return jsonResponse({ error: msg }, 400);
29450
29518
  }
29519
+ },
29520
+ DELETE: async (req) => {
29521
+ const id = decodeURIComponent(req.params.id);
29522
+ try {
29523
+ await deleteGraph(id);
29524
+ return Response.json({ ok: true }, { headers: CORS_HEADERS });
29525
+ } catch (e) {
29526
+ const msg = e instanceof Error ? e.message : String(e);
29527
+ return jsonResponse({ error: msg }, 400);
29528
+ }
29451
29529
  }
29452
29530
  },
29453
29531
  "/api/memory/write": {
@@ -29668,7 +29746,7 @@ async function startServer() {
29668
29746
  await loadPlugins();
29669
29747
  await seedAgentsIfEmpty();
29670
29748
  const dashboardSecret = await getDashboardSecret();
29671
- const dashboardMissing = !existsSync3(DASHBOARD_DIST) || !existsSync3(join13(DASHBOARD_DIST, "index.html"));
29749
+ const dashboardMissing = !existsSync4(DASHBOARD_DIST) || !existsSync4(join13(DASHBOARD_DIST, "index.html"));
29672
29750
  if (dashboardMissing) {
29673
29751
  console.warn(`[sulala] Dashboard not found at ${DASHBOARD_DIST}. From package root run: cd dashboard && npm run build. If using a global install, reinstall: bun install -g @sulala/agent-os@latest`);
29674
29752
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sulala/agent-os",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,55 +0,0 @@
1
- ---
2
- name: gmail
3
- description: Send email via Gmail using your connected account. Uses Sulala Portal for the access token; includes a script to send email from the command line or automation. Requires the Sulala Portal skill for token. Use when the user wants to send email, compose mail, or automate Gmail sending.
4
- metadata:
5
- sulala:
6
- requires:
7
- skills:
8
- - sulala-portal
9
- ---
10
-
11
- # Gmail (Sulala Portal)
12
-
13
- Send email using your **Gmail account connected in the Sulala Portal**. This skill provides a **script**; use the **exec** tool to run it. No static send-email code in the loader; the skill is script + docs like YouTube Shorts automation.
14
-
15
- ## Overview
16
-
17
- 1. **Get an access token** from the Portal (list connections, then `POST connections/{connection_id}/use` for Gmail).
18
- 2. **Send email** by running the script via **exec** (see below).
19
-
20
- ## Getting the token (Portal)
21
-
22
- - **Tool**: `sulala-portal_request`
23
- - **List connections**: `GET` path `connections` (filter by `provider === "gmail"` in the response).
24
- - **Get token**: `POST` path `connections/{id}/use`. Use the connection’s `connection_id` from the list (e.g. `conn_gmail_...`). If you get 404, use `connection_id` in the path instead of `id`. Response: `{ connectionId, provider, accessToken, scopes }`.
25
-
26
- ## Sending email (use exec tool + script — preferred)
27
-
28
- Use the **exec** tool to run the skill script. No need to build RFC 2822 or base64url by hand.
29
-
30
- 1. Get **accessToken** from the Portal (`sulala-portal_request`: list connections, then `POST connections/{connection_id}/use` with the Gmail connection’s `connection_id`).
31
- 2. Call **exec** with:
32
- - **skill_id**: `"gmail"` (so the command runs in this skill’s directory).
33
- - **command**: `python3 scripts/send_email.py --token "<accessToken>" --to "recipient@example.com" --subject "Subject line" --body "Email body text."`
34
-
35
- Example exec call:
36
-
37
- ```json
38
- {
39
- "skill_id": "gmail",
40
- "command": "python3 scripts/send_email.py --token \"<paste accessToken from Portal>\" --to \"sai.ko@mothernode.com\" --subject \"test title\" --body \"test body\""
41
- }
42
- ```
43
-
44
- - **Token**: from `POST connections/{connection_id}/use` (see above).
45
- - **To**, **Subject**, **Body**: recipient email, subject line, and body text. Escape quotes inside the command string as needed.
46
-
47
- Full script options: [references/send-email.md](references/send-email.md).
48
-
49
- ## Skill layout (like YouTube Shorts automation)
50
-
51
- - **scripts/send_email.py** — runnable script: `--token`, `--to`, `--subject`, `--body` or `--body-file`.
52
- - **references/send-email.md** — how to run the script and get the token.
53
- - **SKILL.md** — this file: overview, get token, send via exec + script.
54
-
55
- No separate config: the Sulala Portal skill provides the gateway and token; this skill adds the script and docs.
@@ -1,54 +0,0 @@
1
- # Send email script
2
-
3
- The Gmail skill includes a script that sends email using the Gmail API with an OAuth2 access token. Use it when you have a token (e.g. from Sulala Portal) and want to send from the command line or from automation.
4
-
5
- ## Prerequisites
6
-
7
- - **Access token**: Obtain from Sulala Portal (`POST connections/{connection_id}/use` with your Gmail connection), or from your own OAuth flow. Token must have `https://www.googleapis.com/auth/gmail.send` scope.
8
- - **Python 3.6+**: No extra pip packages; script uses only the standard library.
9
-
10
- ## Usage
11
-
12
- From the skill directory (or with paths adjusted):
13
-
14
- ```bash
15
- python3 scripts/send_email.py \
16
- --token "YOUR_ACCESS_TOKEN" \
17
- --to "recipient@example.com" \
18
- --subject "Subject line" \
19
- --body "Email body text."
20
- ```
21
-
22
- With body from a file:
23
-
24
- ```bash
25
- python3 scripts/send_email.py \
26
- --token "YOUR_ACCESS_TOKEN" \
27
- --to "recipient@example.com" \
28
- --subject "Subject" \
29
- --body-file path/to/body.txt
30
- ```
31
-
32
- ## Options
33
-
34
- | Option | Required | Description |
35
- |-------------|----------|--------------------------------------|
36
- | `--token` | Yes | OAuth2 access token (Bearer). |
37
- | `--to` | Yes | Recipient email address. |
38
- | `--subject` | Yes | Email subject line. |
39
- | `--body` | One of | Email body as a string. |
40
- | `--body-file` | One of | Path to file containing body text. |
41
-
42
- ## Getting the token (Sulala Portal)
43
-
44
- 1. Call the Portal API to list connections: `GET connections` (filter for `provider === "gmail"`).
45
- 2. Call `POST connections/{connection_id}/use` with the Gmail connection’s `connection_id` (e.g. `conn_gmail_...`; use `connection_id` if `id` returns 404).
46
- 3. Use the `accessToken` from the response as `--token`.
47
-
48
- ## Troubleshooting
49
-
50
- | Problem | Cause | Action |
51
- |----------------------|--------------------------|---------------------------------|
52
- | 401 Unauthorized | Token expired or invalid | Refresh or re-issue token. |
53
- | Recipient required | Missing/invalid To | Ensure `--to` is a valid email. |
54
- | Invalid To header | Bad To format | Use plain email, no extra text. |
@@ -1,94 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Send email via Gmail API using an OAuth access token.
3
-
4
- Use when you have a token from Sulala Portal (connections/:id/use) or another source.
5
- No local OAuth files required; pass the token on the command line.
6
-
7
- Usage:
8
- python3 send_email.py --token TOKEN --to recipient@example.com --subject "Subject" --body "Body text"
9
- python3 send_email.py --token TOKEN --to recipient@example.com --subject "Subject" --body-file body.txt
10
-
11
- Requires: Python 3.6+. No extra pip packages (uses urllib and base64 from stdlib).
12
- """
13
-
14
- import argparse
15
- import base64
16
- import json
17
- import sys
18
- import urllib.request
19
- import urllib.error
20
-
21
- GMAIL_SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
22
-
23
-
24
- def base64url_encode(data: bytes) -> str:
25
- """Encode bytes to base64url (RFC 4648): no +/ padding, use -_."""
26
- b64 = base64.b64encode(data).decode("ascii")
27
- return b64.replace("+", "-").replace("/", "_").rstrip("=")
28
-
29
-
30
- def build_rfc2822(to: str, subject: str, body: str) -> str:
31
- """Build a minimal RFC 2822 message (CRLF line endings)."""
32
- lines = [
33
- f"To: {to}",
34
- f"Subject: {subject}",
35
- "Content-Type: text/plain; charset=UTF-8",
36
- "",
37
- body,
38
- ]
39
- return "\r\n".join(lines)
40
-
41
-
42
- def send(token: str, to: str, subject: str, body: str) -> None:
43
- rfc2822 = build_rfc2822(to, subject, body)
44
- raw = base64url_encode(rfc2822.encode("utf-8"))
45
- body_json = json.dumps({"raw": raw}).encode("utf-8")
46
-
47
- req = urllib.request.Request(
48
- GMAIL_SEND_URL,
49
- data=body_json,
50
- headers={
51
- "Authorization": f"Bearer {token}",
52
- "Content-Type": "application/json",
53
- },
54
- method="POST",
55
- )
56
- try:
57
- with urllib.request.urlopen(req) as resp:
58
- if resp.status != 200:
59
- sys.stderr.write(f"Unexpected status: {resp.status}\n")
60
- sys.exit(1)
61
- print("Email sent successfully.")
62
- except urllib.error.HTTPError as e:
63
- sys.stderr.write(f"Gmail API error: {e.code} {e.reason}\n")
64
- if e.fp:
65
- body = e.fp.read().decode("utf-8", errors="replace")
66
- sys.stderr.write(body)
67
- sys.stderr.write("\n")
68
- sys.exit(1)
69
- except OSError as e:
70
- sys.stderr.write(f"Request failed: {e}\n")
71
- sys.exit(1)
72
-
73
-
74
- def main() -> None:
75
- ap = argparse.ArgumentParser(description="Send email via Gmail API with an access token.")
76
- ap.add_argument("--token", required=True, help="OAuth2 access token (e.g. from Sulala Portal)")
77
- ap.add_argument("--to", required=True, help="Recipient email address")
78
- ap.add_argument("--subject", required=True, help="Email subject")
79
- group = ap.add_mutually_exclusive_group(required=True)
80
- group.add_argument("--body", help="Email body (plain text)")
81
- group.add_argument("--body-file", help="Path to file containing email body")
82
- args = ap.parse_args()
83
-
84
- if args.body is not None:
85
- body = args.body
86
- else:
87
- with open(args.body_file, "r", encoding="utf-8") as f:
88
- body = f.read()
89
-
90
- send(args.token, args.to, args.subject, body)
91
-
92
-
93
- if __name__ == "__main__":
94
- main()
@@ -1,91 +0,0 @@
1
- ---
2
- name: youtube
3
- description: Upload videos (or Shorts) to YouTube using a local OAuth setup. Uses a script in the skill directory; run it via the exec tool. Use when the user wants to upload a video, publish to YouTube, or upload Shorts.
4
- credentials:
5
- - YOUTUBE_CLIENT_SECRET_JSON
6
- ---
7
-
8
-
9
- # YouTube upload
10
-
11
- Upload videos to **YouTube** using a **script** in this skill. Credentials are stored in the skill directory (`client_secret.json` + `token.json`). Use the **exec** tool to run the script; no static upload code in the loader.
12
-
13
- ## Overview
14
-
15
- 1. **One-time setup**: Enable YouTube Data API v3, create an OAuth Desktop client, add the client JSON via Skills → Configure (or place `client_secret.json` in the skill). Install Node dependencies with **npm install** (see below). Run the upload script once to complete browser OAuth; `token.json` is saved for reuse.
16
- 2. **Upload**: Use **exec** with `skill_id: "youtube"` and a `command` that runs **node scripts/youtube_upload.js** (recommended) or the Python script, with `--file`, `--title`, and optional `--description`, `--tags`, `--privacy`.
17
-
18
- ## Setup
19
-
20
- Follow these steps so the skill can upload to your YouTube account. The **Skills → YouTube → Configure** dialog (Setup button) shows this section and a field for the client JSON.
21
-
22
- 1. **Google Cloud project**
23
- Go to [Google Cloud Console](https://console.cloud.google.com/). Create or select a project.
24
-
25
- 2. **Enable YouTube Data API v3**
26
- APIs & Services → Library → search “YouTube Data API v3” → Enable.
27
-
28
- 3. **Create OAuth 2.0 credentials**
29
- APIs & Services → Credentials → Create Credentials → **OAuth client ID**.
30
- - If asked, configure the OAuth consent screen (e.g. External, add your email).
31
- - Application type: **Web application** (so you can set a redirect URI).
32
- - Name: e.g. “YouTube upload”.
33
- - Under **Authorized redirect URIs**, click Add URI and add exactly:
34
- **`http://localhost:8090/`**
35
- (If this is missing, you will get “Error 400: redirect_uri_mismatch” when signing in.)
36
- - Create. Copy or download the client JSON.
37
-
38
- 4. **Add the client JSON to this skill**
39
- Paste the **full JSON** (the whole file content) into the **OAuth client JSON** field below (Skills → YouTube → Configure), then Save.
40
- Alternatively, save the file as `scripts/client_secret.json` in the YouTube skill directory.
41
-
42
- 5. **Install Node dependencies**
43
- From the skill directory run: `npm install`. The agent can do this via exec with `skill_id: "youtube"`, `command: "npm install"`.
44
-
45
- 6. **First sign-in**
46
- Run the upload script once (e.g. with a test video). A browser opens; sign in with your Google account and allow access. After that, `token.json` is saved and future uploads do not require signing in again.
47
-
48
- Details: [references/youtube-upload.md](references/youtube-upload.md).
49
-
50
- ## Install dependencies (agent must do this when needed)
51
-
52
- **Preferred (Node):** If the Node script fails with "Missing npm dependencies", or before first upload, install with **exec** and `skill_id: "youtube"`, `command: "npm install"`. Then retry. No system Python or pip needed.
53
-
54
- **Optional (Python):** If using the Python script and it reports missing Google API packages, use a virtual environment; the agent cannot install into system Python on externally-managed systems (e.g. macOS).
55
-
56
- ## Upload video (exec tool)
57
-
58
- Use **exec** with `skill_id: "youtube"` (command runs in the skill directory).
59
-
60
- **Recommended — Node script (no Python/pip):**
61
- `node scripts/youtube_upload.js --file <path> --title "<title>" [--description "<desc>"] [--tags "tag1,tag2"] [--privacy public|private|unlisted]`
62
-
63
- Example:
64
-
65
- ```json
66
- {
67
- "skill_id": "youtube",
68
- "command": "node scripts/youtube_upload.js --file /path/to/video.mp4 --title \"My video\" --description \"Optional\" --tags \"shorts,demo\" --privacy public"
69
- }
70
- ```
71
-
72
- **Optional — Python script** (requires venv + pip): `python3 scripts/youtube_upload.py --file ... --title ...`
73
-
74
- - **--file**: Path to the video file (absolute or relative to skill dir). Must be reachable where the agent runs.
75
- - **--title**: Required. **--description**, **--tags**, **--privacy** optional (default: `public`).
76
-
77
- **Interpreting exec result:** After running the upload script, check **stdout**. If it contains a line like `Uploaded: https://youtube.com/watch?v=...`, the upload **succeeded**. Tell the user the video was uploaded and give them that URL. Do not say authorization is required when stdout shows a success URL. If exitCode is non-zero but stdout contains the upload URL, still report success (the script may have been interrupted after writing the URL). Only when stdout has no upload URL and stderr says "Open this URL in your browser" should you ask the user to authorize.
78
-
79
- Full script options: [references/youtube-upload.md](references/youtube-upload.md).
80
-
81
- ## Skill layout
82
-
83
- - **scripts/youtube_upload.js** — Node upload script (recommended): `--file`, `--title`, `--description`, `--tags`, `--privacy`. No Python/pip required.
84
- - **scripts/youtube_upload.py** — Python upload script (optional; requires venv + pip install).
85
- - **package.json** — Node dependencies (googleapis). Run `npm install` in the skill dir.
86
- - **config.schema.json** — defines `YOUTUBE_CLIENT_SECRET_JSON` for the Skills config UI.
87
- - **scripts/client_secret.json** — (optional) OAuth client JSON if not set via Skills → Configure.
88
- - **scripts/token.json** — (created on first run) stored credentials; shared by both scripts.
89
- - **requirements.txt** — pip dependencies for the Python script only.
90
- - **references/youtube-upload.md** — setup, usage, and exec examples.
91
- - **SKILL.md** — this file.
@@ -1,11 +0,0 @@
1
- {
2
- "type": "object",
3
- "properties": {
4
- "YOUTUBE_CLIENT_SECRET_JSON": {
5
- "type": "string",
6
- "format": "password",
7
- "title": "OAuth client JSON",
8
- "description": "Paste the full content of client_secret.json from Google Cloud Console (Desktop OAuth 2.0 client, YouTube Data API v3 enabled). Alternatively place the file as scripts/client_secret.json in this skill directory."
9
- }
10
- }
11
- }
@@ -1,8 +0,0 @@
1
- {
2
- "name": "youtube-skill",
3
- "private": true,
4
- "description": "YouTube upload script dependencies",
5
- "dependencies": {
6
- "googleapis": "^144.0.0"
7
- }
8
- }
@@ -1,65 +0,0 @@
1
- # YouTube upload script
2
-
3
- Upload a video to YouTube using OAuth credentials. The script reads credentials from **Skills config** (env `YOUTUBE_CLIENT_SECRET_JSON`, set via Dashboard → Skills → YouTube → Configure) or from `scripts/client_secret.json`, and saves `token.json` after the first browser OAuth flow.
4
-
5
- ## Prerequisites
6
-
7
- - **Google Cloud project** with YouTube Data API v3 enabled.
8
- - **OAuth 2.0 Client** (Desktop app): add the JSON via **Skills → YouTube → Configure** (paste full content), or save as `data/skills/youtube/scripts/client_secret.json`.
9
- - **Python 3** with pip packages from `data/skills/youtube/requirements.txt`:
10
- ```bash
11
- pip install -r data/skills/youtube/requirements.txt
12
- ```
13
- Or: `google-api-python-client`, `google-auth-oauthlib`, `google-auth-httplib2`.
14
-
15
- ## One-time setup
16
-
17
- 1. In [Google Cloud Console](https://console.cloud.google.com/): create or select a project → APIs & Services → Enable **YouTube Data API v3**.
18
- 2. Create **OAuth 2.0 Client ID** (Application type: Desktop app). Download the JSON.
19
- 3. Add the JSON via **Skills → YouTube → Configure** (paste the full content), or save as `client_secret.json` in the skill’s **scripts** directory.
20
- 4. Run the script once with any test args; a browser will open to sign in. After authorizing, `token.json` is created in the same directory. Future runs use this token (refreshed automatically).
21
-
22
- ## Usage
23
-
24
- From the skill directory (or use **exec** with `skill_id: "youtube"` so cwd is the skill dir):
25
-
26
- ```bash
27
- python3 scripts/youtube_upload.py \
28
- --file /path/to/video.mp4 \
29
- --title "My video title" \
30
- --description "Optional description" \
31
- --tags "shorts,demo" \
32
- --privacy public
33
- ```
34
-
35
- ## Options
36
-
37
- | Option | Required | Description |
38
- |----------------|----------|--------------------------------------------------|
39
- | `--file` | Yes | Path to video file (absolute or relative to script dir). |
40
- | `--title` | Yes | Video title. |
41
- | `--description`| No | Video description (default: empty). |
42
- | `--tags` | No | Comma-separated tags (e.g. `shorts,demo`). |
43
- | `--privacy` | No | `public`, `private`, or `unlisted` (default: public). |
44
-
45
- ## Agent usage (exec tool)
46
-
47
- Use the **exec** tool with `skill_id: "youtube"` so the command runs in the YouTube skill directory:
48
-
49
- ```json
50
- {
51
- "skill_id": "youtube",
52
- "command": "python3 scripts/youtube_upload.py --file /path/to/video.mp4 --title \"My title\" --description \"Description\" --tags \"tag1,tag2\" --privacy public"
53
- }
54
- ```
55
-
56
- Ensure the video file path is accessible from the environment where the agent runs (e.g. workspace path or absolute path). Quotes inside the command string must be escaped as required by the shell.
57
-
58
- ## Troubleshooting
59
-
60
- | Problem | Cause | Action |
61
- |----------------------------|---------------------------------|---------------------------------------------|
62
- | client_secret.json not found / YOUTUBE_CLIENT_SECRET_JSON not set | Credentials missing | Add via Skills → YouTube → Configure or place `scripts/client_secret.json`. |
63
- | Token expired / invalid | First run or token revoked | Delete `token.json` and run again to re-auth. |
64
- | File not found | Wrong path for `--file` | Use absolute path or path relative to script dir. |
65
- | Quota exceeded | YouTube API quota | Check quota in Cloud Console; wait or request increase. |
@@ -1,3 +0,0 @@
1
- google-api-python-client
2
- google-auth-oauthlib
3
- google-auth-httplib2