@impractical-ai/motion 0.1.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/assets/engine.d.ts +914 -0
- package/assets/impractical/SKILL.md +215 -0
- package/dist/chunk-2RJVXP4V.js +325 -0
- package/dist/index.js +1585 -0
- package/dist/stdio-5F5UX4VN.js +31880 -0
- package/package.json +36 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: impractical
|
|
3
|
+
description: Create and refine studio-quality motion-graphics launch videos with Impractical Motion. Use when the user wants a marketing, launch, promo, or product video made or tweaked, mentions Impractical, or wants to edit an Impractical draft, extract a brand kit, or export a final MP4.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Impractical Motion
|
|
7
|
+
|
|
8
|
+
You direct the video yourself. Impractical provides the engine, the preview
|
|
9
|
+
build, the render, and the studio the user tweaks in — you provide the
|
|
10
|
+
direction. Everything runs through the **Impractical MCP server**; the video's
|
|
11
|
+
source code lives in the cloud, never in the user's project directory.
|
|
12
|
+
|
|
13
|
+
## 0. Setup check (once per session)
|
|
14
|
+
|
|
15
|
+
Call the `account_status` tool (`whoami` is its legacy alias). If the
|
|
16
|
+
Impractical tools are not available, the Claude plugin is not connected. Tell
|
|
17
|
+
the user to run:
|
|
18
|
+
|
|
19
|
+
npx --yes @impractical-ai/motion setup --claude
|
|
20
|
+
|
|
21
|
+
Then ask them to run `/reload-plugins` and type `continue`. If the weekly quota
|
|
22
|
+
is exhausted, call `upgrade` to give the user the authenticated billing page.
|
|
23
|
+
|
|
24
|
+
## 1. Read the engine before you write anything
|
|
25
|
+
|
|
26
|
+
Call **`engine_types`** first. It returns the TypeScript surface a scene is
|
|
27
|
+
written against: the `ctx` object, every named text/logo/transition effect with
|
|
28
|
+
its options, the named eases, and the determinism rules. Writing a direction
|
|
29
|
+
without reading it produces slop.
|
|
30
|
+
|
|
31
|
+
## 2. Make a video
|
|
32
|
+
|
|
33
|
+
Before scaffolding, call **`motion_context`** with the user's brief — plus
|
|
34
|
+
`brandSlug` and `contentType` when you know them. It returns the current
|
|
35
|
+
versioned house skills, the best matching production references, available
|
|
36
|
+
template schemas, and rendered reference frames. Study those frames and sources
|
|
37
|
+
before choosing a template. The library is live: do not rely on a remembered
|
|
38
|
+
catalog from an earlier session.
|
|
39
|
+
|
|
40
|
+
When the workspace has personalization enabled, the result also carries the
|
|
41
|
+
user's resolved motion profile: `personalStyleSkill` (confirmed preferences —
|
|
42
|
+
follow it like house guidance), `acceptedExamples`, a reranked
|
|
43
|
+
style/template order, and a `contextId`. Pass that `contextId` to
|
|
44
|
+
`create_video` as `motionContextId` so the draft is pinned to the
|
|
45
|
+
recommendations that produced it.
|
|
46
|
+
|
|
47
|
+
Gather from the user (ask only for what's missing):
|
|
48
|
+
|
|
49
|
+
- **Value prop** (required): one or two sentences on what ships and why it matters.
|
|
50
|
+
- Optional: their product's front-end code, a brand URL, screen recordings.
|
|
51
|
+
|
|
52
|
+
If they gave a brand URL and no kit exists yet:
|
|
53
|
+
|
|
54
|
+
brand_list
|
|
55
|
+
brand_extract { url: "https://example.com" }
|
|
56
|
+
|
|
57
|
+
Then scaffold the project:
|
|
58
|
+
|
|
59
|
+
create_video { valueProp: "...", brandSlug?: "example-com", appCode?: "...", templateId?: "...", templateParams?: {...} }
|
|
60
|
+
|
|
61
|
+
This creates the project and a draft with starter files. It deliberately does
|
|
62
|
+
**not** run a server-side director — you are the director.
|
|
63
|
+
|
|
64
|
+
## 3. Direct it
|
|
65
|
+
|
|
66
|
+
A scene is two files, and both must exist for the preview to build:
|
|
67
|
+
|
|
68
|
+
- **`App.tsx`** — the stage markup. Plain React and CSS; this is the product UI
|
|
69
|
+
on screen.
|
|
70
|
+
- **`direction.ts`** — the choreography. `export default (ctx) => { ... }`
|
|
71
|
+
against one paused GSAP timeline.
|
|
72
|
+
|
|
73
|
+
Write them with `write_draft_files` (whole files) or `edit_draft_file` (exact
|
|
74
|
+
string replacement — prefer this for revisions). Every save rebuilds the
|
|
75
|
+
preview and **returns the build error if your code does not compile** — that is
|
|
76
|
+
your compile loop, so read it and fix rather than guessing.
|
|
77
|
+
|
|
78
|
+
Rules that matter:
|
|
79
|
+
|
|
80
|
+
- `brand.json` is law for colors and fonts. Read it; do not invent a palette.
|
|
81
|
+
- Never edit `project.json`.
|
|
82
|
+
- The timeline is rendered by seeking frame by frame. Anything that is not a
|
|
83
|
+
pure function of timeline position — `Date.now()`, `Math.random()`,
|
|
84
|
+
`setTimeout`, CSS animations, autoplaying `<video>` — desynchronises the
|
|
85
|
+
export. Use `ctx.video()` for footage.
|
|
86
|
+
- Only `.ts/.tsx/.js/.jsx/.css/.json` files sync, max 100 per draft.
|
|
87
|
+
|
|
88
|
+
Craft, briefly — the full language is `guidance.motionDirection` in the
|
|
89
|
+
`motion_context` result. That copy is live and versioned; this summary is a
|
|
90
|
+
snapshot, so when the two disagree, the tool result wins.
|
|
91
|
+
|
|
92
|
+
- **Beat it out before writing code.** Every beat needs a time, a thing on
|
|
93
|
+
screen, and a move. A beat with no move is not a beat.
|
|
94
|
+
- **Text enters on `rise`** — `text.reveal(el, 'rise')` on `promo-out`, lines
|
|
95
|
+
~0.2s apart. Do *not* default type to `'blur'`: word-by-word de-blur reads as
|
|
96
|
+
out-of-focus, and across a stagger it turns to mush. Blur is for whole-frame
|
|
97
|
+
moves — `fx.spike(el, 'blur')` alongside a whip or dive, where it hides the
|
|
98
|
+
cut. Never animate blur above 20px.
|
|
99
|
+
- **Every ease is a named house curve.** `ease-out-*` for entrances,
|
|
100
|
+
`ease-in-out-*` for travel and every camera move, `ease-in-*` for exits and
|
|
101
|
+
dives only. Never leave an ease unspecified; never put `ease-in-*` on an
|
|
102
|
+
entrance. The promo subset: `promo-out`, `promo-soft`, `promo-inout`,
|
|
103
|
+
`promo-in`, `launch-out`.
|
|
104
|
+
- **Fast**: entrances 0.4–0.6s, exits shorter, staggers ~0.2s apart, nothing
|
|
105
|
+
over 1s but a deliberate hold drift or slow push.
|
|
106
|
+
- **Nothing is ever static.** Every hold carries a slow drift or camera push.
|
|
107
|
+
- Hold long enough to read. A beat that looks legible scrubbing the timeline is
|
|
108
|
+
usually still too fast on screen.
|
|
109
|
+
- Cut hard between sections; crossfading backgrounds reads as cheap.
|
|
110
|
+
- One idea per beat. If two things move for different reasons at once, split them.
|
|
111
|
+
|
|
112
|
+
### The five moves that separate a real video from a generated one
|
|
113
|
+
|
|
114
|
+
These are the engine's `ctx.fx` and `ctx.mask` kits. Reaching for them is most
|
|
115
|
+
of the gap between output that looks authored and output that looks scripted.
|
|
116
|
+
Full signatures are in the engine types; the rules of thumb are:
|
|
117
|
+
|
|
118
|
+
1. **Hide every whip cut with one spike, never two tweens.**
|
|
119
|
+
```ts
|
|
120
|
+
camera.move(camera.framing('[data-next]'), { duration: 0.28, ease: 'promo-inout' }, 'whip')
|
|
121
|
+
fx.spike('[data-panel]', 'blur', { amount: 14 }, 'whip') // ONE call
|
|
122
|
+
```
|
|
123
|
+
Hand-writing `tl.to(stage, { filter: 'blur(12px)' })` up and back is the
|
|
124
|
+
single most common way this looks amateur: the two tweens drift out of sync
|
|
125
|
+
and the user gets two unlinked controls. `fx.spike` is one tween, in and
|
|
126
|
+
out, locked to the move.
|
|
127
|
+
|
|
128
|
+
2. **Feather full-frame reveals.** `mask.reveal(el, 'feather', { direction, softness: 0.3 })`
|
|
129
|
+
— a hard edge across a whole frame reads cheap, and `clip-path` cannot make
|
|
130
|
+
a soft one at all. Use `wipe`/`iris`/`slit`/`barn` when you want the hard
|
|
131
|
+
edge deliberately; `radial` and `blinds` for softer variety.
|
|
132
|
+
|
|
133
|
+
3. **Land on light, not on opacity.** One `fx.spike(el, 'bloom')` as a beat
|
|
134
|
+
settles reads as a light source. Fading opacity reads as a slideshow.
|
|
135
|
+
|
|
136
|
+
4. **Put colour inside the letters.** `mask.fill(el, { gradient, drift })` uses
|
|
137
|
+
`background-clip: text`, so the gradient lives in the glyphs and moves
|
|
138
|
+
through them. Far stronger than a coloured headline, and one call.
|
|
139
|
+
|
|
140
|
+
5. **Composite light instead of shipping a PNG.**
|
|
141
|
+
`mask.blend(disc, 'screen')` behind type gives glow with no asset.
|
|
142
|
+
`multiply` for ink on cream, `luminosity` for duotone.
|
|
143
|
+
|
|
144
|
+
Two things to get right or the effect misfires:
|
|
145
|
+
|
|
146
|
+
- **Grain must be continuous.** Apply it once to a full-frame plate that sits
|
|
147
|
+
above every beat and is never cut (`fx.apply(plate, 'grain', { amount: 0.2 })`),
|
|
148
|
+
or it visibly blinks at each hard cut.
|
|
149
|
+
- **Effects are seasoning.** One or two per beat. `displace`, `chroma`, and
|
|
150
|
+
`threshold` are accents for a single hit, not a look to hold.
|
|
151
|
+
|
|
152
|
+
Ask `motion_context` for the **`optical`** style to see all of this composed in
|
|
153
|
+
one reference, or the **`whip-cut`**, **`soft-reveal`**, and **`gradient-type`**
|
|
154
|
+
templates for a working scene you can adapt.
|
|
155
|
+
|
|
156
|
+
## 4. Look at your work
|
|
157
|
+
|
|
158
|
+
get_frames { draftId: "...", times: [0, 1, 2, 3] }
|
|
159
|
+
|
|
160
|
+
This renders real frames on the user's machine and returns them as images. Ask
|
|
161
|
+
for several times in one call — once the page is open the extra frames are
|
|
162
|
+
nearly free.
|
|
163
|
+
|
|
164
|
+
**Always look at frames before telling the user a change is done.** If a frame
|
|
165
|
+
shows text clipped, an element off-frame, or a blank region, fix it and look
|
|
166
|
+
again. This is the difference between shipping a video and shipping a guess.
|
|
167
|
+
|
|
168
|
+
## 5. Export and hand off
|
|
169
|
+
|
|
170
|
+
export_video { draftId: "..." }
|
|
171
|
+
studio_link { projectId: "...", slug: "..." }
|
|
172
|
+
|
|
173
|
+
Always give the user the studio link — that is where they watch the cuts and
|
|
174
|
+
tweak visually. The export URL expires, so only export once they are happy.
|
|
175
|
+
|
|
176
|
+
## The personal motion profile
|
|
177
|
+
|
|
178
|
+
The `motion_profile` tool manages what Impractical remembers about this user's
|
|
179
|
+
taste. The rules are strict because trust in the profile depends on them:
|
|
180
|
+
|
|
181
|
+
- **Read before directing.** The personalized context from `motion_context`
|
|
182
|
+
already includes the resolved profile; call `motion_profile { action: 'get' }`
|
|
183
|
+
only when the user asks about their profile or you need rule keys.
|
|
184
|
+
- **`remember` is for explicit requests only** — "remember that I like…",
|
|
185
|
+
"always keep my videos under 15 seconds". Never call `remember` for a
|
|
186
|
+
preference you merely inferred.
|
|
187
|
+
- **Inferred preferences go through `propose`.** If the user's feedback during
|
|
188
|
+
this session implies a durable preference, submit it as a structured rule
|
|
189
|
+
with `propose`. It becomes a pending proposal that has zero effect until the
|
|
190
|
+
user confirms it.
|
|
191
|
+
- **Present at most one pending proposal, and only after a completed video.**
|
|
192
|
+
If `motion_context` returned `pendingProposal`, mention it once after the
|
|
193
|
+
export or when the user is clearly done — one sentence, e.g. "Want me to
|
|
194
|
+
remember that you prefer faster pacing? (yes/no)". Call `confirm` or
|
|
195
|
+
`reject` with their answer. Never present more than one, never mid-work, and
|
|
196
|
+
**never silently confirm**.
|
|
197
|
+
- The user can `pause`, `forget` a single rule, or `reset` everything; do these
|
|
198
|
+
only when asked.
|
|
199
|
+
|
|
200
|
+
## Conflicts
|
|
201
|
+
|
|
202
|
+
The web tweak chat edits the same draft. `edit_draft_file` reads, edits and
|
|
203
|
+
writes in one call against the current revision, so ordinary races resolve
|
|
204
|
+
themselves. If a tool reports the draft changed underneath your edit, re-read
|
|
205
|
+
the file and re-apply your change on top of the new content — never paste stale
|
|
206
|
+
content back.
|
|
207
|
+
|
|
208
|
+
If a tool reports the draft is locked by a job, wait for that job with
|
|
209
|
+
`job_status { wait: true }` and retry.
|
|
210
|
+
|
|
211
|
+
## Costs
|
|
212
|
+
|
|
213
|
+
- Preview builds and exports run on Impractical's infrastructure; the direction
|
|
214
|
+
thinking is yours, so iterate on the code freely.
|
|
215
|
+
- Preview rebuilds take tens of seconds. Batch your edits, then look at frames.
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name in all)
|
|
13
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
24
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
25
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
26
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
27
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
28
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
29
|
+
mod
|
|
30
|
+
));
|
|
31
|
+
|
|
32
|
+
// src/config.ts
|
|
33
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
// src/constants.ts
|
|
38
|
+
var PRODUCTION_APP_URL = "https://motion.impractical.ai";
|
|
39
|
+
var PRODUCTION_SUPABASE_URL = "https://uaxjweednprtbhbceify.supabase.co";
|
|
40
|
+
var PACKAGE_NAME = "@impractical-ai/motion";
|
|
41
|
+
var PACKAGE_COMMAND = `npx --yes ${PACKAGE_NAME}`;
|
|
42
|
+
|
|
43
|
+
// src/config.ts
|
|
44
|
+
var configDir = () => join(homedir(), ".impractical");
|
|
45
|
+
var configPath = () => join(configDir(), "config.json");
|
|
46
|
+
function loadConfig() {
|
|
47
|
+
let stored = {};
|
|
48
|
+
try {
|
|
49
|
+
stored = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
apiKey: process.env.IMPRACTICAL_API_KEY ?? stored.apiKey,
|
|
54
|
+
supabaseUrl: process.env.IMPRACTICAL_SUPABASE_URL ?? stored.supabaseUrl ?? PRODUCTION_SUPABASE_URL,
|
|
55
|
+
appUrl: process.env.IMPRACTICAL_APP_URL ?? stored.appUrl ?? PRODUCTION_APP_URL
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function saveConfig(patch) {
|
|
59
|
+
let stored = {};
|
|
60
|
+
try {
|
|
61
|
+
stored = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
const next = { ...stored, ...patch };
|
|
65
|
+
mkdirSync(configDir(), { recursive: true });
|
|
66
|
+
writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
|
|
67
|
+
chmodSync(configPath(), 384);
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
function requireConfig() {
|
|
71
|
+
const config = loadConfig();
|
|
72
|
+
if (!config.supabaseUrl) {
|
|
73
|
+
throw new Error("Not configured. Run: npx --yes @impractical-ai/motion setup --claude");
|
|
74
|
+
}
|
|
75
|
+
if (!config.apiKey) {
|
|
76
|
+
throw new Error("Not connected. Run: npx --yes @impractical-ai/motion setup --claude");
|
|
77
|
+
}
|
|
78
|
+
return config;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/api.ts
|
|
82
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
83
|
+
import { randomUUID } from "node:crypto";
|
|
84
|
+
var admissionContext = new AsyncLocalStorage();
|
|
85
|
+
var ApiError = class extends Error {
|
|
86
|
+
constructor(status, message, fn) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.status = status;
|
|
89
|
+
this.fn = fn;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
async function invoke(name, body = {}) {
|
|
93
|
+
const { apiKey, supabaseUrl } = requireConfig();
|
|
94
|
+
const receipt = admissionContext.getStore();
|
|
95
|
+
return invokeAt(supabaseUrl, name, body, apiKey, receipt);
|
|
96
|
+
}
|
|
97
|
+
async function invokeAt(supabaseUrl, name, body = {}, apiKey, admissionReceipt) {
|
|
98
|
+
const response = await fetch(`${supabaseUrl.replace(/\/$/, "")}/functions/v1/${name}`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
...apiKey ? { authorization: `Bearer ${apiKey}` } : {},
|
|
102
|
+
...admissionReceipt ? { "x-impractical-admission": admissionReceipt } : {},
|
|
103
|
+
"content-type": "application/json"
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify(body)
|
|
106
|
+
});
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
let data = null;
|
|
109
|
+
try {
|
|
110
|
+
data = text ? JSON.parse(text) : null;
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const message = data?.error ?? (text || `${name} failed (${response.status})`);
|
|
115
|
+
throw new ApiError(response.status, message, name);
|
|
116
|
+
}
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
async function admitMcpCall(toolName, callId = randomUUID()) {
|
|
120
|
+
const { apiKey, supabaseUrl } = requireConfig();
|
|
121
|
+
return invokeAt(supabaseUrl, "mcp-admission", { callId, toolName }, apiKey);
|
|
122
|
+
}
|
|
123
|
+
function withAdmissionReceipt(receipt, task) {
|
|
124
|
+
return admissionContext.run(receipt, task);
|
|
125
|
+
}
|
|
126
|
+
async function withMeteredCall(toolName, task) {
|
|
127
|
+
const admission = await admitMcpCall(toolName);
|
|
128
|
+
return withAdmissionReceipt(admission.receipt, task);
|
|
129
|
+
}
|
|
130
|
+
var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
131
|
+
async function jobStatus(jobIds) {
|
|
132
|
+
const result = await invoke("job-status", { jobIds });
|
|
133
|
+
return result.jobs;
|
|
134
|
+
}
|
|
135
|
+
async function waitForJobs(jobIds, onTick, deadlineMs = 72e5) {
|
|
136
|
+
const deadline = Date.now() + deadlineMs;
|
|
137
|
+
const terminal = /* @__PURE__ */ new Set();
|
|
138
|
+
let latest = [];
|
|
139
|
+
while (terminal.size < jobIds.length) {
|
|
140
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for jobs");
|
|
141
|
+
latest = await jobStatus(jobIds);
|
|
142
|
+
for (const row of latest) {
|
|
143
|
+
if (row.status === "completed" || row.status === "failed" || row.status === "canceled") {
|
|
144
|
+
terminal.add(row.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
onTick(latest);
|
|
148
|
+
if (terminal.size < jobIds.length) await delay(2e3);
|
|
149
|
+
}
|
|
150
|
+
return latest;
|
|
151
|
+
}
|
|
152
|
+
function studioLink(appUrl, projectId, slug) {
|
|
153
|
+
const base = (appUrl ?? "http://localhost:5173").replace(/\/$/, "");
|
|
154
|
+
return `${base}/projects/${projectId}/studio${slug ? `?scene=${slug}` : ""}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/onboarding.ts
|
|
158
|
+
import { chmodSync as chmodSync2, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
159
|
+
import { homedir as homedir2 } from "node:os";
|
|
160
|
+
import { dirname, join as join2 } from "node:path";
|
|
161
|
+
import { fileURLToPath } from "node:url";
|
|
162
|
+
import { spawnSync } from "node:child_process";
|
|
163
|
+
var CLAUDE_MARKETPLACE = "Openpod/impractical-agent-plugins";
|
|
164
|
+
var CLAUDE_PLUGIN = "impractical-motion@impractical";
|
|
165
|
+
function commandResult(command, args) {
|
|
166
|
+
return spawnSync(command, args, { encoding: "utf8", windowsHide: true });
|
|
167
|
+
}
|
|
168
|
+
function requireCommand(command, label) {
|
|
169
|
+
const result = commandResult(command, ["--version"]);
|
|
170
|
+
if (result.error || result.status !== 0) {
|
|
171
|
+
throw new Error(`${label} is required before setup can continue.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function hasChrome() {
|
|
175
|
+
const macPaths = [
|
|
176
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
177
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
178
|
+
];
|
|
179
|
+
if (macPaths.some(existsSync)) return true;
|
|
180
|
+
return ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"].some((command) => {
|
|
181
|
+
const result = spawnSync(command, ["--version"], { stdio: "ignore", windowsHide: true });
|
|
182
|
+
return !result.error && result.status === 0;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function verifyClaudePrerequisites() {
|
|
186
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
187
|
+
if (!Number.isInteger(major) || major < 22) throw new Error("Node.js 22 or newer is required.");
|
|
188
|
+
requireCommand("claude", "Claude Code");
|
|
189
|
+
if (!hasChrome()) throw new Error("Google Chrome or Chromium is required to inspect video frames.");
|
|
190
|
+
}
|
|
191
|
+
function runClaude(args, tolerateAlreadyInstalled = false) {
|
|
192
|
+
const result = commandResult("claude", args);
|
|
193
|
+
if (!result.error && result.status === 0) return;
|
|
194
|
+
const detail = `${result.stdout ?? ""}
|
|
195
|
+
${result.stderr ?? ""}`.trim();
|
|
196
|
+
if (tolerateAlreadyInstalled && /already (?:added|exists|installed|configured)/i.test(detail)) return;
|
|
197
|
+
throw new Error(`Claude plugin setup failed: ${detail || result.error?.message || "unknown error"}`);
|
|
198
|
+
}
|
|
199
|
+
function installClaudePlugin() {
|
|
200
|
+
runClaude(["plugin", "marketplace", "add", CLAUDE_MARKETPLACE], true);
|
|
201
|
+
runClaude(["plugin", "install", CLAUDE_PLUGIN, "--scope", "user"], true);
|
|
202
|
+
runClaude(["plugin", "marketplace", "update", "impractical"], true);
|
|
203
|
+
runClaude(["plugin", "update", CLAUDE_PLUGIN], true);
|
|
204
|
+
}
|
|
205
|
+
var skillSourcePath = () => fileURLToPath(new URL("../assets/impractical/SKILL.md", import.meta.url));
|
|
206
|
+
function installSkills(home = homedir2(), sourcePath = skillSourcePath()) {
|
|
207
|
+
const source = readFileSync2(sourcePath, "utf8");
|
|
208
|
+
const targets = [
|
|
209
|
+
{
|
|
210
|
+
harness: "Claude Code",
|
|
211
|
+
path: join2(home, ".claude", "skills", "impractical", "SKILL.md")
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
harness: "Codex",
|
|
215
|
+
path: join2(home, ".codex", "skills", "impractical", "SKILL.md")
|
|
216
|
+
}
|
|
217
|
+
];
|
|
218
|
+
for (const target of targets) {
|
|
219
|
+
mkdirSync2(dirname(target.path), { recursive: true, mode: 448 });
|
|
220
|
+
writeFileSync2(target.path, source, { mode: 420 });
|
|
221
|
+
}
|
|
222
|
+
return targets;
|
|
223
|
+
}
|
|
224
|
+
function browserCommand(url) {
|
|
225
|
+
if (process.platform === "darwin") return { command: "open", args: [url] };
|
|
226
|
+
if (process.platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
|
|
227
|
+
return { command: "xdg-open", args: [url] };
|
|
228
|
+
}
|
|
229
|
+
function openBrowser(url) {
|
|
230
|
+
const launch = browserCommand(url);
|
|
231
|
+
const result = spawnSync(launch.command, launch.args, {
|
|
232
|
+
stdio: "ignore",
|
|
233
|
+
windowsHide: true
|
|
234
|
+
});
|
|
235
|
+
return !result.error && result.status === 0;
|
|
236
|
+
}
|
|
237
|
+
async function waitForApproval(supabaseUrl, authorization, log) {
|
|
238
|
+
const deadline = Date.parse(authorization.expiresAt);
|
|
239
|
+
const intervalMs = Math.max(1, authorization.intervalSeconds) * 1e3;
|
|
240
|
+
let announced = false;
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
const result = await invokeAt(supabaseUrl, "cli-device-auth", {
|
|
243
|
+
action: "poll",
|
|
244
|
+
deviceCode: authorization.deviceCode
|
|
245
|
+
});
|
|
246
|
+
if (result.status === "authorized") return result.apiKey;
|
|
247
|
+
if (!announced) {
|
|
248
|
+
log("Waiting for browser approval\u2026");
|
|
249
|
+
announced = true;
|
|
250
|
+
}
|
|
251
|
+
await delay(intervalMs);
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`Authorization expired. Run \`${PACKAGE_COMMAND} setup --claude\` again.`);
|
|
254
|
+
}
|
|
255
|
+
async function setup(options) {
|
|
256
|
+
if (options.installClaudePlugin) verifyClaudePrerequisites();
|
|
257
|
+
const supabaseUrl = process.env.IMPRACTICAL_SUPABASE_URL?.trim() || PRODUCTION_SUPABASE_URL;
|
|
258
|
+
const appUrl = process.env.IMPRACTICAL_APP_URL?.trim() || PRODUCTION_APP_URL;
|
|
259
|
+
const authorization = await invokeAt(supabaseUrl, "cli-device-auth", {
|
|
260
|
+
action: "start"
|
|
261
|
+
});
|
|
262
|
+
options.log("");
|
|
263
|
+
options.log("Connect Impractical Motion");
|
|
264
|
+
options.log(`Code: ${authorization.userCode}`);
|
|
265
|
+
options.log(`Open: ${authorization.verificationUri}`);
|
|
266
|
+
options.log("");
|
|
267
|
+
if (options.openBrowser) {
|
|
268
|
+
const opened = openBrowser(authorization.verificationUri);
|
|
269
|
+
options.log(opened ? "Opened your browser." : "Could not open a browser automatically; use the link above.");
|
|
270
|
+
}
|
|
271
|
+
let apiKey;
|
|
272
|
+
try {
|
|
273
|
+
apiKey = await waitForApproval(supabaseUrl, authorization, options.log);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof ApiError && error.status === 410) {
|
|
276
|
+
throw new Error(`Authorization expired. Run \`${PACKAGE_COMMAND} setup --claude\` again.`);
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
saveConfig({ apiKey, supabaseUrl, appUrl });
|
|
281
|
+
const configPath2 = join2(homedir2(), ".impractical", "config.json");
|
|
282
|
+
chmodSync2(configPath2, 384);
|
|
283
|
+
const verified = await invokeAt(
|
|
284
|
+
supabaseUrl,
|
|
285
|
+
"workspace-bootstrap",
|
|
286
|
+
{},
|
|
287
|
+
apiKey
|
|
288
|
+
);
|
|
289
|
+
if (options.installClaudePlugin) installClaudePlugin();
|
|
290
|
+
const installed = options.installAgentSkills ? installSkills() : [];
|
|
291
|
+
options.log("");
|
|
292
|
+
options.log("Connected.");
|
|
293
|
+
for (const skill of installed) {
|
|
294
|
+
options.log(`Installed /impractical for ${skill.harness}.`);
|
|
295
|
+
}
|
|
296
|
+
if (options.installClaudePlugin) {
|
|
297
|
+
options.log(`Installed ${CLAUDE_PLUGIN} for Claude Code (user scope).`);
|
|
298
|
+
}
|
|
299
|
+
options.log(`Workspace: ${verified.orgId}${verified.plan ? ` \xB7 ${verified.plan}` : ""}`);
|
|
300
|
+
options.log("");
|
|
301
|
+
options.log("Run /reload-plugins, then type continue.");
|
|
302
|
+
return { installed, appUrl, supabaseUrl };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export {
|
|
306
|
+
__commonJS,
|
|
307
|
+
__export,
|
|
308
|
+
__toESM,
|
|
309
|
+
PRODUCTION_APP_URL,
|
|
310
|
+
PRODUCTION_SUPABASE_URL,
|
|
311
|
+
loadConfig,
|
|
312
|
+
saveConfig,
|
|
313
|
+
requireConfig,
|
|
314
|
+
ApiError,
|
|
315
|
+
invoke,
|
|
316
|
+
invokeAt,
|
|
317
|
+
admitMcpCall,
|
|
318
|
+
withAdmissionReceipt,
|
|
319
|
+
withMeteredCall,
|
|
320
|
+
jobStatus,
|
|
321
|
+
waitForJobs,
|
|
322
|
+
studioLink,
|
|
323
|
+
openBrowser,
|
|
324
|
+
setup
|
|
325
|
+
};
|