@impractical-ai/motion 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -1,22 +1,34 @@
1
1
  # Impractical Motion CLI
2
2
 
3
- Connect Claude Code to Impractical Motion through its MCP server.
3
+ Connect Claude Code, Codex, Cursor, or OpenCode to Impractical Motion through
4
+ its MCP server.
4
5
 
5
6
  ## Requirements
6
7
 
7
8
  - Node.js 22 or newer
8
- - Claude Code installed and signed in
9
+ - At least one supported coding agent: Claude Code, Codex, Cursor, or OpenCode
9
10
  - Google Chrome or Chromium for frame inspection
10
11
 
11
12
  ## Setup
12
13
 
14
+ ```sh
15
+ npx --yes @impractical-ai/motion setup --agent
16
+ ```
17
+
18
+ Approve the device code in the browser, then restart the detected coding agents.
19
+ Setup installs the global `impractical` MCP configuration for every detected
20
+ Claude Code, Codex, Cursor, and OpenCode client, plus the shared skill for Claude
21
+ Code and Codex. Re-running setup verifies and reuses a working credential,
22
+ preserves unrelated client configuration, and does not require another browser
23
+ authorization.
24
+
25
+ For the full Claude Code plugin workflow, run:
26
+
13
27
  ```sh
14
28
  npx --yes @impractical-ai/motion setup --claude
15
29
  ```
16
30
 
17
- Approve the device code in the browser, then run `/reload-plugins` in Claude Code and type
18
- `continue`. Re-running setup verifies and reuses a working credential, refreshes the user-scoped
19
- plugin, and does not require another browser authorization.
31
+ Then run `/reload-plugins` in Claude Code and type `continue`.
20
32
 
21
33
  Setup writes the API credential to `~/.impractical/config.json` with mode `0600`. Environment
22
34
  variables `IMPRACTICAL_API_KEY`, `IMPRACTICAL_SUPABASE_URL`, and `IMPRACTICAL_APP_URL` take
@@ -24,7 +36,7 @@ precedence over that file.
24
36
 
25
37
  ## MCP server
26
38
 
27
- The installed Claude plugin launches the server automatically. Other MCP clients can run:
39
+ The installer configures supported clients to run:
28
40
 
