@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/dashboard-dist/assets/index-CVI9FAmG.css +1 -0
- package/dashboard-dist/assets/index-DdMu_Z6v.js +75 -0
- package/dashboard-dist/index.html +2 -2
- package/data/agents/crm_hubspot_agent.json +11 -0
- package/data/agents/research_agent.json +1 -1
- package/data/agents/social_media_agent.json +1 -1
- package/data/agents/source_verify_agent.json +10 -0
- package/data/agents/writer_agent.json +1 -1
- package/data/skills/content-writing/SKILL.md +32 -0
- package/dist/cli.js +123 -46
- package/dist/index.js +113 -35
- package/package.json +1 -1
- package/data/skills/gmail/SKILL.md +0 -55
- package/data/skills/gmail/references/send-email.md +0 -54
- package/data/skills/gmail/scripts/send_email.py +0 -94
- package/data/skills/youtube/SKILL.md +0 -91
- package/data/skills/youtube/config.schema.json +0 -11
- package/data/skills/youtube/package.json +0 -8
- package/data/skills/youtube/references/youtube-upload.md +0 -65
- package/data/skills/youtube/requirements.txt +0 -3
- package/data/skills/youtube/scripts/youtube_upload.js +0 -200
- package/data/skills/youtube/scripts/youtube_upload.py +0 -125
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
|
-
|
|
27227
|
-
|
|
27228
|
-
|
|
27229
|
-
|
|
27230
|
-
|
|
27231
|
-
|
|
27232
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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
|
|
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 (
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
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 = !
|
|
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,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,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. |
|