laive-mcp 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.
Files changed (67) hide show
  1. package/AGENTS.md +48 -0
  2. package/CHANGELOG.md +13 -0
  3. package/LICENSE +674 -0
  4. package/README.md +219 -0
  5. package/bin/laive.mjs +340 -0
  6. package/package.json +66 -0
  7. package/packages/als-parser/src/index.js +2 -0
  8. package/packages/als-parser/src/read.js +16 -0
  9. package/packages/als-parser/src/summarize.js +116 -0
  10. package/packages/common/src/index.js +3 -0
  11. package/packages/common/src/jsonl.js +41 -0
  12. package/packages/common/src/protocol.js +94 -0
  13. package/packages/common/src/validation.js +121 -0
  14. package/packages/live-bridge-remote-script/README.md +22 -0
  15. package/packages/live-bridge-remote-script/python/laive/__init__.py +7 -0
  16. package/packages/live-bridge-remote-script/python/laive/control_surface.py +208 -0
  17. package/packages/live-bridge-remote-script/python/laive/fake_live.py +168 -0
  18. package/packages/live-bridge-remote-script/python/laive/listeners.py +46 -0
  19. package/packages/live-bridge-remote-script/python/laive/live_access.py +272 -0
  20. package/packages/live-bridge-remote-script/python/laive/protocol.py +87 -0
  21. package/packages/live-bridge-remote-script/python/laive/server.py +130 -0
  22. package/packages/live-bridge-remote-script/python/laive/task_queue.py +47 -0
  23. package/packages/live-bridge-remote-script/src/bridge/client.js +113 -0
  24. package/packages/live-bridge-remote-script/src/bridge/server.js +189 -0
  25. package/packages/live-bridge-remote-script/src/cli/client.js +75 -0
  26. package/packages/live-bridge-remote-script/src/cli/server.js +51 -0
  27. package/packages/live-bridge-remote-script/src/fixtures/default-live-set.json +113 -0
  28. package/packages/live-bridge-remote-script/src/index.js +3 -0
  29. package/packages/live-bridge-remote-script/src/runtime/fixture-runtime.js +356 -0
  30. package/packages/live-sidecar-m4l/README.md +45 -0
  31. package/packages/live-sidecar-m4l/device/laive-sidecar.amxd +0 -0
  32. package/packages/live-sidecar-m4l/project/code/laive-sidecar-node.js +149 -0
  33. package/packages/live-sidecar-m4l/project/data/laive-sidecar.manifest.json +8 -0
  34. package/packages/live-sidecar-m4l/project/laive-sidecar.maxproj +36 -0
  35. package/packages/live-sidecar-m4l/project/patchers/laive-sidecar.maxpat +172 -0
  36. package/packages/live-sidecar-m4l/src/contracts.js +35 -0
  37. package/packages/live-sidecar-m4l/src/index.js +19 -0
  38. package/packages/live-sidecar-m4l/src/install-sidecar-device.js +15 -0
  39. package/packages/live-sidecar-m4l/src/package-sidecar.js +5 -0
  40. package/packages/live-sidecar-m4l/src/project.js +132 -0
  41. package/packages/live-sidecar-m4l/src/runtime.js +96 -0
  42. package/packages/live-sidecar-m4l/src/workflows.js +95 -0
  43. package/packages/mcp-server/src/cli.js +113 -0
  44. package/packages/mcp-server/src/default-tools.js +253 -0
  45. package/packages/mcp-server/src/errors.js +24 -0
  46. package/packages/mcp-server/src/index.js +10 -0
  47. package/packages/mcp-server/src/server.js +96 -0
  48. package/packages/mcp-server/src/session.js +475 -0
  49. package/packages/mcp-server/src/tool-registry.js +41 -0
  50. package/packages/state-engine/src/engine.js +566 -0
  51. package/packages/state-engine/src/ids.js +57 -0
  52. package/packages/state-engine/src/index.js +40 -0
  53. package/packages/state-engine/src/normalize.js +357 -0
  54. package/packages/state-engine/src/queries.js +154 -0
  55. package/packages/state-engine/src/replay.js +60 -0
  56. package/packages/ui-automation/src/executor.js +87 -0
  57. package/packages/ui-automation/src/guards.js +21 -0
  58. package/packages/ui-automation/src/helper.js +186 -0
  59. package/packages/ui-automation/src/index.js +20 -0
  60. package/packages/ui-automation/src/macos.js +82 -0
  61. package/packages/ui-automation/src/package-ui-helper.js +5 -0
  62. package/packages/ui-automation/src/workflows.js +72 -0
  63. package/scripts/install-remote-script.py +7 -0
  64. package/scripts/install-ui-helper.mjs +14 -0
  65. package/scripts/package-remote-script.py +7 -0
  66. package/scripts/package-ui-helper.mjs +4 -0
  67. package/scripts/remote_script_tooling.py +253 -0
