wyren-mcp 1.0.0 → 1.2.0
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/CHANGELOG.md +33 -0
- package/README.md +79 -6
- package/lib/config.mjs +46 -0
- package/lib/device-auth.mjs +96 -0
- package/lib/open-browser.mjs +35 -0
- package/lib/service.mjs +216 -0
- package/package.json +26 -4
- package/remotion-bundle/724.bundle.js +72911 -0
- package/remotion-bundle/724.bundle.js.map +1 -0
- package/remotion-bundle/761.bundle.js +64 -0
- package/remotion-bundle/761.bundle.js.map +1 -0
- package/remotion-bundle/bundle.js +91375 -0
- package/remotion-bundle/bundle.js.map +1 -0
- package/remotion-bundle/favicon.ico +0 -0
- package/remotion-bundle/index.html +48 -0
- package/remotion-bundle/source-map-helper.wasm +0 -0
- package/scripts/sync-worker.mjs +75 -0
- package/setup.mjs +123 -30
- package/skills/wyren/rules/nodes.md +26 -16
- package/skills/wyren/rules/workflow-patterns.md +34 -0
- package/worker-launcher.mjs +67 -0
- package/worker-standalone/index.mjs +37730 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
- **Fixed** Caption (and slideshow) renders failed in the daemon: the vendored `remotion-bundle` was missing `bundle.js.map`, which Remotion's `prepareServer` reads — `sync-worker.mjs` was stripping ALL `.map` files. It now keeps the Remotion bundle maps (still drops the worker's debug map).
|
|
6
|
+
- **Fixed** Chunked captions served trimmed segments over `file://`, which Chromium refuses (fails on WSL2; OffthreadVideo is http-only). Segments are now served over a loopback HTTP server (Range + CORS).
|
|
7
|
+
- **Fixed** Daemon exited on any WebSocket drop (every backend redeploy) instead of reconnecting — the reconnect timer was `unref`'d. It is now ref'd, so the daemon rides through deploys via exponential backoff.
|
|
8
|
+
- **Changed** Remotion pinned to exactly 4.0.421 across renderer + bundler (was drifting to 4.0.452); the worker bundle is rebuilt from monorepo main (0600ac2).
|
|
9
|
+
- **Fixed** The daemon no longer needs R2 credentials. It uploads results via backend-issued **presigned URLs** (`/api/upload/{video,image}/presign` → PUT) using only its `frm_` API key — so it runs on any end-user machine without distributing R2 secrets. Verified end-to-end against prod.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## 1.1.0
|
|
13
|
+
|
|
14
|
+
- **Added** Auto-starting local render worker. `npx wyren-mcp` now logs you in
|
|
15
|
+
via a one-time browser approval (device-code flow), writes the minted key to
|
|
16
|
+
`~/.wyren/config.json` (0600), and registers a login-time service (launchd /
|
|
17
|
+
systemd `--user` / Task Scheduler) that runs the bundled worker.
|
|
18
|
+
- **Added** `worker-standalone/` (self-contained worker binary) and
|
|
19
|
+
`remotion-bundle/` (prebuilt Remotion compositions) shipped as package assets,
|
|
20
|
+
generated from the monorepo via `scripts/sync-worker.mjs`.
|
|
21
|
+
- **Added** Runtime dependencies for the worker: `@remotion/renderer` (pinned
|
|
22
|
+
`4.0.421`), `ffmpeg-static`, `ws`, `zod`, `file-type`, `@aws-sdk/client-s3`,
|
|
23
|
+
`@aws-sdk/lib-storage`.
|
|
24
|
+
- **Added** `npx wyren-mcp --uninstall` to remove the auto-start service, and
|
|
25
|
+
`--no-worker` to skip worker setup at install time.
|
|
26
|
+
- **Changed** `setup.mjs` is now a 4-step installer (MCP add → skill → device
|
|
27
|
+
login → service registration). All worker steps are wrapped so install never
|
|
28
|
+
hard-fails; the exact manual run command is printed if any step is
|
|
29
|
+
unsupported or declined.
|
|
30
|
+
|
|
31
|
+
## 1.0.0
|
|
32
|
+
|
|
33
|
+
- Initial installer: adds the Wyren MCP server and copies the agent skill.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# wyren-mcp
|
|
2
2
|
|
|
3
|
-
Install the [Wyren](https://wyren.yibby.ai) MCP server
|
|
3
|
+
Install the [Wyren](https://wyren.yibby.ai) MCP server, agent skills, and an auto-starting local render worker for Claude Code.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -12,16 +12,78 @@ npx wyren-mcp
|
|
|
12
12
|
npx wyren-mcp --global
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
This
|
|
15
|
+
This:
|
|
16
|
+
|
|
17
|
+
1. Adds the Wyren MCP server to your Claude Code config.
|
|
18
|
+
2. Installs the Wyren agent skill for guided AI pipeline building.
|
|
19
|
+
3. Logs you in once via your browser and starts a **local render worker** that auto-starts at login.
|
|
16
20
|
|
|
17
21
|
## What you get
|
|
18
22
|
|
|
19
|
-
- **MCP Server** —
|
|
20
|
-
- **Agent Skill** — teaches Claude how to use Wyren tools effectively, with workflow patterns and domain knowledge
|
|
23
|
+
- **MCP Server** — tools for creating, building, executing, and publishing AI workflows.
|
|
24
|
+
- **Agent Skill** — teaches Claude how to use Wyren tools effectively, with workflow patterns and domain knowledge.
|
|
25
|
+
- **Local render worker** — a background daemon that runs heavy renders (captions, slideshows, video merge/trim, audio overlay, brainrot compose) on your machine instead of Wyren's servers. Faster for you, cheaper to run.
|
|
26
|
+
|
|
27
|
+
## The local render worker
|
|
28
|
+
|
|
29
|
+
### One-time browser approval
|
|
30
|
+
|
|
31
|
+
During setup, your browser opens to a Wyren `/device` page showing a short code. Approve it while logged in to your Wyren account. The installer then mints an API key scoped to your account and writes it to:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
~/.wyren/config.json (permissions 0600 — readable only by you)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
You never copy-paste a key. After this one approval, the worker authenticates automatically on every start.
|
|
38
|
+
|
|
39
|
+
### Auto-start at login
|
|
40
|
+
|
|
41
|
+
The installer registers a per-user login service that runs the worker whenever you log in:
|
|
42
|
+
|
|
43
|
+
| Platform | Mechanism | Location |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| macOS | launchd LaunchAgent | `~/Library/LaunchAgents/ai.wyren.worker.plist` |
|
|
46
|
+
| Linux | systemd `--user` unit | `~/.config/systemd/user/wyren-worker.service` |
|
|
47
|
+
| Windows | Task Scheduler (ONLOGON) | task `Wyren\WyrenWorker` |
|
|
48
|
+
|
|
49
|
+
The service runs `worker-launcher.mjs`, which reads your key from `~/.wyren/config.json` and starts the bundled worker. The key is never written into the service definition itself.
|
|
50
|
+
|
|
51
|
+
### Where things live
|
|
52
|
+
|
|
53
|
+
- **Key / config**: `~/.wyren/config.json`
|
|
54
|
+
- **Logs**: `~/.wyren/worker.log`
|
|
55
|
+
- **Worker binary + Remotion bundle**: shipped inside this package (`worker-standalone/`, `remotion-bundle/`).
|
|
56
|
+
|
|
57
|
+
On the first render the worker downloads a headless Chromium (~200MB) via Remotion's `ensureBrowser()`. This is a one-time download.
|
|
58
|
+
|
|
59
|
+
### Disabling / uninstalling the worker
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx wyren-mcp --uninstall
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This removes the auto-start service. Your `~/.wyren/config.json` is left in place — delete it manually to fully reset. To skip the worker at install time:
|
|
21
66
|
|
|
22
|
-
|
|
67
|
+
```bash
|
|
68
|
+
npx wyren-mcp --no-worker
|
|
69
|
+
```
|
|
23
70
|
|
|
24
|
-
|
|
71
|
+
### Running the worker manually
|
|
72
|
+
|
|
73
|
+
If auto-start is unsupported on your system, or you prefer to run it yourself:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
WYREN_API_KEY=<your frm_ key> \
|
|
77
|
+
WYREN_BACKEND_URL=https://api.wyren.ai \
|
|
78
|
+
WYREN_REMOTION_BUNDLE_DIR=<package>/remotion-bundle \
|
|
79
|
+
node <package>/worker-standalone/index.mjs
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
(`<package>` is wherever npm installed `wyren-mcp`.)
|
|
83
|
+
|
|
84
|
+
## Manual MCP setup
|
|
85
|
+
|
|
86
|
+
If the installer doesn't work, set up the MCP server manually:
|
|
25
87
|
|
|
26
88
|
```bash
|
|
27
89
|
# Add MCP server (local)
|
|
@@ -33,3 +95,14 @@ claude mcp add --transport http --scope user wyren https://api.wyren.ai/mcp
|
|
|
33
95
|
# Install skill (via skills CLI)
|
|
34
96
|
npx skills add briarbearrr/wyren-mcp
|
|
35
97
|
```
|
|
98
|
+
|
|
99
|
+
## Maintainers
|
|
100
|
+
|
|
101
|
+
The `worker-standalone/` and `remotion-bundle/` directories are **generated** from the Wyren monorepo, not authored here. To update them, see `scripts/sync-worker.mjs`:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# in the monorepo:
|
|
105
|
+
npm run build:worker && npm run bundle:remotion
|
|
106
|
+
# then, in this repo:
|
|
107
|
+
node scripts/sync-worker.mjs /path/to/frames
|
|
108
|
+
```
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Wyren config persistence — `~/.wyren/config.json`, 0600.
|
|
2
|
+
//
|
|
3
|
+
// The device-login flow writes the minted `frm_...` key + backend URL here so
|
|
4
|
+
// the worker daemon (and every subsequent login-time start) can authenticate
|
|
5
|
+
// without the user ever copy-pasting a key. The service definition we register
|
|
6
|
+
// reads the key from this file at start time.
|
|
7
|
+
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
export const WYREN_DIR = join(homedir(), '.wyren');
|
|
13
|
+
export const CONFIG_PATH = join(WYREN_DIR, 'config.json');
|
|
14
|
+
export const LOG_PATH = join(WYREN_DIR, 'worker.log');
|
|
15
|
+
|
|
16
|
+
/** Ensure `~/.wyren` exists with restrictive perms; return its path. */
|
|
17
|
+
export function ensureWyrenDir() {
|
|
18
|
+
if (!existsSync(WYREN_DIR)) {
|
|
19
|
+
mkdirSync(WYREN_DIR, { recursive: true, mode: 0o700 });
|
|
20
|
+
}
|
|
21
|
+
return WYREN_DIR;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read `~/.wyren/config.json`, or `null` if it doesn't exist / is unreadable. */
|
|
25
|
+
export function readConfig() {
|
|
26
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write `{ apiKey, backendUrl, ... }` to `~/.wyren/config.json` with 0600 perms.
|
|
36
|
+
* Merges over any existing config so re-running setup doesn't drop fields.
|
|
37
|
+
*/
|
|
38
|
+
export function writeConfig(patch) {
|
|
39
|
+
ensureWyrenDir();
|
|
40
|
+
const current = readConfig() ?? {};
|
|
41
|
+
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
42
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
43
|
+
// writeFileSync's mode only applies on create; enforce on overwrite too.
|
|
44
|
+
chmodSync(CONFIG_PATH, 0o600);
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Device-code login — mirrors `gh auth login` / OAuth 2.0 device flow.
|
|
2
|
+
//
|
|
3
|
+
// Backend contract (frames monorepo `backend/src/routes/auth.ts`):
|
|
4
|
+
// POST /api/auth/device-code (unauthenticated)
|
|
5
|
+
// → 200 { deviceCode, userCode, verificationUrl, expiresIn, interval }
|
|
6
|
+
// POST /api/auth/device-token (unauthenticated, body { deviceCode })
|
|
7
|
+
// → 428 { status: 'authorization_pending' } (keep polling)
|
|
8
|
+
// → 200 { status: 'complete', apiKey: 'frm_...' }
|
|
9
|
+
// → 400 { status: 'expired' | 'denied' }
|
|
10
|
+
//
|
|
11
|
+
// The user approves in-browser at `verificationUrl` (the `/device` page) while
|
|
12
|
+
// logged in; that mints a key scoped to their account and flips the device code
|
|
13
|
+
// to approved. We never see their session — only the one-time `frm_...` key.
|
|
14
|
+
|
|
15
|
+
import { openBrowser } from './open-browser.mjs';
|
|
16
|
+
|
|
17
|
+
class DeviceAuthError extends Error {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run the full device-code login against `backendUrl`.
|
|
21
|
+
* Returns the minted `frm_...` apiKey, or throws DeviceAuthError on
|
|
22
|
+
* expiry/denial/timeout. Network/transport failures bubble as-is so the
|
|
23
|
+
* caller's try/catch can fall back to the manual path.
|
|
24
|
+
*/
|
|
25
|
+
export async function deviceLogin(backendUrl, { log = console.log } = {}) {
|
|
26
|
+
const base = backendUrl.replace(/\/$/, '');
|
|
27
|
+
|
|
28
|
+
const startRes = await fetch(`${base}/api/auth/device-code`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: '{}',
|
|
32
|
+
});
|
|
33
|
+
if (!startRes.ok) {
|
|
34
|
+
throw new DeviceAuthError(
|
|
35
|
+
`device-code request failed (HTTP ${startRes.status}). The backend may not yet expose the device-auth flow.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const grant = await startRes.json();
|
|
39
|
+
const { deviceCode, userCode, verificationUrl } = grant;
|
|
40
|
+
const intervalMs = Math.max(1, Number(grant.interval) || 5) * 1000;
|
|
41
|
+
const expiresMs = Math.max(30, Number(grant.expiresIn) || 600) * 1000;
|
|
42
|
+
|
|
43
|
+
log('');
|
|
44
|
+
log(' To finish setup, approve this device in your browser:');
|
|
45
|
+
log(` ${verificationUrl}`);
|
|
46
|
+
log('');
|
|
47
|
+
log(` Verification code: ${userCode}`);
|
|
48
|
+
log(' (Opening your browser. If it does not open, visit the URL above.)');
|
|
49
|
+
log('');
|
|
50
|
+
|
|
51
|
+
openBrowser(verificationUrl);
|
|
52
|
+
|
|
53
|
+
const deadline = Date.now() + expiresMs;
|
|
54
|
+
// OAuth device-flow politeness: respect the server interval between polls.
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
await sleep(intervalMs);
|
|
57
|
+
let pollRes;
|
|
58
|
+
try {
|
|
59
|
+
pollRes = await fetch(`${base}/api/auth/device-token`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'content-type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ deviceCode }),
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
// Transient network blip — keep polling until the deadline.
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pollRes.status === 428) continue; // authorization_pending
|
|
70
|
+
if (pollRes.ok) {
|
|
71
|
+
const data = await pollRes.json();
|
|
72
|
+
if (data.status === 'complete' && typeof data.apiKey === 'string') {
|
|
73
|
+
return data.apiKey;
|
|
74
|
+
}
|
|
75
|
+
throw new DeviceAuthError('Unexpected device-token response.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 400 → expired or denied.
|
|
79
|
+
let status = 'denied';
|
|
80
|
+
try {
|
|
81
|
+
({ status } = await pollRes.json());
|
|
82
|
+
} catch {
|
|
83
|
+
/* keep default */
|
|
84
|
+
}
|
|
85
|
+
throw new DeviceAuthError(
|
|
86
|
+
status === 'expired'
|
|
87
|
+
? 'Device code expired before approval.'
|
|
88
|
+
: 'Device authorization was denied.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
throw new DeviceAuthError('Timed out waiting for browser approval.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sleep(ms) {
|
|
95
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
96
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Cross-platform "open this URL in the default browser".
|
|
2
|
+
//
|
|
3
|
+
// No dependency on the `open` npm package — a tiny spawn of the platform's
|
|
4
|
+
// native opener keeps the installer dependency-free for the auth step. Best
|
|
5
|
+
// effort: if the spawn fails (headless box, no DISPLAY), the caller still
|
|
6
|
+
// prints the URL for the user to open manually.
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
|
|
10
|
+
export function openBrowser(url) {
|
|
11
|
+
const platform = process.platform;
|
|
12
|
+
let cmd;
|
|
13
|
+
let args;
|
|
14
|
+
|
|
15
|
+
if (platform === 'darwin') {
|
|
16
|
+
cmd = 'open';
|
|
17
|
+
args = [url];
|
|
18
|
+
} else if (platform === 'win32') {
|
|
19
|
+
// `start` is a cmd builtin; the empty title arg avoids quoting pitfalls.
|
|
20
|
+
cmd = 'cmd';
|
|
21
|
+
args = ['/c', 'start', '', url];
|
|
22
|
+
} else {
|
|
23
|
+
cmd = 'xdg-open';
|
|
24
|
+
args = [url];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
29
|
+
child.on('error', () => {});
|
|
30
|
+
child.unref();
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/lib/service.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// OS auto-start registration for the Wyren worker.
|
|
2
|
+
//
|
|
3
|
+
// Registers a login-time service that runs `node <pkg>/worker-launcher.mjs`
|
|
4
|
+
// (which reads the 0600 config and execs the bundled worker). Idempotent:
|
|
5
|
+
// re-running setup re-writes the same unit/plist/task in place rather than
|
|
6
|
+
// duplicating it. `uninstallService` tears it down.
|
|
7
|
+
//
|
|
8
|
+
// macOS → launchd LaunchAgent (~/Library/LaunchAgents/ai.wyren.worker.plist)
|
|
9
|
+
// Linux → systemd --user unit (~/.config/systemd/user/wyren-worker.service)
|
|
10
|
+
// Windows → Task Scheduler task (schtasks /TN Wyren\WyrenWorker, ONLOGON)
|
|
11
|
+
//
|
|
12
|
+
// VERIFIED ON: Linux (systemd --user path validated with `systemctl --user
|
|
13
|
+
// cat`). The macOS launchd and Windows schtasks generators are written and
|
|
14
|
+
// linted but UNVERIFIED on the build box — see README "Disabling the worker".
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'child_process';
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
|
|
21
|
+
import { LOG_PATH, ensureWyrenDir } from './config.mjs';
|
|
22
|
+
|
|
23
|
+
export const SERVICE_LABEL = 'ai.wyren.worker';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Linux — systemd --user
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const SYSTEMD_DIR = join(homedir(), '.config', 'systemd', 'user');
|
|
30
|
+
const SYSTEMD_UNIT_PATH = join(SYSTEMD_DIR, 'wyren-worker.service');
|
|
31
|
+
|
|
32
|
+
function linuxUnit(nodePath, launcherPath) {
|
|
33
|
+
// Restart=always with a backoff keeps the worker alive across crashes/network
|
|
34
|
+
// drops; the daemon itself also reconnects, so this only covers hard exits.
|
|
35
|
+
return `[Unit]
|
|
36
|
+
Description=Wyren local render worker
|
|
37
|
+
After=network-online.target
|
|
38
|
+
Wants=network-online.target
|
|
39
|
+
|
|
40
|
+
[Service]
|
|
41
|
+
Type=simple
|
|
42
|
+
ExecStart=${nodePath} ${launcherPath}
|
|
43
|
+
Restart=always
|
|
44
|
+
RestartSec=5
|
|
45
|
+
StandardOutput=append:${LOG_PATH}
|
|
46
|
+
StandardError=append:${LOG_PATH}
|
|
47
|
+
|
|
48
|
+
[Install]
|
|
49
|
+
WantedBy=default.target
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function installLinux(nodePath, launcherPath) {
|
|
54
|
+
mkdirSync(SYSTEMD_DIR, { recursive: true });
|
|
55
|
+
writeFileSync(SYSTEMD_UNIT_PATH, linuxUnit(nodePath, launcherPath), { mode: 0o644 });
|
|
56
|
+
// `daemon-reload` picks up the new unit; `enable` wires it to login;
|
|
57
|
+
// `--now` also starts it immediately. All best-effort.
|
|
58
|
+
systemctlUser(['daemon-reload']);
|
|
59
|
+
systemctlUser(['enable', '--now', 'wyren-worker.service']);
|
|
60
|
+
return { type: 'systemd', path: SYSTEMD_UNIT_PATH };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function uninstallLinux() {
|
|
64
|
+
// `disable` fails loudly when the unit was never installed — that's the
|
|
65
|
+
// common "already uninstalled" case, so swallow it and just clean up.
|
|
66
|
+
systemctlUserSoft(['disable', '--now', 'wyren-worker.service']);
|
|
67
|
+
if (existsSync(SYSTEMD_UNIT_PATH)) rmSync(SYSTEMD_UNIT_PATH);
|
|
68
|
+
systemctlUserSoft(['daemon-reload']);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function systemctlUser(args) {
|
|
72
|
+
execFileSync('systemctl', ['--user', ...args], { stdio: 'pipe' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function systemctlUserSoft(args) {
|
|
76
|
+
try {
|
|
77
|
+
systemctlUser(args);
|
|
78
|
+
} catch {
|
|
79
|
+
/* unit not present / nothing to do */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// macOS — launchd LaunchAgent
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const LAUNCHD_DIR = join(homedir(), 'Library', 'LaunchAgents');
|
|
88
|
+
const LAUNCHD_PLIST_PATH = join(LAUNCHD_DIR, `${SERVICE_LABEL}.plist`);
|
|
89
|
+
|
|
90
|
+
function macPlist(nodePath, launcherPath) {
|
|
91
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
92
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
93
|
+
<plist version="1.0">
|
|
94
|
+
<dict>
|
|
95
|
+
<key>Label</key>
|
|
96
|
+
<string>${SERVICE_LABEL}</string>
|
|
97
|
+
<key>ProgramArguments</key>
|
|
98
|
+
<array>
|
|
99
|
+
<string>${nodePath}</string>
|
|
100
|
+
<string>${launcherPath}</string>
|
|
101
|
+
</array>
|
|
102
|
+
<key>RunAtLoad</key>
|
|
103
|
+
<true/>
|
|
104
|
+
<key>KeepAlive</key>
|
|
105
|
+
<true/>
|
|
106
|
+
<key>StandardOutPath</key>
|
|
107
|
+
<string>${LOG_PATH}</string>
|
|
108
|
+
<key>StandardErrorPath</key>
|
|
109
|
+
<string>${LOG_PATH}</string>
|
|
110
|
+
</dict>
|
|
111
|
+
</plist>
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function installMac(nodePath, launcherPath) {
|
|
116
|
+
mkdirSync(LAUNCHD_DIR, { recursive: true });
|
|
117
|
+
writeFileSync(LAUNCHD_PLIST_PATH, macPlist(nodePath, launcherPath), { mode: 0o644 });
|
|
118
|
+
// Idempotent: unload first (ignore failure when not yet loaded), then load.
|
|
119
|
+
try {
|
|
120
|
+
execFileSync('launchctl', ['unload', LAUNCHD_PLIST_PATH], { stdio: 'pipe' });
|
|
121
|
+
} catch {
|
|
122
|
+
/* not loaded yet */
|
|
123
|
+
}
|
|
124
|
+
execFileSync('launchctl', ['load', '-w', LAUNCHD_PLIST_PATH], { stdio: 'pipe' });
|
|
125
|
+
return { type: 'launchd', path: LAUNCHD_PLIST_PATH };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function uninstallMac() {
|
|
129
|
+
if (existsSync(LAUNCHD_PLIST_PATH)) {
|
|
130
|
+
try {
|
|
131
|
+
execFileSync('launchctl', ['unload', '-w', LAUNCHD_PLIST_PATH], { stdio: 'pipe' });
|
|
132
|
+
} catch {
|
|
133
|
+
/* already unloaded */
|
|
134
|
+
}
|
|
135
|
+
rmSync(LAUNCHD_PLIST_PATH);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Windows — Task Scheduler (ONLOGON)
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
const WIN_TASK_NAME = 'Wyren\\WyrenWorker';
|
|
144
|
+
|
|
145
|
+
function installWindows(nodePath, launcherPath) {
|
|
146
|
+
// /F overwrites an existing task → idempotent. /SC ONLOGON runs at user login.
|
|
147
|
+
// /RL LIMITED keeps it in the user's security context.
|
|
148
|
+
const tr = `"${nodePath}" "${launcherPath}"`;
|
|
149
|
+
execFileSync(
|
|
150
|
+
'schtasks',
|
|
151
|
+
['/Create', '/F', '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TN', WIN_TASK_NAME, '/TR', tr],
|
|
152
|
+
{ stdio: 'pipe' },
|
|
153
|
+
);
|
|
154
|
+
// Start it now (best-effort; the task is also wired to next logon).
|
|
155
|
+
try {
|
|
156
|
+
execFileSync('schtasks', ['/Run', '/TN', WIN_TASK_NAME], { stdio: 'pipe' });
|
|
157
|
+
} catch {
|
|
158
|
+
/* will run at next logon */
|
|
159
|
+
}
|
|
160
|
+
return { type: 'schtasks', path: WIN_TASK_NAME };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function uninstallWindows() {
|
|
164
|
+
try {
|
|
165
|
+
execFileSync('schtasks', ['/End', '/TN', WIN_TASK_NAME], { stdio: 'pipe' });
|
|
166
|
+
} catch {
|
|
167
|
+
/* not running */
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
execFileSync('schtasks', ['/Delete', '/F', '/TN', WIN_TASK_NAME], { stdio: 'pipe' });
|
|
171
|
+
} catch {
|
|
172
|
+
/* task not present / already removed */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Public API
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Register + start the login-time worker service for the current platform.
|
|
182
|
+
* `launcherPath` is the absolute path to `worker-launcher.mjs` in the package.
|
|
183
|
+
* Returns a descriptor; throws if the platform tooling rejects the call (the
|
|
184
|
+
* caller wraps this in try/catch and prints the manual fallback).
|
|
185
|
+
*/
|
|
186
|
+
export function installService(launcherPath) {
|
|
187
|
+
// The service definition points its logs at LOG_PATH (~/.wyren/worker.log);
|
|
188
|
+
// ensure the dir exists so systemd/launchd can open the file at start time
|
|
189
|
+
// even if device-login hasn't run yet (e.g. a manual re-register).
|
|
190
|
+
ensureWyrenDir();
|
|
191
|
+
const nodePath = process.execPath;
|
|
192
|
+
switch (process.platform) {
|
|
193
|
+
case 'linux':
|
|
194
|
+
return installLinux(nodePath, launcherPath);
|
|
195
|
+
case 'darwin':
|
|
196
|
+
return installMac(nodePath, launcherPath);
|
|
197
|
+
case 'win32':
|
|
198
|
+
return installWindows(nodePath, launcherPath);
|
|
199
|
+
default:
|
|
200
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Remove the login-time worker service. Best-effort across platforms. */
|
|
205
|
+
export function uninstallService() {
|
|
206
|
+
switch (process.platform) {
|
|
207
|
+
case 'linux':
|
|
208
|
+
return uninstallLinux();
|
|
209
|
+
case 'darwin':
|
|
210
|
+
return uninstallMac();
|
|
211
|
+
case 'win32':
|
|
212
|
+
return uninstallWindows();
|
|
213
|
+
default:
|
|
214
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wyren-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Install the Wyren MCP server
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Install the Wyren MCP server, agent skills, and auto-starting local render worker for Claude Code",
|
|
5
5
|
"bin": {
|
|
6
6
|
"wyren-mcp": "setup.mjs"
|
|
7
7
|
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"sync-worker": "node scripts/sync-worker.mjs"
|
|
10
|
+
},
|
|
8
11
|
"files": [
|
|
9
12
|
"setup.mjs",
|
|
10
|
-
"
|
|
13
|
+
"worker-launcher.mjs",
|
|
14
|
+
"lib/",
|
|
15
|
+
"scripts/",
|
|
16
|
+
"skills/",
|
|
17
|
+
"worker-standalone/",
|
|
18
|
+
"remotion-bundle/",
|
|
19
|
+
"CHANGELOG.md"
|
|
11
20
|
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@aws-sdk/client-s3": "^3.975.0",
|
|
23
|
+
"@aws-sdk/lib-storage": "^3.975.0",
|
|
24
|
+
"@remotion/renderer": "4.0.421",
|
|
25
|
+
"ffmpeg-static": "^5.3.0",
|
|
26
|
+
"file-type": "^21.3.0",
|
|
27
|
+
"ws": "^8.18.0",
|
|
28
|
+
"zod": "3.22.3"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
12
33
|
"keywords": [
|
|
13
34
|
"wyren",
|
|
14
35
|
"mcp",
|
|
15
36
|
"claude",
|
|
16
37
|
"ai",
|
|
17
38
|
"video",
|
|
18
|
-
"agent-skills"
|
|
39
|
+
"agent-skills",
|
|
40
|
+
"render-worker"
|
|
19
41
|
],
|
|
20
42
|
"license": "MIT",
|
|
21
43
|
"repository": {
|