29
41
  ```sh
30
42
  npx --yes @impractical-ai/motion@latest mcp
@@ -13,13 +13,15 @@ source code lives in the cloud, never in the user's project directory.
13
13
  ## 0. Setup check (once per session)
14
14
 
15
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:
16
+ Impractical tools are not available, the current coding agent is not connected.
17
+ Tell the user to run:
18
18
 
19
- npx --yes @impractical-ai/motion setup --claude
19
+ npx --yes @impractical-ai/motion setup --agent
20
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.
21
+ Then ask them to restart or reload their coding agent and type `continue`.
22
+ Claude Code users who specifically want the full plugin can run the same command
23
+ with `--claude` instead. If the weekly quota is exhausted, call `upgrade` to give
24
+ the user the authenticated billing page.
23
25
 
24
26
  ## 1. Read the engine before you write anything
25
27
 
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ApiContractError,
4
+ ApiError,
5
+ admitMcpCall,
6
+ delay,
7
+ invoke,
8
+ invokeAt,
9
+ jobStatus,
10
+ studioLink,
11
+ validateApiResponse,
12
+ waitForJobs,
13
+ withAdmissionReceipt,
14
+ withMeteredCall
15
+ } from "./chunk-7724464Z.js";
16
+ import "./chunk-JDN3QHCX.js";
17
+ export {
18
+ ApiContractError,
19
+ ApiError,
20
+ admitMcpCall,
21
+ delay,
22
+ invoke,
23
+ invokeAt,
24
+ jobStatus,
25
+ studioLink,
26
+ validateApiResponse,
27
+ waitForJobs,
28
+ withAdmissionReceipt,
29
+ withMeteredCall
30
+ };
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ PRODUCTION_APP_URL,
4
+ PRODUCTION_SUPABASE_URL
5
+ } from "./chunk-JDN3QHCX.js";
6
+
7
+ // src/config.ts
8
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, renameSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ var configDir = () => join(homedir(), ".impractical");
12
+ var configPath = () => join(configDir(), "config.json");
13
+ function recoverCorruptConfig() {
14
+ const path = configPath();
15
+ if (!existsSync(path)) return null;
16
+ try {
17
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
18
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
19
+ throw new SyntaxError("the root value must be an object");
20
+ }
21
+ return null;
22
+ } catch (error) {
23
+ if (!(error instanceof SyntaxError)) {
24
+ throw new Error(
25
+ `Could not inspect ${path}: ${error instanceof Error ? error.message : String(error)}`
26
+ );
27
+ }
28
+ let backupPath = `${path}.corrupt-${Date.now()}`;
29
+ let suffix = 1;
30
+ while (existsSync(backupPath)) backupPath = `${path}.corrupt-${Date.now()}-${suffix++}`;
31
+ renameSync(path, backupPath);
32
+ return backupPath;
33
+ }
34
+ }
35
+ function readStoredConfig() {
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39
+ throw new SyntaxError("the root value must be an object");
40
+ }
41
+ return parsed;
42
+ } catch (error) {
43
+ if (error?.code === "ENOENT") return {};
44
+ if (error instanceof SyntaxError) {
45
+ throw new Error(
46
+ `Invalid JSON in ${configPath()}: ${error.message}
47
+ Run: npx --yes @impractical-ai/motion setup --agent`
48
+ );
49
+ }
50
+ throw new Error(
51
+ `Could not read ${configPath()}: ${error instanceof Error ? error.message : String(error)}`
52
+ );
53
+ }
54
+ }
55
+ function loadConfig() {
56
+ const stored = readStoredConfig();
57
+ return {
58
+ apiKey: process.env.IMPRACTICAL_API_KEY ?? stored.apiKey,
59
+ supabaseUrl: process.env.IMPRACTICAL_SUPABASE_URL ?? stored.supabaseUrl ?? PRODUCTION_SUPABASE_URL,
60
+ appUrl: process.env.IMPRACTICAL_APP_URL ?? stored.appUrl ?? PRODUCTION_APP_URL
61
+ };
62
+ }
63
+ function saveConfig(patch) {
64
+ const stored = readStoredConfig();
65
+ const next = { ...stored, ...patch };
66
+ mkdirSync(configDir(), { recursive: true });
67
+ writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
68
+ chmodSync(configPath(), 384);
69
+ return next;
70
+ }
71
+ function requireConfig() {
72
+ const config = loadConfig();
73
+ if (!config.supabaseUrl) {
74
+ throw new Error("Not configured. Run: npx --yes @impractical-ai/motion setup --agent");
75
+ }
76
+ if (!config.apiKey) {
77
+ throw new Error("Not connected. Run: npx --yes @impractical-ai/motion setup --agent");
78
+ }
79
+ return config;
80
+ }
81
+
82
+ // src/api.ts
83
+ import { AsyncLocalStorage } from "node:async_hooks";
84
+ import { randomUUID } from "node:crypto";
85
+ var admissionContext = new AsyncLocalStorage();
86
+ var ApiError = class extends Error {
87
+ constructor(status, message, fn, details) {
88
+ super(message);
89
+ this.status = status;
90
+ this.fn = fn;
91
+ this.details = details;
92
+ }
93
+ };
94
+ var ApiContractError = class extends Error {
95
+ constructor(fn, expected) {
96
+ super(`${fn} returned an invalid response: expected ${expected}. Please retry; if this persists, update Impractical Motion or contact support.`);
97
+ this.fn = fn;
98
+ }
99
+ };
100
+ function isRecord(value) {
101
+ return typeof value === "object" && value !== null && !Array.isArray(value);
102
+ }
103
+ function bodyRecord(body) {
104
+ return isRecord(body) ? body : {};
105
+ }
106
+ function validateApiResponse(name, body, data) {
107
+ if (!isRecord(data)) throw new ApiContractError(name, "a JSON object");
108
+ const input = bodyRecord(body);
109
+ if (name === "workspace-bootstrap") {
110
+ const billing = data.billing;
111
+ if (typeof data.orgId !== "string" || typeof data.plan !== "string" || typeof data.hasAccess !== "boolean" || !isRecord(billing) || typeof billing.status !== "string") {
112
+ throw new ApiContractError(
113
+ name,
114
+ "an orgId, plan, hasAccess flag, and billing status"
115
+ );
116
+ }
117
+ }
118
+ if (name === "list-projects") {
119
+ const key = input.projectId ? "project" : "projects";
120
+ const value = data[key];
121
+ if (key === "projects" ? !Array.isArray(value) : !isRecord(value)) {
122
+ throw new ApiContractError(name, `an object with a ${key} ${key === "projects" ? "array" : "object"}`);
123
+ }
124
+ }
125
+ if (name === "prepare-generation") {
126
+ if (typeof data.projectId !== "string" || !Array.isArray(data.drafts) || data.drafts.length === 0 || !isRecord(data.drafts[0]) || typeof data.drafts[0].id !== "string") {
127
+ throw new ApiContractError(
128
+ name,
129
+ "a projectId and at least one draft with an id"
130
+ );
131
+ }
132
+ }
133
+ if (name === "get-draft" && (!isRecord(data.files) || typeof data.draftId !== "string")) {
134
+ throw new ApiContractError(name, "draft metadata and a files object");
135
+ }
136
+ if (name === "save-draft" && typeof data.revision !== "number") {
137
+ throw new ApiContractError(name, "a numeric revision");
138
+ }
139
+ if (name === "job-status" && !Array.isArray(data.jobs)) {
140
+ throw new ApiContractError(name, "an object with a jobs array");
141
+ }
142
+ if (name === "start-export" && typeof data.jobId !== "string") {
143
+ throw new ApiContractError(name, "an object with a jobId");
144
+ }
145
+ if (name === "brand-kits") {
146
+ const key = input.action === "list" ? "brands" : input.action === "get" ? "brand" : null;
147
+ if (key && (key === "brands" ? !Array.isArray(data[key]) : !isRecord(data[key]))) {
148
+ throw new ApiContractError(name, `an object with a ${key} ${key === "brands" ? "array" : "object"}`);
149
+ }
150
+ }
151
+ return data;
152
+ }
153
+ async function invoke(name, body = {}) {
154
+ const { apiKey, supabaseUrl } = requireConfig();
155
+ const receipt = admissionContext.getStore();
156
+ return invokeAt(supabaseUrl, name, body, apiKey, receipt);
157
+ }
158
+ async function invokeAt(supabaseUrl, name, body = {}, apiKey, admissionReceipt) {
159
+ const response = await fetch(`${supabaseUrl.replace(/\/$/, "")}/functions/v1/${name}`, {
160
+ method: "POST",
161
+ headers: {
162
+ ...apiKey ? { authorization: `Bearer ${apiKey}` } : {},
163
+ ...admissionReceipt ? { "x-impractical-admission": admissionReceipt } : {},
164
+ "content-type": "application/json"
165
+ },
166
+ body: JSON.stringify(body)
167
+ });
168
+ const text = await response.text();
169
+ let data = null;
170
+ try {
171
+ data = text ? JSON.parse(text) : null;
172
+ } catch {
173
+ }
174
+ if (!response.ok) {
175
+ const details = isRecord(data) ? data : void 0;
176
+ const message = typeof details?.message === "string" && details.message || typeof details?.error === "string" && details.error || text || `${name} failed (${response.status})`;
177
+ throw new ApiError(response.status, message, name, details);
178
+ }
179
+ return validateApiResponse(name, body, data);
180
+ }
181
+ async function admitMcpCall(toolName, callId = randomUUID()) {
182
+ const { apiKey, supabaseUrl } = requireConfig();
183
+ return invokeAt(supabaseUrl, "mcp-admission", { callId, toolName }, apiKey);
184
+ }
185
+ function withAdmissionReceipt(receipt, task) {
186
+ return admissionContext.run(receipt, task);
187
+ }
188
+ async function withMeteredCall(toolName, task) {
189
+ const admission = await admitMcpCall(toolName);
190
+ return withAdmissionReceipt(admission.receipt, task);
191
+ }
192
+ var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
193
+ async function jobStatus(jobIds) {
194
+ const result = await invoke("job-status", { jobIds });
195
+ return result.jobs;
196
+ }
197
+ async function waitForJobs(jobIds, onTick, deadlineMs = 72e5) {
198
+ const deadline = Date.now() + deadlineMs;
199
+ const terminal = /* @__PURE__ */ new Set();
200
+ let latest = [];
201
+ while (terminal.size < jobIds.length) {
202
+ if (Date.now() >= deadline) throw new Error("Timed out waiting for jobs");
203
+ latest = await jobStatus(jobIds);
204
+ for (const row of latest) {
205
+ if (row.status === "completed" || row.status === "failed" || row.status === "canceled") {
206
+ terminal.add(row.id);
207
+ }
208
+ }
209
+ onTick(latest);
210
+ if (terminal.size < jobIds.length) await delay(2e3);
211
+ }
212
+ return latest;
213
+ }
214
+ function studioLink(appUrl, projectId, slug) {
215
+ const base = (appUrl ?? "http://localhost:5173").replace(/\/$/, "");
216
+ return `${base}/projects/${projectId}/studio${slug ? `?scene=${slug}` : ""}`;
217
+ }
218
+
219
+ export {
220
+ recoverCorruptConfig,
221
+ loadConfig,
222
+ saveConfig,
223
+ requireConfig,
224
+ ApiError,
225
+ ApiContractError,
226
+ validateApiResponse,
227
+ invoke,
228
+ invokeAt,
229
+ admitMcpCall,
230
+ withAdmissionReceipt,
231
+ withMeteredCall,
232
+ delay,
233
+ jobStatus,
234
+ waitForJobs,
235
+ studioLink
236
+ };