@@ -0,0 +1,186 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { chmod, cp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const repoRoot = path.resolve(packageRoot, "..", "..");
9
+ const rootPackageJsonPath = path.join(repoRoot, "package.json");
10
+
11
+ function getPackageVersion() {
12
+ return JSON.parse(readFileSync(rootPackageJsonPath, "utf8")).version;
13
+ }
14
+
15
+ export function getUiHelperBundlePaths({
16
+ destinationRoot = path.join(repoRoot, "artifacts", "ui-helper")
17
+ } = {}) {
18
+ const appBundleRoot = path.join(destinationRoot, "laive-ui-helper.app");
19
+ const contentsRoot = path.join(appBundleRoot, "Contents");
20
+ const macOsRoot = path.join(contentsRoot, "MacOS");
21
+ const resourcesRoot = path.join(contentsRoot, "Resources");
22
+ const executablePath = path.join(macOsRoot, "laive-ui-helper");
23
+ const infoPlistPath = path.join(contentsRoot, "Info.plist");
24
+
25
+ return {
26
+ repoRoot,
27
+ destinationRoot,
28
+ appBundleRoot,
29
+ contentsRoot,
30
+ macOsRoot,
31
+ resourcesRoot,
32
+ executablePath,
33
+ infoPlistPath
34
+ };
35
+ }
36
+
37
+ export function getDefaultHelperExecutablePath() {
38
+ return getUiHelperBundlePaths().executablePath;
39
+ }
40
+
41
+ export function getStableUiHelperInstallPaths({
42
+ destinationRoot = path.join(os.homedir(), "Applications")
43
+ } = {}) {
44
+ return getUiHelperBundlePaths({ destinationRoot });
45
+ }
46
+
47
+ export function buildHelperInfoPlist() {
48
+ const packageVersion = getPackageVersion();
49
+ return `<?xml version="1.0" encoding="UTF-8"?>
50
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
51
+ <plist version="1.0">
52
+ <dict>
53
+ <key>CFBundleDevelopmentRegion</key>
54
+ <string>en</string>
55
+ <key>CFBundleExecutable</key>
56
+ <string>laive-ui-helper</string>
57
+ <key>CFBundleIdentifier</key>
58
+ <string>com.laive.ui-helper</string>
59
+ <key>CFBundleInfoDictionaryVersion</key>
60
+ <string>6.0</string>
61
+ <key>CFBundleName</key>
62
+ <string>laive-ui-helper</string>
63
+ <key>CFBundlePackageType</key>
64
+ <string>APPL</string>
65
+ <key>CFBundleShortVersionString</key>
66
+ <string>${packageVersion}</string>
67
+ <key>CFBundleVersion</key>
68
+ <string>1</string>
69
+ <key>LSUIElement</key>
70
+ <true/>
71
+ </dict>
72
+ </plist>
73
+ `;
74
+ }
75
+
76
+ function shellEscapeSingleQuotes(value) {
77
+ return String(value).replaceAll("'", `'\\''`);
78
+ }
79
+
80
+ export function buildHelperExecutableScript() {
81
+ const frontmostScript = [
82
+ 'tell application "System Events"',
83
+ "set frontApp to name of first application process whose frontmost is true",
84
+ "end tell",
85
+ "return frontApp"
86
+ ].join("\n");
87
+
88
+ return `#!/bin/zsh
89
+ set -euo pipefail
90
+
91
+ command="\${1:-}"
92
+ shift || true
93
+
94
+ decode_base64() {
95
+ /usr/bin/python3 - "$1" <<'PY'
96
+ import base64
97
+ import sys
98
+ print(base64.b64decode(sys.argv[1]).decode("utf-8"), end="")
99
+ PY
100
+ }
101
+
102
+ run_script() {
103
+ local script_content="$1"
104
+ /usr/bin/osascript -e "$script_content"
105
+ }
106
+
107
+ case "$command" in
108
+ run_applescript_base64)
109
+ if [[ "$#" -lt 1 ]]; then
110
+ echo "Missing base64 payload" >&2
111
+ exit 64
112
+ fi
113
+ script_content="$(decode_base64 "$1")"
114
+ run_script "$script_content"
115
+ ;;
116
+ frontmost_app)
117
+ run_script '${shellEscapeSingleQuotes(frontmostScript)}'
118
+ ;;
119
+ activate_app)
120
+ if [[ "$#" -lt 1 ]]; then
121
+ echo "Missing app name" >&2
122
+ exit 64
123
+ fi
124
+ run_script "tell application \\"$1\\" to activate"
125
+ ;;
126
+ *)
127
+ echo "Unsupported command: $command" >&2
128
+ exit 64
129
+ ;;
130
+ esac
131
+ `;
132
+ }
133
+
134
+ export async function stageUiHelper(options = {}) {
135
+ const paths = getUiHelperBundlePaths(options);
136
+
137
+ await mkdir(paths.macOsRoot, { recursive: true });
138
+ await mkdir(paths.resourcesRoot, { recursive: true });
139
+ await writeFile(paths.infoPlistPath, buildHelperInfoPlist(), "utf8");
140
+ await writeFile(paths.executablePath, buildHelperExecutableScript(), "utf8");
141
+ await chmod(paths.executablePath, 0o755);
142
+
143
+ return {
144
+ appBundleRoot: paths.appBundleRoot,
145
+ executablePath: paths.executablePath,
146
+ infoPlistPath: paths.infoPlistPath
147
+ };
148
+ }
149
+
150
+ export async function installUiHelper({
151
+ destinationRoot = path.join(os.homedir(), "Applications"),
152
+ dryRun = true,
153
+ overwrite = true
154
+ } = {}) {
155
+ const staged = await stageUiHelper();
156
+ const installPaths = getStableUiHelperInstallPaths({ destinationRoot });
157
+
158
+ const payload = {
159
+ stagedAppBundleRoot: staged.appBundleRoot,
160
+ stagedExecutablePath: staged.executablePath,
161
+ installDestinationRoot: installPaths.destinationRoot,
162
+ appBundleRoot: installPaths.appBundleRoot,
163
+ executablePath: installPaths.executablePath,
164
+ infoPlistPath: installPaths.infoPlistPath,
165
+ dryRun,
166
+ overwrite
167
+ };
168
+
169
+ if (dryRun) {
170
+ return {
171
+ ...payload,
172
+ status: "dry_run"
173
+ };
174
+ }
175
+
176
+ await mkdir(installPaths.destinationRoot, { recursive: true });
177
+ if (overwrite) {
178
+ await rm(installPaths.appBundleRoot, { recursive: true, force: true });
179
+ }
180
+ await cp(staged.appBundleRoot, installPaths.appBundleRoot, { recursive: true });
181
+
182
+ return {
183
+ ...payload,
184
+ status: "installed"
185
+ };
186
+ }
@@ -0,0 +1,20 @@
1
+ export { captureContext, executeWorkflow, materializeWorkflow } from "./executor.js";
2
+ export { assertMacOS, assertSupportedLiveWindow, assertWorkflowAllowed } from "./guards.js";
3
+ export {
4
+ buildHelperExecutableScript,
5
+ buildHelperInfoPlist,
6
+ getDefaultHelperExecutablePath,
7
+ getUiHelperBundlePaths,
8
+ getStableUiHelperInstallPaths,
9
+ installUiHelper,
10
+ stageUiHelper
11
+ } from "./helper.js";
12
+ export {
13
+ activateApplication,
14
+ clickMenuPath,
15
+ getFrontmostApplication,
16
+ runAppleScript,
17
+ sendKeystroke,
18
+ typeText
19
+ } from "./macos.js";
20
+ export { getWorkflow, workflows } from "./workflows.js";
@@ -0,0 +1,82 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+
5
+ import { assertMacOS } from "./guards.js";
6
+ import { getDefaultHelperExecutablePath } from "./helper.js";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ function quoteAppleScriptString(value) {
11
+ return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
12
+ }
13
+
14
+ export async function runAppleScript(lines) {
15
+ assertMacOS();
16
+
17
+ const script = Array.isArray(lines) ? lines.join("\n") : lines;
18
+ const helperExecutablePath = process.env.LAIVE_UI_HELPER_EXECUTABLE ?? getDefaultHelperExecutablePath();
19
+ const encodedScript = Buffer.from(script, "utf8").toString("base64");
20
+ const command =
21
+ helperExecutablePath && existsSync(helperExecutablePath)
22
+ ? { executable: helperExecutablePath, args: ["run_applescript_base64", encodedScript] }
23
+ : { executable: "/usr/bin/osascript", args: ["-e", script] };
24
+ const { stdout } = await execFileAsync(command.executable, command.args);
25
+ return stdout.trim();
26
+ }
27
+
28
+ export async function getFrontmostApplication() {
29
+ const output = await runAppleScript([
30
+ 'tell application "System Events"',
31
+ "set frontApp to name of first application process whose frontmost is true",
32
+ "end tell",
33
+ "return frontApp"
34
+ ]);
35
+
36
+ return {
37
+ appName: output,
38
+ isFrontmost: Boolean(output)
39
+ };
40
+ }
41
+
42
+ export async function activateApplication(appName) {
43
+ const safeAppName = quoteAppleScriptString(appName);
44
+ await runAppleScript(`tell application "${safeAppName}" to activate`);
45
+ }
46
+
47
+ export async function clickMenuPath(appName, menuPath) {
48
+ const [menuBarItem, menuItem] = menuPath;
49
+ const safeAppName = quoteAppleScriptString(appName);
50
+ const safeMenuBarItem = quoteAppleScriptString(menuBarItem);
51
+ const safeMenuItem = quoteAppleScriptString(menuItem);
52
+
53
+ await runAppleScript([
54
+ `tell application "${safeAppName}" to activate`,
55
+ 'tell application "System Events"',
56
+ `tell process "${safeAppName}"`,
57
+ `click menu item "${safeMenuItem}" of menu "${safeMenuBarItem}" of menu bar item "${safeMenuBarItem}" of menu bar 1`,
58
+ "end tell",
59
+ "end tell"
60
+ ]);
61
+ }
62
+
63
+ export async function sendKeystroke(value, modifiers = []) {
64
+ const safeValue = quoteAppleScriptString(value);
65
+ const modifierExpression =
66
+ modifiers.length > 0 ? ` using {${modifiers.map((item) => `${item} down`).join(", ")}}` : "";
67
+
68
+ await runAppleScript([
69
+ 'tell application "System Events"',
70
+ `keystroke "${safeValue}"${modifierExpression}`,
71
+ "end tell"
72
+ ]);
73
+ }
74
+
75
+ export async function typeText(value) {
76
+ const safeValue = quoteAppleScriptString(value);
77
+ await runAppleScript([
78
+ 'tell application "System Events"',
79
+ `keystroke "${safeValue}"`,
80
+ "end tell"
81
+ ]);
82
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { stageUiHelper } from "./helper.js";
3
+
4
+ const result = await stageUiHelper();
5
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -0,0 +1,72 @@
1
+ const commonGuards = ["platform:darwin", "app:ableton-live-frontmost"];
2
+
3
+ export const workflows = {
4
+ exportAudioVideo: {
5
+ name: "exportAudioVideo",
6
+ description: "Open the Export Audio/Video dialog and stage the UI for a deterministic export flow.",
7
+ allowFallback: true,
8
+ guards: commonGuards,
9
+ steps: [
10
+ { type: "activate_app", appName: "Ableton Live" },
11
+ { type: "menu_click", menuPath: ["File", "Export Audio/Video..."] },
12
+ { type: "wait_for_window", title: "Export Audio/Video", timeoutMs: 5000 }
13
+ ]
14
+ },
15
+ exportWithPreset: {
16
+ name: "exportWithPreset",
17
+ description: "Apply a known export preset, then confirm the export dialog.",
18
+ allowFallback: true,
19
+ guards: commonGuards,
20
+ parameters: ["presetName", "outputPath"],
21
+ steps: [
22
+ { type: "activate_app", appName: "Ableton Live" },
23
+ { type: "menu_click", menuPath: ["File", "Export Audio/Video..."] },
24
+ { type: "wait_for_window", title: "Export Audio/Video", timeoutMs: 5000 },
25
+ { type: "set_text_field", label: "Preset", parameter: "presetName" },
26
+ { type: "set_text_field", label: "Output Folder", parameter: "outputPath" },
27
+ { type: "press_button", label: "Export" }
28
+ ]
29
+ },
30
+ browserSearchAndLoad: {
31
+ name: "browserSearchAndLoad",
32
+ description: "Focus the browser, search for an item, and perform a deterministic load action.",
33
+ allowFallback: true,
34
+ guards: commonGuards,
35
+ parameters: ["query"],
36
+ steps: [
37
+ { type: "activate_app", appName: "Ableton Live" },
38
+ { type: "menu_click", menuPath: ["View", "Browser"] },
39
+ { type: "keystroke", value: "f", modifiers: ["command"] },
40
+ { type: "type_text", parameter: "query" },
41
+ { type: "keystroke", value: "return" }
42
+ ]
43
+ },
44
+ focusSection: {
45
+ name: "focusSection",
46
+ description: "Navigate Live to a named section using deterministic menu or shortcut actions.",
47
+ allowFallback: true,
48
+ guards: commonGuards,
49
+ parameters: ["sectionName"],
50
+ steps: [
51
+ { type: "activate_app", appName: "Ableton Live" },
52
+ { type: "focus_section", parameter: "sectionName" }
53
+ ]
54
+ },
55
+ captureContext: {
56
+ name: "captureContext",
57
+ description: "Capture focused app metadata for diagnostics before running a fallback action.",
58
+ allowFallback: true,
59
+ guards: ["platform:darwin"],
60
+ steps: [{ type: "capture_context" }]
61
+ }
62
+ };
63
+
64
+ export function getWorkflow(name) {
65
+ const workflow = workflows[name];
66
+
67
+ if (!workflow) {
68
+ throw new Error(`Unknown workflow: ${name}`);
69
+ }
70
+
71
+ return workflow;
72
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from remote_script_tooling import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,14 @@
1
+ import process from "node:process";
2
+
3
+ import { installUiHelper } from "../packages/ui-automation/src/index.js";
4
+
5
+ const args = process.argv.slice(2);
6
+ const dryRun = !args.includes("--apply");
7
+ const overwrite = args.includes("--overwrite");
8
+
9
+ const result = await installUiHelper({
10
+ dryRun,
11
+ overwrite
12
+ });
13
+
14
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from remote_script_tooling import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main(["package"]))
@@ -0,0 +1,4 @@
1
+ import { stageUiHelper } from "../packages/ui-automation/src/index.js";
2
+
3
+ const payload = await stageUiHelper();
4
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shutil
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Iterable, List
12
+
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parents[1]
15
+ REMOTE_SCRIPT_SOURCE = (
16
+ REPO_ROOT / "packages" / "live-bridge-remote-script" / "python" / "laive"
17
+ )
18
+ DEFAULT_ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "remote-script"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LiveInstall:
23
+ app_path: Path
24
+ remote_scripts_dir: Path
25
+
26
+ def to_dict(self) -> dict:
27
+ return {
28
+ "app_path": str(self.app_path),
29
+ "remote_scripts_dir": str(self.remote_scripts_dir),
30
+ }
31
+
32
+
33
+ def candidate_live_search_roots() -> List[Path]:
34
+ return [Path("/Applications"), Path.home() / "Applications"]
35
+
36
+
37
+ def remote_scripts_dir_for_app(app_path: Path) -> Path:
38
+ return app_path / "Contents" / "App-Resources" / "MIDI Remote Scripts"
39
+
40
+
41
+ def detect_live_installs(search_roots: Iterable[Path] | None = None) -> List[LiveInstall]:
42
+ installs = []
43
+ for root in search_roots or candidate_live_search_roots():
44
+ if not root.exists():
45
+ continue
46
+ for app_path in sorted(root.glob("Ableton Live*.app")):
47
+ remote_scripts_dir = remote_scripts_dir_for_app(app_path)
48
+ installs.append(
49
+ LiveInstall(app_path=app_path, remote_scripts_dir=remote_scripts_dir)
50
+ )
51
+ return installs
52
+
53
+
54
+ def ensure_source_exists(source_root: Path = REMOTE_SCRIPT_SOURCE) -> Path:
55
+ if not source_root.exists():
56
+ raise FileNotFoundError("Remote Script source not found: {0}".format(source_root))
57
+ return source_root
58
+
59
+
60
+ def stage_remote_script(
61
+ source_root: Path = REMOTE_SCRIPT_SOURCE,
62
+ artifacts_dir: Path = DEFAULT_ARTIFACTS_DIR,
63
+ archive_name: str = "laive-remote-script",
64
+ ) -> dict:
65
+ source_root = ensure_source_exists(source_root)
66
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
67
+ staging_root = artifacts_dir / "staging"
68
+ target_dir = staging_root / source_root.name
69
+
70
+ if target_dir.exists():
71
+ shutil.rmtree(target_dir)
72
+
73
+ shutil.copytree(source_root, target_dir)
74
+ archive_path = shutil.make_archive(
75
+ str(artifacts_dir / archive_name), "zip", root_dir=staging_root, base_dir=source_root.name
76
+ )
77
+
78
+ return {
79
+ "source_root": str(source_root),
80
+ "staging_dir": str(target_dir),
81
+ "archive_path": archive_path,
82
+ }
83
+
84
+
85
+ def choose_live_install(
86
+ live_app_path: Path | None = None, search_roots: Iterable[Path] | None = None
87
+ ) -> LiveInstall:
88
+ if live_app_path is not None:
89
+ return LiveInstall(
90
+ app_path=live_app_path,
91
+ remote_scripts_dir=remote_scripts_dir_for_app(live_app_path),
92
+ )
93
+
94
+ installs = detect_live_installs(search_roots)
95
+ if not installs:
96
+ raise FileNotFoundError(
97
+ "No Ableton Live installs were detected. Pass --live-app to choose a bundle explicitly."
98
+ )
99
+ if len(installs) > 1:
100
+ raise RuntimeError(
101
+ "Multiple Live installs were detected. Pass --live-app to choose one explicitly."
102
+ )
103
+ return installs[0]
104
+
105
+
106
+ def doctor_report(search_roots: Iterable[Path] | None = None) -> dict:
107
+ installs = detect_live_installs(search_roots)
108
+ remote_script_source_exists = REMOTE_SCRIPT_SOURCE.exists()
109
+ archive_path = DEFAULT_ARTIFACTS_DIR / "laive-remote-script.zip"
110
+ package_json = REPO_ROOT / "package.json"
111
+ bin_script = REPO_ROOT / "bin" / "laive.mjs"
112
+
113
+ return {
114
+ "repo_root": str(REPO_ROOT),
115
+ "python_executable": sys.executable,
116
+ "python_version": sys.version.split()[0],
117
+ "remote_script_source": str(REMOTE_SCRIPT_SOURCE),
118
+ "remote_script_source_exists": remote_script_source_exists,
119
+ "cli_entrypoint": str(bin_script),
120
+ "cli_entrypoint_exists": bin_script.exists(),
121
+ "package_json_exists": package_json.exists(),
122
+ "packaged_archive": str(archive_path),
123
+ "packaged_archive_exists": archive_path.exists(),
124
+ "detected_live_installs": [install.to_dict() for install in installs],
125
+ "ready_for_install": remote_script_source_exists and len(installs) >= 1,
126
+ }
127
+
128
+
129
+ def install_remote_script(
130
+ live_app_path: Path | None = None,
131
+ source_root: Path = REMOTE_SCRIPT_SOURCE,
132
+ dry_run: bool = True,
133
+ overwrite: bool = False,
134
+ auto_package: bool = True,
135
+ ) -> dict:
136
+ source_root = ensure_source_exists(source_root)
137
+ chosen_install = choose_live_install(live_app_path)
138
+ live_app_path = chosen_install.app_path
139
+ remote_scripts_dir = chosen_install.remote_scripts_dir
140
+ target_dir = remote_scripts_dir / source_root.name
141
+ package_payload = stage_remote_script(source_root=source_root) if auto_package else None
142
+
143
+ payload = {
144
+ "live_app_path": str(live_app_path),
145
+ "remote_scripts_dir": str(remote_scripts_dir),
146
+ "source_root": str(source_root),
147
+ "target_dir": str(target_dir),
148
+ "dry_run": dry_run,
149
+ "overwrite": overwrite,
150
+ "auto_packaged": auto_package,
151
+ "package_payload": package_payload,
152
+ }
153
+
154
+ if dry_run:
155
+ payload["status"] = "dry_run"
156
+ payload["would_install"] = source_root.exists()
157
+ payload["remote_scripts_dir_exists"] = remote_scripts_dir.exists()
158
+ payload["target_exists"] = target_dir.exists()
159
+ return payload
160
+
161
+ remote_scripts_dir.mkdir(parents=True, exist_ok=True)
162
+ if target_dir.exists():
163
+ if not overwrite:
164
+ raise FileExistsError(
165
+ "Target Remote Script already exists: {0}. Use --overwrite to replace it.".format(
166
+ target_dir
167
+ )
168
+ )
169
+ shutil.rmtree(target_dir)
170
+
171
+ shutil.copytree(source_root, target_dir)
172
+ payload["status"] = "installed"
173
+ payload["target_exists"] = target_dir.exists()
174
+ return payload
175
+
176
+
177
+ def _build_parser() -> argparse.ArgumentParser:
178
+ parser = argparse.ArgumentParser(description="Package and install the laive Remote Script.")
179
+ subparsers = parser.add_subparsers(dest="command", required=True)
180
+
181
+ detect_parser = subparsers.add_parser("detect", help="Detect likely Ableton Live.app installs.")
182
+ detect_parser.add_argument("--json", action="store_true", help="Emit JSON instead of plain text.")
183
+
184
+ doctor_parser = subparsers.add_parser("doctor", help="Report whether the repo is ready to install.")
185
+ doctor_parser.add_argument("--json", action="store_true", help="Emit JSON instead of plain text.")
186
+
187
+ package_parser = subparsers.add_parser("package", help="Stage and zip the Remote Script.")
188
+ package_parser.add_argument(
189
+ "--artifacts-dir",
190
+ default=str(DEFAULT_ARTIFACTS_DIR),
191
+ help="Directory where staged files and zip archives are written.",
192
+ )
193
+ package_parser.add_argument("--json", action="store_true", help="Emit JSON instead of plain text.")
194
+
195
+ install_parser = subparsers.add_parser("install", help="Install the Remote Script into a Live.app bundle.")
196
+ install_parser.add_argument("--live-app", help="Path to Ableton Live.app")
197
+ install_parser.add_argument("--apply", action="store_true", help="Perform the install instead of a dry run.")
198
+ install_parser.add_argument("--overwrite", action="store_true", help="Overwrite an existing laive script.")
199
+ install_parser.add_argument("--json", action="store_true", help="Emit JSON instead of plain text.")
200
+
201
+ return parser
202
+
203
+
204
+ def main(argv: list[str] | None = None) -> int:
205
+ parser = _build_parser()
206
+ args = parser.parse_args(argv)
207
+
208
+ if args.command == "detect":
209
+ installs = [install.to_dict() for install in detect_live_installs()]
210
+ if args.json:
211
+ print(json.dumps({"installs": installs}, indent=2))
212
+ else:
213
+ for install in installs:
214
+ print("{app_path} -> {remote_scripts_dir}".format(**install))
215
+ return 0
216
+
217
+ if args.command == "doctor":
218
+ payload = doctor_report()
219
+ if args.json:
220
+ print(json.dumps(payload, indent=2))
221
+ else:
222
+ for key, value in payload.items():
223
+ print("{0}: {1}".format(key, value))
224
+ return 0
225
+
226
+ if args.command == "package":
227
+ payload = stage_remote_script(artifacts_dir=Path(args.artifacts_dir))
228
+ if args.json:
229
+ print(json.dumps(payload, indent=2))
230
+ else:
231
+ for key, value in payload.items():
232
+ print("{0}: {1}".format(key, value))
233
+ return 0
234
+
235
+ if args.command == "install":
236
+ payload = install_remote_script(
237
+ live_app_path=Path(args.live_app) if args.live_app else None,
238
+ dry_run=not args.apply,
239
+ overwrite=args.overwrite,
240
+ )
241
+ if args.json:
242
+ print(json.dumps(payload, indent=2))
243
+ else:
244
+ for key, value in payload.items():
245
+ print("{0}: {1}".format(key, value))
246
+ return 0
247
+
248
+ parser.error("Unknown command")
249
+ return 1
250
+
251
+
252
+ if __name__ == "__main__":
253
+ raise SystemExit(main())