diff --git a/.gitignore b/.gitignore index d7178cd..4adb412 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ api/*.pyc api/**/*.pyc api/.venv/ api/venv/ +tools/modly-cli/__pycache__/ # Models (heavy, downloaded at runtime) /models/ diff --git a/README.md b/README.md index 50738c1..cb81c3c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Alternatively, you can clone the repository and run the app directly without ins ```bash # Windows -launcher.bat +launch.bat # Linux ./launcher.sh @@ -91,7 +91,22 @@ Modly supports external AI model extensions. Each extension is a GitHub reposito --- -### Community +## Modly CLI + +Agents and scripts can call a running Modly desktop app without using the UI via the stdlib-only CLI: + +```bash +python tools/modly-cli/agent.py health +python tools/modly-cli/agent.py generate --image ./input.png --output ./export.glb +python tools/modly-cli/agent.py generate-from-workflow --workflow Trellis2Workflow --prompt "clean isolated robot toy" --output ./export.glb +python tools/modly-cli/agent.py generate --image ./input.png --output ./fast-geometry.glb --no-texture +``` + +Useful extras include `status`, `models`, `params`, `job`, `cancel`, `export`, directory/manifest `batch`, preconfigured ComfyUI source-image runs via `comfy-image` / `generate-from-workflow` (default workflow name: `Trellis2Workflow`), and optional headless backend startup with `serve` / `ensure-server --start`. The CLI talks to the local app API at `http://127.0.0.1:8765`, runs the texture/refine node by default when one is available, waits for image-to-3D generation, exports the mesh, and prints a single JSON object containing the new `export_path`. Agent texture defaults are intentionally higher quality than the extension UI defaults (`texture_steps=30`, `texture_guidance=3.0`); override them with flags if needed. Use `--no-texture` only for faster geometry-only smoke tests. See `tools/modly-cli/SKILL.md` for the agent workflow and output contract. + +--- + +### Community Join the [Discord server](https://discord.gg/BvjDCvS3yr) to stay up to date with the latest news, report bugs, and share feedback. diff --git a/package-lock.json b/package-lock.json index 28cca19..27cf7c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modly", - "version": "0.3.0", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modly", - "version": "0.3.0", + "version": "0.3.5", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@react-three/drei": "^9.120.0", @@ -82,6 +82,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1732,6 +1733,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.26.7", @@ -2380,6 +2382,7 @@ "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2429,6 +2432,7 @@ "version": "0.171.0", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.171.0.tgz", "integrity": "sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==", + "peer": true, "dependencies": { "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", @@ -2583,6 +2587,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2617,6 +2622,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2815,7 +2821,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -2835,7 +2840,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -2858,7 +2862,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -2874,8 +2877,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -2883,7 +2885,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -3063,7 +3064,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -3090,7 +3090,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -3161,6 +3160,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3585,7 +3585,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -3691,7 +3690,6 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -3731,7 +3729,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -3845,6 +3842,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4057,6 +4055,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -4197,6 +4196,7 @@ "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", "hasInstallScript": true, + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", @@ -4242,7 +4242,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -4256,7 +4255,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -4272,7 +4270,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -4286,7 +4283,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -5073,6 +5069,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5472,8 +5469,7 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "8.1.0", @@ -6117,8 +6113,7 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -6196,6 +6191,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6291,7 +6287,6 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -6305,7 +6300,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -6321,8 +6315,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -6330,7 +6323,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -6401,16 +6393,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -6423,8 +6413,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -6438,8 +6427,7 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -6452,8 +6440,7 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", @@ -7000,6 +6987,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7142,6 +7130,7 @@ "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.0.tgz", "integrity": "sha512-/G6JY8hs426lcto/pBZlnFSkyEo1fHsh4gy7FPJtq1SaSUOzJgDW6f6f1K/+aMOYzK/eQEefyOb3++jPPIUeDA==", "license": "Zlib", + "peer": true, "peerDependencies": { "three": ">= 0.168.0 < 0.184.0" } @@ -7165,8 +7154,7 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -7267,6 +7255,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7289,6 +7278,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7381,7 +7371,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -7397,7 +7386,6 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -7600,8 +7588,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -7825,7 +7812,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -8032,7 +8018,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -8152,7 +8137,8 @@ "node_modules/three": { "version": "0.171.0", "resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz", - "integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==" + "integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.9.9", @@ -8228,6 +8214,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -8484,6 +8471,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9125,7 +9113,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -9141,7 +9128,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", diff --git a/tools/modly-cli/SKILL.md b/tools/modly-cli/SKILL.md new file mode 100644 index 0000000..4d55a36 --- /dev/null +++ b/tools/modly-cli/SKILL.md @@ -0,0 +1,241 @@ +--- +name: modly-cli +description: Use when an agent needs to call a running Modly desktop instance or headless FastAPI backend from the terminal to generate/export image-to-3D assets without using the UI. +version: 1.1.0 +author: Modly +license: MIT +metadata: + hermes: + tags: [modly, image-to-3d, cli, automation, agents] + related_skills: [] +--- + +# Modly CLI + +## Overview + +Modly exposes a local FastAPI server at `http://127.0.0.1:8765` while the desktop app is running. The repository includes a small stdlib-only CLI at `tools/modly-cli/agent.py` so coding agents and scripts can call that API predictably, wait for generation, export meshes, and receive machine-readable JSON. + +The preferred path is to launch the official Modly desktop app first. The CLI also includes optional headless helpers (`serve`, `ensure-server --start`) that start only the FastAPI backend when the API directory and Python environment can be discovered or passed explicitly. Headless mode does not install dependencies. + +## When to Use + +- You need to verify Modly end-to-end from an agent or CI-like shell. +- You have an input image and want a GLB/STL/OBJ/PLY export path without clicking through the UI. +- You need a stable JSON result containing `job_id`, `workspace_path`, `export_path`, and `bytes_written`. +- You want a tiny integration surface with no npm install and no third-party Python packages. +- You need to inspect API/model status, model params, one job, or re-export an existing workspace mesh. + +Do not use this for UI-only workflows such as installing extensions, repairing extension environments, or changing app settings unless an API endpoint exists. + +## Prerequisites + +1. Launch the official Modly desktop app, or start the backend with `serve` if a configured API environment already exists. +2. Confirm the local API is ready: + +```bash +python tools/modly-cli/agent.py health +``` + +A healthy response looks like: + +```json +{ + "base_url": "http://127.0.0.1:8765", + "health": {"status": "ok"}, + "ok": true +} +``` + +Use `--compact` when another agent needs single-line JSON: + +```bash +python tools/modly-cli/agent.py --compact status +``` + +## Common Commands + +Show health plus active model status: + +```bash +python tools/modly-cli/agent.py status +``` + +List model adapters known to the running app: + +```bash +python tools/modly-cli/agent.py models +``` + +Show parameter schema for the auto-selected, active, or explicit model: + +```bash +python tools/modly-cli/agent.py params +python tools/modly-cli/agent.py params --model active +python tools/modly-cli/agent.py params --model sf3d +``` + +Generate a textured GLB from an image and export beside the input image: + +```bash +python tools/modly-cli/agent.py generate \ + --image /path/to/input.png \ + --format glb \ + --collection Agent \ + --progress +``` + +Texture generation is enabled by default for agent runs. When Modly exposes a separate texture/refine node (for example `trellis2/refine`), the CLI runs generation first, passes the generated `mesh_path` into the texture node, then exports the textured result. Agent defaults intentionally use slower texture settings than the UI extension default (`texture_steps=30`, `texture_guidance=3.0`) because low-step/low-guidance texturing can look washed out or poorly aligned. Override these with `--texture-steps`, `--texture-guidance`, or `--texture-params-json`. Use `--no-texture` only when you intentionally want a faster geometry-only smoke test: + +```bash +python tools/modly-cli/agent.py generate \ + --image /path/to/input.png \ + --output /path/to/fast-geometry.glb \ + --no-texture +``` + +Use a preconfigured ComfyUI workflow to create the source image first, then feed that into Modly. The default workflow name is `Trellis2Workflow`; pass a JSON path or set `COMFYUI_WORKFLOW_DIR` if the workflow is not exposed through the running ComfyUI `/userdata` API: + +```bash +python tools/modly-cli/agent.py generate-from-workflow \ + --workflow Trellis2Workflow \ + --prompt "clean orthographic product render of a stylized robot toy, centered, white background" \ + --output /path/to/export.glb \ + --progress +``` + +For debugging the ComfyUI side only, save just the first image output: + +```bash +python tools/modly-cli/agent.py comfy-image \ + --workflow Trellis2Workflow \ + --prompt "clean object render, isolated on white" \ + --comfy-output /path/to/source.png +``` + +Generate with an explicit output path: + +```bash +python tools/modly-cli/agent.py generate \ + --image /path/to/input.png \ + --output /path/to/export.glb +``` + +Use a non-default model and model-specific JSON parameters: + +```bash +python tools/modly-cli/agent.py generate \ + --image /path/to/input.png \ + --model sf3d \ + --params-json '{"foreground_ratio":0.85}' +``` + +Check or cancel an existing generation job: + +```bash +python tools/modly-cli/agent.py job +python tools/modly-cli/agent.py cancel +``` + +Export an existing workspace mesh path without running generation: + +```bash +python tools/modly-cli/agent.py export \ + --path Agent/foo.glb \ + --output ./foo.glb +``` + +Generate meshes sequentially from a directory or manifest JSON: + +```bash +python tools/modly-cli/agent.py batch \ + --input-dir ./images \ + --output-dir ./meshes \ + --continue-on-error + +python tools/modly-cli/agent.py batch \ + --manifest ./jobs.json \ + --output-dir ./meshes +``` + +Manifest files may be a JSON list, or an object with `jobs`/`images`. Each entry can be a string image path or an object with `image`, optional `output`, and optional `format`. + +Start or inspect the headless backend command: + +```bash +python tools/modly-cli/agent.py serve --print-command +python tools/modly-cli/agent.py ensure-server +python tools/modly-cli/agent.py ensure-server --fail-on-unavailable +python tools/modly-cli/agent.py ensure-server --start --detach +``` + +## Headless Backend Notes + +- `serve` starts `python -m uvicorn main:app` in the Modly API directory; it does not launch Electron. +- `serve` does not install dependencies. If setup has not run, launch desktop Modly once or pass explicit `--api-dir` and `--python` paths after setup. +- `ensure-server --start` checks health first, then starts `serve` behavior only if the API is unavailable. +- `ensure-server` reports `ok: false` with exit 0 by default when the server is unavailable; add `--fail-on-unavailable` when an agent/CI step should exit non-zero. +- Do not use `ensure-server --start` when the desktop app is already managing the API. +- Useful explicit flags: `--api-dir`, `--python`, `--models-dir`, `--workspace-dir`, `--extensions-dir`, `--model`, `--hf-token`. + +## Output Contract + +Successful `generate` calls print one JSON object to stdout: + +```json +{ + "ok": true, + "base_url": "http://127.0.0.1:8765", + "image": "/absolute/path/to/input.png", + "model_id": "sf3d", + "job_id": "...", + "status": {"status": "done", "progress": 100, "output_url": "/workspace/..."}, + "workspace_path": "Agent/model_textured.glb", + "geometry_workspace_path": "Agent/model.glb", + "texture_model_id": "trellis2/refine", + "texture_job_id": "...", + "export_format": "glb", + "texture_enabled": true, + "export_path": "/absolute/path/to/export.glb", + "bytes_written": 123456 +} +``` + +Failures also print JSON, but with `ok: false` and a human-readable `error` field. The process exits non-zero. + +Progress output from `generate` / `batch` is emitted to stderr when `--progress` is passed, keeping stdout parseable as one final JSON object. Add `--quiet` to suppress progress. + +## Agent Workflow + +1. Create or locate an image file. +2. Run `python tools/modly-cli/agent.py health` or `status` and stop early if it fails. +3. Optionally run `models` and `params` to inspect available model ids and parameter schema. By default `generate` uses `--model auto`, preferring an image generation model over texture/refine-only nodes, and then auto-selects a texture/refine model for a second pass when available. +4. If the desired source asset should come from a preconfigured ComfyUI workflow, run `generate-from-workflow --workflow Trellis2Workflow --prompt ...` instead of manually creating an image file. Use `comfy-image` first when debugging the ComfyUI prompt/workflow output. +5. Run `generate` with an explicit `--output` path when the caller needs deterministic artifacts. Add `--no-texture` only for intentional geometry-only smoke tests. +6. Parse stdout JSON and verify: + - `ok` is `true` + - `export_path` exists + - `bytes_written` is greater than zero +7. If a re-export is needed, call `export --path --output `. + +## Common Pitfalls + +1. **Modly is not running.** The CLI will return `Cannot reach Modly API`. Launch the desktop app first or use `ensure-server --start` only when an API environment is ready. +2. **First model load can be slow.** Use `--timeout 3600` for first-run downloads or cold GPU loads. +3. **Model id mismatch.** Run `models` and use an id from the returned list. The default is `auto`, which prefers ids/names containing `generate`; pass `--model active` only when the active model accepts raw images. +4. **Untextured smoke-test exports.** `generate` enables textures by default. If a mesh is gray/white, check that the command or manifest did not pass `--no-texture`, and inspect the returned `texture_enabled` field. +5. **WSL/Windows path confusion.** From WSL, pass `/mnt/c/...` paths. From Windows PowerShell or cmd, pass normal `C:\...` paths. +6. **Progress output is on stderr.** This keeps stdout parseable as a single final JSON object. +7. **Headless startup is not setup.** It starts an existing configured backend; it does not create a venv, download models, or repair dependencies. + +## Verification Checklist + +- [ ] `python tools/modly-cli/agent.py health` returns `ok: true`. +- [ ] `python tools/modly-cli/agent.py status` returns `ok`, `health`, and `model`. +- [ ] `python tools/modly-cli/agent.py models` returns at least one model. +- [ ] `python tools/modly-cli/agent.py params` returns a parameter schema. +- [ ] `generate` exits 0 and prints `ok: true` with `texture_enabled: true` unless `--no-texture` was intentionally passed. +- [ ] The reported `export_path` exists and has non-zero size. +- [ ] `export --path ` can re-export the generated mesh. +- [ ] The exported mesh opens in Modly or another GLB/STL/OBJ/PLY viewer. +- [ ] `python tools/modly-cli/test_agent.py` passes. diff --git a/tools/modly-cli/agent.py b/tools/modly-cli/agent.py new file mode 100644 index 0000000..538b23a --- /dev/null +++ b/tools/modly-cli/agent.py @@ -0,0 +1,913 @@ +#!/usr/bin/env python3 +"""Minimal agent-friendly CLI for the local Modly API. + +The Electron app normally owns the FastAPI server. This tool is intentionally +small and stdlib-only so automation agents can call a running Modly instance, +optionally start only the FastAPI backend, and always receive parseable JSON. +""" +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +DEFAULT_BASE_URL = os.environ.get("MODLY_API_URL", "http://127.0.0.1:8765") +DEFAULT_TIMEOUT_SECONDS = int(os.environ.get("MODLY_CLI_TIMEOUT", os.environ.get("MODLY_AGENT_TIMEOUT", "1800"))) +DEFAULT_POLL_SECONDS = float(os.environ.get("MODLY_CLI_POLL_SECONDS", os.environ.get("MODLY_AGENT_POLL_SECONDS", "2"))) +EXPORT_FORMATS = ("glb", "stl", "obj", "ply") +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} + + +class ModlyCliError(RuntimeError): + """Expected user/API failure that should be reported as JSON.""" + + +def _json_print(data: dict[str, Any], *, compact: bool = False) -> None: + if compact: + print(json.dumps(data, separators=(",", ":"), sort_keys=True)) + else: + print(json.dumps(data, indent=2, sort_keys=True)) + + +def _request_json( + method: str, + url: str, + *, + timeout: float, + data: bytes | None = None, + headers: dict[str, str] | None = None, +) -> Any: + req = urllib.request.Request(url, data=data, method=method, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise ModlyCliError(f"HTTP {exc.code} from {url}: {detail}") from exc + except urllib.error.URLError as exc: + raise ModlyCliError(f"Cannot reach Modly API at {url}: {exc.reason}") from exc + try: + return json.loads(raw) if raw else {} + except json.JSONDecodeError as exc: + raise ModlyCliError(f"Expected JSON from {url}, got: {raw[:500]}") from exc + + +def _download(url: str, dest: Path, *, timeout: float) -> int: + dest.parent.mkdir(parents=True, exist_ok=True) + try: + with urllib.request.urlopen(url, timeout=timeout) as resp, dest.open("wb") as fh: + total = 0 + while True: + chunk = resp.read(1024 * 1024) + if not chunk: + return total + fh.write(chunk) + total += len(chunk) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise ModlyCliError(f"HTTP {exc.code} while downloading {url}: {detail}") from exc + except urllib.error.URLError as exc: + raise ModlyCliError(f"Cannot download {url}: {exc.reason}") from exc + except OSError as exc: + raise ModlyCliError(f"Cannot write to {dest}: {exc}") from exc + + +def _multipart_form(fields: dict[str, str], file_field: str, file_path: Path) -> tuple[bytes, str]: + boundary = f"----modly-cli-{time.time_ns()}" + parts: list[bytes] = [] + + for name, value in fields.items(): + parts.extend([ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(), + str(value).encode("utf-8"), + b"\r\n", + ]) + + content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + parts.extend([ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="{file_field}"; filename="{file_path.name}"\r\n'.encode(), + f"Content-Type: {content_type}\r\n\r\n".encode(), + file_path.read_bytes(), + b"\r\n", + f"--{boundary}--\r\n".encode(), + ]) + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +def _workspace_relative_path(output_url: str) -> str: + parsed = urllib.parse.urlparse(output_url) + path = parsed.path if parsed.scheme else output_url + prefix = "/workspace/" + if path.startswith(prefix): + return urllib.parse.unquote(path[len(prefix):]) + return urllib.parse.unquote(path.lstrip("/")) + + +def _export_workspace_path(base_url: str, workspace_path: str, fmt: str, dest: Path, *, timeout: float) -> int: + export_url = f"{base_url.rstrip('/')}/export/{urllib.parse.quote(fmt)}?{urllib.parse.urlencode({'path': workspace_path})}" + return _download(export_url, dest, timeout=timeout) + + +def _try_health(base_url: str, timeout: float) -> dict[str, Any] | None: + try: + health = _request_json("GET", f"{base_url.rstrip('/')}/health", timeout=timeout) + except ModlyCliError: + return None + return health if isinstance(health, dict) else {"raw": health} + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _windows_env_paths(name: str) -> list[Path]: + value = os.environ.get(name) + if value: + return [Path(value)] + paths: list[Path] = [] + if os.name == "posix": + users_dir = Path("/mnt/c/Users") + roots: list[Path] = [] + if users_dir.exists(): + try: + roots = sorted(p for p in users_dir.glob("*") if p.is_dir()) + except PermissionError: + roots = [] + for root in roots: + try: + if name == "LOCALAPPDATA": + candidate = root / "AppData" / "Local" + elif name == "APPDATA": + candidate = root / "AppData" / "Roaming" + else: + continue + if candidate.exists(): + paths.append(candidate) + except PermissionError: + continue + return paths + + +def _windows_env_path(name: str) -> Path | None: + paths = _windows_env_paths(name) + return paths[0] if paths else None + + +def _default_api_dir() -> Path | None: + repo_api = _repo_root() / "api" + if (repo_api / "main.py").exists(): + return repo_api + for local in _windows_env_paths("LOCALAPPDATA"): + installed = local / "Programs" / "Modly" / "resources" / "api" + if (installed / "main.py").exists(): + return installed + return None + + +def _default_python(api_dir: Path) -> Path | None: + candidates = [ + api_dir / ".venv" / "Scripts" / "python.exe", + api_dir / ".venv" / "bin" / "python", + ] + for appdata in _windows_env_paths("APPDATA"): + candidates.append(appdata / "Modly" / "dependencies" / "venv" / "Scripts" / "python.exe") + candidates.append(Path(sys.executable)) + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def _load_modly_settings() -> dict[str, Any]: + candidates: list[Path] = [] + for appdata in _windows_env_paths("APPDATA"): + candidates.append(appdata / "Modly" / "settings.json") + candidates.append(Path.home() / ".config" / "Modly" / "settings.json") + for path in candidates: + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + return {} + + +def _resolve_serve_config(args: argparse.Namespace) -> tuple[Path, Path, dict[str, str], list[str], str]: + api_dir = Path(args.api_dir).expanduser().resolve() if getattr(args, "api_dir", None) else _default_api_dir() + if not api_dir or not (api_dir / "main.py").exists(): + raise ModlyCliError("Could not find Modly api directory; pass --api-dir") + + python = Path(args.python).expanduser().resolve() if getattr(args, "python", None) else _default_python(api_dir) + if not python or not python.exists(): + raise ModlyCliError("Could not find Modly Python environment; pass --python") + + settings = _load_modly_settings() + env = os.environ.copy() + hf_token = getattr(args, "hf_token", None) or settings.get("hfToken") or os.environ.get("HF_TOKEN", "") + env.update({ + "PYTHONUNBUFFERED": "1", + "MODELS_DIR": getattr(args, "models_dir", None) or settings.get("modelsDir") or str(Path.home() / ".modly" / "models"), + "WORKSPACE_DIR": getattr(args, "workspace_dir", None) or settings.get("workspaceDir") or str(Path.home() / ".modly" / "workspace"), + "EXTENSIONS_DIR": getattr(args, "extensions_dir", None) or settings.get("extensionsDir") or "", + "SELECTED_MODEL_ID": getattr(args, "model", None) or os.environ.get("SELECTED_MODEL_ID", ""), + "HUGGING_FACE_HUB_TOKEN": hf_token, + "HF_TOKEN": hf_token, + }) + cmd = [str(python), "-m", "uvicorn", "main:app", "--host", args.host, "--port", str(args.port)] + base_url = f"http://{args.host}:{args.port}" + return api_dir, python, env, cmd, base_url + + +def _start_backend(cmd: list[str], *, api_dir: Path, env: dict[str, str], detach: bool) -> subprocess.Popen[Any]: + kwargs: dict[str, Any] = {"cwd": str(api_dir), "env": env} + if detach: + kwargs.update({ + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + }) + if os.name != "nt": + kwargs["start_new_session"] = True + else: + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + return subprocess.Popen(cmd, **kwargs) + + +def cmd_health(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + data = _request_json("GET", f"{base_url}/health", timeout=args.request_timeout) + _json_print({"ok": True, "base_url": base_url, "health": data}, compact=args.compact) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + health = _request_json("GET", f"{base_url}/health", timeout=args.request_timeout) + model = _request_json("GET", f"{base_url}/model/status", timeout=args.request_timeout) + _json_print({"ok": True, "base_url": base_url, "health": health, "model": model}, compact=args.compact) + return 0 + + +def cmd_models(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + data = _request_json("GET", f"{base_url}/model/all", timeout=args.request_timeout) + _json_print({"ok": True, "base_url": base_url, "models": data}, compact=args.compact) + return 0 + + +def _parse_params(params_json: str | None, params_file: str | None) -> dict[str, Any]: + if params_file: + text = Path(params_file).expanduser().read_text(encoding="utf-8") + else: + text = params_json or "{}" + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise ModlyCliError(f"params must be valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise ModlyCliError("params must be a JSON object") + return parsed + + +def _choose_auto_model(base_url: str, request_timeout: float) -> str: + models = _request_json("GET", f"{base_url.rstrip('/')}/model/all", timeout=request_timeout) + if not isinstance(models, list) or not models: + active = _request_json("GET", f"{base_url.rstrip('/')}/model/status", timeout=request_timeout) + if isinstance(active, dict) and active.get("id"): + return str(active["id"]) + raise ModlyCliError(f"Could not resolve a model id: {models}") + + def text(model: dict[str, Any]) -> str: + return f"{model.get('id', '')} {model.get('name', '')}".lower() + + active_models = [m for m in models if isinstance(m, dict) and m.get("active")] + if active_models: + active_text = text(active_models[0]) + if "refine" not in active_text and "texture" not in active_text: + return str(active_models[0].get("id")) + + for model in models: + if isinstance(model, dict) and ("generate" in text(model) or str(model.get("id", "")).endswith("/generate")): + return str(model.get("id")) + first = models[0] + if isinstance(first, dict) and first.get("id"): + return str(first["id"]) + raise ModlyCliError(f"Could not resolve a model id: {models}") + + +def _resolve_model_id(args: argparse.Namespace, base_url: str) -> str: + model_id = args.model + if not model_id or model_id == "auto": + return _choose_auto_model(base_url, args.request_timeout) + if model_id == "active": + active = _request_json("GET", f"{base_url}/model/status", timeout=args.request_timeout) + if not isinstance(active, dict) or not active.get("id"): + raise ModlyCliError(f"Could not resolve active model id: {active}") + return str(active["id"]) + return str(model_id) + + +def cmd_params(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + model_id = args.model + if model_id == "auto": + model_id = _choose_auto_model(base_url, args.request_timeout) + query = "" + if model_id and model_id != "active": + query = "?" + urllib.parse.urlencode({"model_id": model_id}) + params = _request_json("GET", f"{base_url}/model/params{query}", timeout=args.request_timeout) + _json_print({"ok": True, "base_url": base_url, "model_id": model_id, "params": params}, compact=args.compact) + return 0 + + +def cmd_job(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + status = _request_json("GET", f"{base_url}/generate/status/{urllib.parse.quote(args.job_id)}", timeout=args.request_timeout) + _json_print({"ok": True, "base_url": base_url, "job_id": args.job_id, "status": status}, compact=args.compact) + return 0 + + +def cmd_cancel(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + result = _request_json("POST", f"{base_url}/generate/cancel/{urllib.parse.quote(args.job_id)}", timeout=args.request_timeout) + _json_print({"ok": True, "base_url": base_url, "job_id": args.job_id, "cancel": result}, compact=args.compact) + return 0 + + + +def _load_comfy_workflow(workflow: str, *, host: str, timeout: float) -> dict[str, Any]: + path = Path(workflow).expanduser() + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ModlyCliError(f"workflow must be valid JSON: {path}: {exc}") from exc + if not isinstance(data, dict): + raise ModlyCliError(f"workflow JSON must be an object: {path}") + return data + + candidates = [workflow, f"{workflow}.json"] if not workflow.endswith(".json") else [workflow] + for name in candidates: + quoted = urllib.parse.quote(name.lstrip("/"), safe="/") + for prefix in ("/userdata/workflows/", "/api/userdata/workflows/", "/userdata/", "/api/userdata/"): + try: + data = _request_json("GET", f"{host.rstrip('/')}{prefix}{quoted}", timeout=timeout) + except ModlyCliError: + continue + if isinstance(data, dict): + return data + + search_roots: list[Path] = [] + for value in [os.environ.get("COMFYUI_WORKFLOW_DIR"), os.environ.get("COMFYUI_USER_DIR")]: + if value: + search_roots.append(Path(value).expanduser()) + search_roots.extend([ + Path.home() / "ComfyUI" / "user" / "default" / "workflows", + Path.home() / "Documents" / "ComfyUI" / "user" / "default" / "workflows", + ]) + for appdata in _windows_env_paths("APPDATA"): + search_roots.extend([ + appdata / "ComfyUI" / "user" / "default" / "workflows", + appdata / "comfyui" / "user" / "default" / "workflows", + ]) + for root in search_roots: + for name in candidates: + candidate = root / name + if candidate.exists(): + try: + data = json.loads(candidate.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ModlyCliError(f"workflow must be valid JSON: {candidate}: {exc}") from exc + if isinstance(data, dict): + return data + raise ModlyCliError(f"Could not find ComfyUI workflow '{workflow}'. Pass a JSON path or set COMFYUI_WORKFLOW_DIR.") + + +def _patch_comfy_workflow(workflow: dict[str, Any], *, prompt: str | None, seed: int | None) -> dict[str, Any]: + workflow = json.loads(json.dumps(workflow)) + nodes = workflow.get("prompt", workflow) + if not isinstance(nodes, dict): + raise ModlyCliError("ComfyUI workflow must be API format (top-level node-id object, or {'prompt': {...}})") + if "nodes" in workflow and "links" in workflow: + raise ModlyCliError("ComfyUI workflow is editor format; export it as API format first") + + if prompt is not None: + patched = False + for node in nodes.values(): + if not isinstance(node, dict): + continue + class_type = str(node.get("class_type", "")).lower() + inputs = node.get("inputs") if isinstance(node.get("inputs"), dict) else {} + text = str(inputs.get("text", "")).lower() + if "cliptextencode" in class_type and "negative" not in text: + inputs["text"] = prompt + patched = True + break + if not patched: + for node in nodes.values(): + if isinstance(node, dict) and isinstance(node.get("inputs"), dict): + inputs = node["inputs"] + for key in ("prompt", "positive", "text"): + if key in inputs and isinstance(inputs[key], str): + inputs[key] = prompt + patched = True + break + if patched: + break + if not patched: + raise ModlyCliError("Could not find a text/prompt input to patch in ComfyUI workflow") + + if seed is not None: + for node in nodes.values(): + if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict): + continue + inputs = node["inputs"] + for key in ("seed", "noise_seed"): + if key in inputs and isinstance(inputs[key], int): + inputs[key] = seed + return nodes + + +def _run_comfy_image(args: argparse.Namespace) -> dict[str, Any]: + host = args.comfy_url.rstrip("/") + workflow = _load_comfy_workflow(args.workflow, host=host, timeout=args.request_timeout) + prompt = getattr(args, "prompt", None) + seed = getattr(args, "seed", None) + graph = _patch_comfy_workflow(workflow, prompt=prompt, seed=seed) + payload = json.dumps({"prompt": graph, "client_id": "modly-cli"}).encode("utf-8") + queued = _request_json("POST", f"{host}/prompt", timeout=args.request_timeout, data=payload, headers={"Content-Type": "application/json"}) + prompt_id = queued.get("prompt_id") if isinstance(queued, dict) else None + if not prompt_id: + raise ModlyCliError(f"ComfyUI did not return prompt_id: {queued}") + + deadline = time.monotonic() + args.timeout + history: dict[str, Any] = {} + while time.monotonic() < deadline: + data = _request_json("GET", f"{host}/history/{urllib.parse.quote(str(prompt_id))}", timeout=args.request_timeout) + if isinstance(data, dict) and str(prompt_id) in data: + history = data[str(prompt_id)] if isinstance(data[str(prompt_id)], dict) else {"raw": data[str(prompt_id)]} + break + if getattr(args, "progress", False) and not getattr(args, "quiet", False): + print(json.dumps({"phase": "comfy", "prompt_id": prompt_id, "status": "running"}), file=sys.stderr) + time.sleep(args.poll) + if not history: + raise ModlyCliError(f"Timed out waiting for ComfyUI prompt {prompt_id}") + + outputs = history.get("outputs") if isinstance(history.get("outputs"), dict) else {} + image_ref = None + for node_output in outputs.values(): + if isinstance(node_output, dict) and node_output.get("images"): + images = node_output.get("images") + if isinstance(images, list) and images and isinstance(images[0], dict): + image_ref = images[0] + break + if not image_ref: + raise ModlyCliError(f"ComfyUI prompt {prompt_id} completed without an image output") + + out_path: Path | None = getattr(args, "comfy_output", None) + if out_path: + out = Path(out_path).expanduser().resolve() + else: + tmp = tempfile.NamedTemporaryFile(delete=False, prefix="modly-comfy-", suffix=".png") + tmp.close() + out = Path(tmp.name) + query = urllib.parse.urlencode({ + "filename": image_ref.get("filename", ""), + "subfolder": image_ref.get("subfolder", ""), + "type": image_ref.get("type", "output"), + }) + bytes_written = _download(f"{host}/view?{query}", out, timeout=args.request_timeout) + return {"ok": True, "comfy_url": host, "workflow": args.workflow, "prompt_id": str(prompt_id), "image_path": str(out), "bytes_written": bytes_written, "image": image_ref} + + +def cmd_comfy_image(args: argparse.Namespace) -> int: + result = _run_comfy_image(args) + _json_print(result, compact=args.compact) + return 0 + + +def cmd_generate_from_workflow(args: argparse.Namespace) -> int: + comfy = _run_comfy_image(args) + output = Path(args.output).expanduser().resolve() if args.output else None + result = _generate_one(args, Path(str(comfy["image_path"])), output) + result["source"] = "comfy-workflow" + result["comfy"] = comfy + _json_print(result, compact=args.compact) + return 0 + +def _run_generation_job( + args: argparse.Namespace, + image_path: Path, + *, + base_url: str, + model_id: str, + params: dict[str, Any], + progress_label: str, +) -> tuple[str, dict[str, Any], str]: + fields = { + "model_id": model_id, + "collection": args.collection, + "remesh": args.remesh, + "enable_texture": "true" if getattr(args, "enable_texture", True) else "false", + "texture_resolution": str(getattr(args, "texture_resolution", 1024)), + "params": json.dumps(params, separators=(",", ":")), + } + body, content_type = _multipart_form(fields, "image", image_path) + started = _request_json( + "POST", + f"{base_url}/generate/from-image", + timeout=args.request_timeout, + data=body, + headers={"Content-Type": content_type}, + ) + job_id = started.get("job_id") if isinstance(started, dict) else None + if not job_id: + raise ModlyCliError(f"Modly did not return a job_id: {started}") + + deadline = time.monotonic() + args.timeout + last_status: dict[str, Any] = {} + while time.monotonic() < deadline: + status = _request_json("GET", f"{base_url}/generate/status/{urllib.parse.quote(str(job_id))}", timeout=args.request_timeout) + last_status = status if isinstance(status, dict) else {"raw": status} + state = last_status.get("status") + if state == "done": + output_url = str(last_status.get("output_url") or "") + if not output_url: + raise ModlyCliError(f"Job completed without output_url: {last_status}") + return str(job_id), last_status, _workspace_relative_path(output_url) + if state in {"error", "cancelled"}: + raise ModlyCliError(f"Job {job_id} ended with status {state}: {last_status}") + if getattr(args, "progress", False) and not getattr(args, "quiet", False): + progress = last_status.get("progress", 0) + step = last_status.get("step", "") + print(json.dumps({"phase": progress_label, "job_id": job_id, "status": state, "progress": progress, "step": step}), file=sys.stderr) + time.sleep(args.poll) + + raise ModlyCliError(f"Timed out waiting for job {job_id}. Last status: {last_status}") + + +def _texture_model_id(args: argparse.Namespace, base_url: str) -> str | None: + requested = getattr(args, "texture_model", "auto") + if requested and requested != "auto": + return str(requested) + models = _request_json("GET", f"{base_url}/model/all", timeout=args.request_timeout) + if not isinstance(models, list): + return None + for model in models: + if not isinstance(model, dict): + continue + text = f"{model.get('id', '')} {model.get('name', '')}".lower() + if "refine" in text or "texture" in text: + return str(model.get("id")) + return None + + +def _generate_one(args: argparse.Namespace, image_path: Path, output_path: Path | None = None) -> dict[str, Any]: + base_url = args.base_url.rstrip("/") + image_path = image_path.expanduser().resolve() + if not image_path.exists() or not image_path.is_file(): + raise ModlyCliError(f"image file not found: {image_path}") + + params = _parse_params(getattr(args, "params_json", None), getattr(args, "params_file", None)) + model_id = _resolve_model_id(args, base_url) + job_id, status, rel_path = _run_generation_job( + args, + image_path, + base_url=base_url, + model_id=model_id, + params=params, + progress_label="generate", + ) + + texture_enabled = bool(getattr(args, "enable_texture", True)) + texture_job_id = None + texture_status = None + texture_model_id = None + geometry_workspace_path = rel_path + if texture_enabled and "refine" not in model_id.lower() and "texture" not in model_id.lower(): + texture_model_id = _texture_model_id(args, base_url) + if texture_model_id: + texture_params = _parse_params(getattr(args, "texture_params_json", None), getattr(args, "texture_params_file", None)) + texture_params.setdefault("mesh_path", rel_path) + texture_params.setdefault("texture_resolution", getattr(args, "texture_resolution", 1024)) + texture_params.setdefault("texture_size", getattr(args, "texture_size", 2048)) + texture_params.setdefault("texture_steps", getattr(args, "texture_steps", 30)) + texture_params.setdefault("texture_guidance", getattr(args, "texture_guidance", 3.0)) + texture_job_id, texture_status, rel_path = _run_generation_job( + args, + image_path, + base_url=base_url, + model_id=texture_model_id, + params=texture_params, + progress_label="texture", + ) + else: + texture_enabled = False + + export_dest = output_path or image_path.resolve().parent / f"{Path(rel_path).stem}.{args.format}" + export_dest = export_dest.expanduser().resolve() + bytes_written = _export_workspace_path(base_url, rel_path, args.format, export_dest, timeout=args.request_timeout) + return { + "ok": True, + "base_url": base_url, + "image": str(image_path), + "model_id": model_id, + "job_id": job_id, + "status": status, + "workspace_path": rel_path, + "geometry_workspace_path": geometry_workspace_path, + "texture_enabled": texture_enabled, + "texture_model_id": texture_model_id, + "texture_job_id": texture_job_id, + "texture_status": texture_status, + "export_format": args.format, + "export_path": str(export_dest), + "bytes_written": bytes_written, + } + + +def cmd_generate(args: argparse.Namespace) -> int: + output = Path(args.output).expanduser().resolve() if args.output else None + result = _generate_one(args, Path(args.image), output) + _json_print(result, compact=args.compact) + return 0 + + +def cmd_export(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + dest = Path(args.output).expanduser().resolve() + bytes_written = _export_workspace_path(base_url, args.path, args.format, dest, timeout=args.request_timeout) + _json_print({ + "ok": True, + "base_url": base_url, + "workspace_path": args.path, + "export_format": args.format, + "export_path": str(dest), + "bytes_written": bytes_written, + }, compact=args.compact) + return 0 + + +def _iter_images(input_dir: Path) -> list[Path]: + if not input_dir.exists() or not input_dir.is_dir(): + raise ModlyCliError(f"input directory not found: {input_dir}") + return sorted(p for p in input_dir.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES) + + +def _manifest_jobs(path: Path, fallback_output_dir: Path | None, default_format: str) -> list[tuple[Path, Path | None, str]]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ModlyCliError(f"manifest must be valid JSON: {exc}") from exc + entries = raw.get("jobs", raw.get("images")) if isinstance(raw, dict) else raw + if not isinstance(entries, list): + raise ModlyCliError("manifest must be a JSON list or object with a jobs/images list") + + jobs: list[tuple[Path, Path | None, str]] = [] + for index, entry in enumerate(entries): + if isinstance(entry, str): + image = Path(entry) + fmt = default_format + output = None + elif isinstance(entry, dict): + image_value = entry.get("image") or entry.get("image_path") or entry.get("path") + if not image_value: + raise ModlyCliError(f"manifest entry {index} is missing image") + image = Path(str(image_value)) + fmt = str(entry.get("format") or default_format) + if fmt not in EXPORT_FORMATS: + raise ModlyCliError(f"manifest entry {index} has unsupported format: {fmt}") + output = Path(str(entry["output"])) if entry.get("output") else None + else: + raise ModlyCliError(f"manifest entry {index} must be a string or object") + + if not image.is_absolute(): + image = path.parent / image + if output is None and fallback_output_dir is not None: + output = fallback_output_dir / f"{image.stem}.{fmt}" + elif output is not None and not output.is_absolute(): + output = path.parent / output + jobs.append((image, output, fmt)) + return jobs + + +def cmd_batch(args: argparse.Namespace) -> int: + output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else None + if output_dir: + output_dir.mkdir(parents=True, exist_ok=True) + if args.manifest: + jobs = _manifest_jobs(Path(args.manifest).expanduser().resolve(), output_dir, args.format) + else: + if not args.input_dir or output_dir is None: + raise ModlyCliError("batch requires --input-dir and --output-dir, or --manifest with per-entry outputs") + input_dir = Path(args.input_dir).expanduser().resolve() + jobs = [(image, output_dir / f"{image.stem}.{args.format}", args.format) for image in _iter_images(input_dir)] + + results: list[dict[str, Any]] = [] + failures = 0 + original_format = args.format + for image, output, fmt in jobs: + args.format = fmt + try: + results.append(_generate_one(args, image, output)) + except ModlyCliError as exc: + failures += 1 + results.append({"ok": False, "image": str(image), "error": str(exc)}) + if not args.continue_on_error: + break + args.format = original_format + _json_print({"ok": failures == 0, "count": len(results), "failures": failures, "results": results}, compact=args.compact) + return 0 if failures == 0 else 1 + + +def cmd_serve(args: argparse.Namespace) -> int: + api_dir, _python, env, cmd, base_url = _resolve_serve_config(args) + public_env = {k: env.get(k, "") for k in ["MODELS_DIR", "WORKSPACE_DIR", "EXTENSIONS_DIR", "SELECTED_MODEL_ID"]} + if args.print_command: + _json_print({"ok": True, "cmd": cmd, "cwd": str(api_dir), "base_url": base_url, "env": public_env}, compact=args.compact) + return 0 + proc = _start_backend(cmd, api_dir=api_dir, env=env, detach=args.detach) + if args.detach: + _json_print({"ok": True, "started": True, "pid": proc.pid, "base_url": base_url, "cmd": cmd, "cwd": str(api_dir), "env": public_env}, compact=args.compact) + return 0 + return int(proc.wait()) + + +def cmd_ensure_server(args: argparse.Namespace) -> int: + base_url = args.base_url.rstrip("/") + health = _try_health(base_url, args.request_timeout) + if health: + _json_print({"ok": True, "started": False, "base_url": base_url, "health": health}, compact=args.compact) + return 0 + if not args.start: + message = "Modly API is not running; launch Modly or run ensure-server --start" + if args.fail_on_unavailable: + raise ModlyCliError(message) + _json_print({"ok": False, "started": False, "base_url": base_url, "error": message}, compact=args.compact) + return 0 + api_dir, _python, env, cmd, resolved_url = _resolve_serve_config(args) + public_env = {k: env.get(k, "") for k in ["MODELS_DIR", "WORKSPACE_DIR", "EXTENSIONS_DIR", "SELECTED_MODEL_ID"]} + if args.print_command: + _json_print({"ok": True, "started": False, "would_start": True, "base_url": resolved_url, "cmd": cmd, "cwd": str(api_dir), "env": public_env}, compact=args.compact) + return 0 + proc = _start_backend(cmd, api_dir=api_dir, env=env, detach=args.detach) + _json_print({"ok": True, "started": True, "pid": proc.pid, "base_url": resolved_url, "cmd": cmd, "cwd": str(api_dir), "env": public_env}, compact=args.compact) + if not args.detach: + return int(proc.wait()) + return 0 + + +def _add_comfy_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--workflow", default="Trellis2Workflow", help="ComfyUI API-format workflow path or saved workflow name (default: Trellis2Workflow)") + parser.add_argument("--prompt", help="Prompt text to inject into the first positive text/prompt input") + parser.add_argument("--seed", type=int, help="Seed to inject into seed/noise_seed inputs") + parser.add_argument("--comfy-url", default=os.environ.get("COMFYUI_URL", "http://127.0.0.1:8188"), help="ComfyUI API URL (default: http://127.0.0.1:8188)") + parser.add_argument("--comfy-output", help="Where to save the ComfyUI image output before passing it to Modly") + + +def _add_generation_options(parser: argparse.ArgumentParser, *, image: bool, output: bool, batch: bool = False) -> None: + if image: + parser.add_argument("--image", required=True, help="Input image path") + if output: + parser.add_argument("--output", help="Export destination path. Defaults beside the input image") + parser.add_argument("--format", choices=EXPORT_FORMATS, default="glb", help="Export format (default: glb)") + parser.add_argument("--model", default="auto", help="Model id to use, 'active', or 'auto' (default: auto)") + parser.add_argument("--collection", default="Agent", help="Modly workspace collection (default: Agent)") + parser.add_argument("--remesh", choices=["quad", "triangle", "none"], default="quad", help="Remesh mode (default: quad)") + parser.add_argument("--texture", dest="enable_texture", action="store_true", default=True, help="Enable texture generation (default)") + parser.add_argument("--no-texture", dest="enable_texture", action="store_false", help="Disable texture generation for faster geometry-only smoke tests") + parser.add_argument("--enable-texture", dest="enable_texture", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--texture-resolution", type=int, default=1024, help="Texture diffusion resolution when texturing is enabled") + parser.add_argument("--texture-model", default="auto", help="Texture/refine model id, or 'auto' (default: auto)") + parser.add_argument("--texture-size", type=int, default=2048, help="Texture atlas size when texturing is enabled (default: 2048)") + parser.add_argument("--texture-steps", type=int, default=30, help="Texture diffusion steps when texturing is enabled (default: 30)") + parser.add_argument("--texture-guidance", type=float, default=3.0, help="Texture guidance strength when texturing is enabled (default: 3.0)") + parser.add_argument("--texture-params-json", help="Texture/refine params as a JSON object") + parser.add_argument("--texture-params-file", help="Path to texture/refine params JSON file") + parser.add_argument("--params-json", help="Model-specific params as a JSON object") + parser.add_argument("--params-file", help="Path to model-specific params JSON file") + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS, help=f"Generation timeout in seconds (default: {DEFAULT_TIMEOUT_SECONDS})") + parser.add_argument("--poll", type=float, default=DEFAULT_POLL_SECONDS, help=f"Polling interval in seconds (default: {DEFAULT_POLL_SECONDS})") + parser.add_argument("--progress", action="store_true", help="Emit progress JSON lines to stderr while waiting") + if batch: + parser.add_argument("--continue-on-error", action="store_true", help="Continue batch after an image fails") + + +def _add_serve_options(parser: argparse.ArgumentParser, *, include_start: bool = False) -> None: + if include_start: + parser.add_argument("--start", action="store_true", help="Start the backend if health check fails") + parser.add_argument("--api-dir", help="Directory containing Modly API main.py") + parser.add_argument("--python", help="Python executable with Modly API dependencies installed") + parser.add_argument("--host", default="127.0.0.1", help="Backend host (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8765, help="Backend port (default: 8765)") + parser.add_argument("--models-dir", help="Models directory for the backend") + parser.add_argument("--workspace-dir", help="Workspace directory for generated meshes") + parser.add_argument("--extensions-dir", help="Extensions directory for the backend") + parser.add_argument("--model", help="Initial SELECTED_MODEL_ID") + parser.add_argument("--hf-token", help="Hugging Face token for gated models") + parser.add_argument("--detach", action="store_true", help="Start in background and print pid") + parser.add_argument("--print-command", action="store_true", help="Print resolved command/env without starting") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="modly-cli", + description="Tiny stdlib-only CLI for agents calling a running Modly desktop API.", + ) + parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help=f"Modly API URL (default: {DEFAULT_BASE_URL})") + parser.add_argument("--request-timeout", type=float, default=30, help="Per-request timeout in seconds (default: 30)") + parser.add_argument("--compact", action="store_true", help="Print compact one-line JSON") + parser.add_argument("--quiet", action="store_true", help="Suppress progress output; final JSON is still printed") + sub = parser.add_subparsers(dest="command", required=True) + + health = sub.add_parser("health", help="Check that Modly's local API is reachable") + health.set_defaults(func=cmd_health) + + status = sub.add_parser("status", help="Show API health and active model status") + status.set_defaults(func=cmd_status) + + models = sub.add_parser("models", help="List installed/available model adapters") + models.set_defaults(func=cmd_models) + + params = sub.add_parser("params", help="Show model parameter schema") + params.add_argument("--model", default="auto", help="Model id, 'active', or 'auto' (default: auto)") + params.set_defaults(func=cmd_params) + + job = sub.add_parser("job", help="Show one generation job status") + job.add_argument("job_id") + job.set_defaults(func=cmd_job) + + cancel = sub.add_parser("cancel", help="Cancel one generation job") + cancel.add_argument("job_id") + cancel.set_defaults(func=cmd_cancel) + + gen = sub.add_parser("generate", help="Generate a 3D mesh from an image, wait, export it, and print JSON") + _add_generation_options(gen, image=True, output=True) + gen.set_defaults(func=cmd_generate) + + comfy = sub.add_parser("comfy-image", help="Run a preconfigured ComfyUI workflow and save its first image output") + _add_comfy_options(comfy) + comfy.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS, help=f"ComfyUI timeout in seconds (default: {DEFAULT_TIMEOUT_SECONDS})") + comfy.add_argument("--poll", type=float, default=DEFAULT_POLL_SECONDS, help=f"Polling interval in seconds (default: {DEFAULT_POLL_SECONDS})") + comfy.add_argument("--progress", action="store_true", help="Emit progress JSON lines to stderr while waiting") + comfy.set_defaults(func=cmd_comfy_image) + + wf = sub.add_parser("generate-from-workflow", help="Run a ComfyUI workflow (default: Trellis2Workflow), feed its image output into Modly, and export the mesh") + _add_comfy_options(wf) + _add_generation_options(wf, image=False, output=True) + wf.set_defaults(func=cmd_generate_from_workflow) + + exp = sub.add_parser("export", help="Export an existing workspace mesh path") + exp.add_argument("--path", required=True, help="Workspace-relative mesh path, e.g. Agent/foo.glb") + exp.add_argument("--output", required=True, help="Destination file path") + exp.add_argument("--format", choices=EXPORT_FORMATS, default="glb", help="Export format (default: glb)") + exp.set_defaults(func=cmd_export) + + batch = sub.add_parser("batch", help="Generate meshes sequentially from an image directory or manifest JSON") + group = batch.add_mutually_exclusive_group(required=True) + group.add_argument("--input-dir", help="Directory of .png/.jpg/.jpeg/.webp images") + group.add_argument("--manifest", help="JSON list or object with jobs/images entries") + batch.add_argument("--output-dir", help="Directory for exported meshes; required for --input-dir") + _add_generation_options(batch, image=False, output=False, batch=True) + batch.set_defaults(func=cmd_batch) + + serve = sub.add_parser("serve", help="Start Modly FastAPI backend without Electron UI") + _add_serve_options(serve) + serve.set_defaults(func=cmd_serve) + + ensure = sub.add_parser("ensure-server", help="Check API health and optionally start headless backend") + _add_serve_options(ensure, include_start=True) + ensure.add_argument("--fail-on-unavailable", action="store_true", help="Exit nonzero when API is unavailable") + ensure.set_defaults(func=cmd_ensure_server) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = None + try: + args = parser.parse_args(argv) + return int(args.func(args)) + except ModlyCliError as exc: + _json_print({"ok": False, "error": str(exc)}, compact=getattr(args, "compact", False) if args else False) + return 1 + except KeyboardInterrupt: + _json_print({"ok": False, "error": "interrupted"}, compact=getattr(args, "compact", False) if args else False) + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/modly-cli/test_agent.py b/tools/modly-cli/test_agent.py new file mode 100644 index 0000000..452253c --- /dev/null +++ b/tools/modly-cli/test_agent.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Unit tests for the stdlib-only Modly agent CLI.""" +from __future__ import annotations + +import importlib.util +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +MODULE_PATH = Path(__file__).with_name("agent.py") +SPEC = importlib.util.spec_from_file_location("modly_agent", MODULE_PATH) +agent = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(agent) + + +class OutputTests(unittest.TestCase): + def test_compact_json_is_one_line(self) -> None: + buf = io.StringIO() + with redirect_stdout(buf): + agent._json_print({"ok": True, "nested": {"x": 1}}, compact=True) + self.assertEqual(buf.getvalue(), '{"nested":{"x":1},"ok":true}\n') + + +class CommandTests(unittest.TestCase): + def test_status_combines_health_and_model(self) -> None: + calls: list[tuple[str, str]] = [] + + def fake_request(method: str, url: str, *, timeout: float, **_: object) -> object: + calls.append((method, url)) + if url.endswith("/health"): + return {"status": "ok"} + if url.endswith("/model/status"): + return {"id": "sf3d", "loaded": True} + raise AssertionError(url) + + args = SimpleNamespace(base_url="http://example.test/", request_timeout=1, compact=True) + buf = io.StringIO() + with patch.object(agent, "_request_json", fake_request), redirect_stdout(buf): + self.assertEqual(agent.cmd_status(args), 0) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload["health"], {"status": "ok"}) + self.assertEqual(payload["model"]["id"], "sf3d") + self.assertEqual(calls, [("GET", "http://example.test/health"), ("GET", "http://example.test/model/status")]) + + def test_params_auto_resolves_model_id(self) -> None: + def fake_request(method: str, url: str, *, timeout: float, **_: object) -> object: + if url.endswith("/model/all"): + return [{"id": "sf3d/generate", "name": "Generate", "active": False}] + if url.endswith("/model/params?model_id=sf3d%2Fgenerate"): + return [{"name": "foreground_ratio"}] + raise AssertionError(url) + + args = SimpleNamespace(base_url="http://example.test", request_timeout=1, model="auto", compact=False) + buf = io.StringIO() + with patch.object(agent, "_request_json", fake_request), redirect_stdout(buf): + self.assertEqual(agent.cmd_params(args), 0) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload["model_id"], "sf3d/generate") + self.assertEqual(payload["params"][0]["name"], "foreground_ratio") + + def test_export_downloads_workspace_path(self) -> None: + with tempfile.TemporaryDirectory() as td: + out = Path(td) / "mesh.glb" + args = SimpleNamespace(base_url="http://example.test", request_timeout=1, path="Agent/foo.glb", output=str(out), format="glb", compact=True) + with patch.object(agent, "_download", return_value=123) as download, redirect_stdout(io.StringIO()) as buf: + self.assertEqual(agent.cmd_export(args), 0) + download.assert_called_once() + self.assertIn("/export/glb?path=Agent%2Ffoo.glb", download.call_args.args[0]) + self.assertEqual(json.loads(buf.getvalue())["bytes_written"], 123) + + def test_generate_enables_texture_by_default(self) -> None: + with tempfile.TemporaryDirectory() as td: + image = Path(td) / "robot.png" + output = Path(td) / "robot.glb" + image.write_bytes(b"png") + args = SimpleNamespace( + base_url="http://example.test", + request_timeout=1, + image=str(image), + output=str(output), + format="glb", + model="sf3d", + collection="Agent", + remesh="quad", + enable_texture=True, + texture_resolution=1024, + params_json=None, + params_file=None, + timeout=10, + poll=0, + progress=False, + quiet=True, + ) + bodies: list[bytes] = [] + + def fake_request(method: str, url: str, *, timeout: float, data: bytes | None = None, **_: object) -> object: + if url.endswith("/generate/from-image"): + self.assertEqual(method, "POST") + assert data is not None + bodies.append(data) + return {"job_id": "job-1"} + if url.endswith("/generate/status/job-1"): + return {"status": "done", "progress": 100, "output_url": "/workspace/Agent/robot.glb"} + raise AssertionError(url) + + with patch.object(agent, "_request_json", fake_request), patch.object(agent, "_export_workspace_path", return_value=456), patch.object(agent, "_texture_model_id", return_value=None): + result = agent._generate_one(args, image, output) + self.assertFalse(result["texture_enabled"]) + self.assertIn(b'name="enable_texture"\r\n\r\ntrue', bodies[0]) + + def test_batch_processes_images_sequentially(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + inputs = root / "inputs" + outputs = root / "outputs" + inputs.mkdir() + (inputs / "b.jpg").write_bytes(b"jpg") + (inputs / "a.png").write_bytes(b"png") + (inputs / "ignore.txt").write_text("no") + args = SimpleNamespace(input_dir=str(inputs), manifest=None, output_dir=str(outputs), format="glb", compact=False, continue_on_error=False) + + def fake_generate(_args: object, image: Path, output: Path | None = None) -> dict[str, object]: + return {"ok": True, "image": str(image), "export_path": str(output)} + + with patch.object(agent, "_generate_one", fake_generate), redirect_stdout(io.StringIO()) as buf: + self.assertEqual(agent.cmd_batch(args), 0) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload["count"], 2) + self.assertEqual([Path(r["image"]).name for r in payload["results"]], ["a.png", "b.jpg"]) + + def test_batch_accepts_manifest_json(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + image = root / "robot.png" + output = root / "robot.stl" + manifest = root / "jobs.json" + image.write_bytes(b"png") + manifest.write_text(json.dumps({"jobs": [{"image": "robot.png", "output": "robot.stl", "format": "stl"}]}), encoding="utf-8") + args = SimpleNamespace(input_dir=None, manifest=str(manifest), output_dir=None, format="glb", compact=True, continue_on_error=False) + + def fake_generate(_args: object, image_path: Path, output_path: Path | None = None) -> dict[str, object]: + self.assertEqual(_args.format, "stl") + return {"ok": True, "image": str(image_path), "export_path": str(output_path)} + + with patch.object(agent, "_generate_one", fake_generate), redirect_stdout(io.StringIO()) as buf: + self.assertEqual(agent.cmd_batch(args), 0) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload["results"][0]["image"], str(image)) + self.assertEqual(payload["results"][0]["export_path"], str(output)) + + +class ComfyWorkflowTests(unittest.TestCase): + def test_patch_positive_cliptextencode(self) -> None: + workflow = { + "1": {"class_type": "CLIPTextEncode", "inputs": {"text": "old prompt", "clip": ["4", 1]}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": "negative", "clip": ["4", 1]}}, + } + result = agent._patch_comfy_workflow(workflow, prompt="new prompt", seed=42) + self.assertEqual(result["1"]["inputs"]["text"], "new prompt") + self.assertEqual(result["2"]["inputs"]["text"], "negative") + + def test_patch_raises_when_no_text_input(self) -> None: + workflow = { + "1": {"class_type": "LoadImage", "inputs": {"image": "photo.png"}}, + } + with self.assertRaises(agent.ModlyCliError): + agent._patch_comfy_workflow(workflow, prompt="good", seed=None) + + def test_patch_fallback_to_prompt_key(self) -> None: + workflow = { + "1": {"class_type": "KSampler", "inputs": {"prompt": "old", "seed": 0}}, + } + result = agent._patch_comfy_workflow(workflow, prompt="new", seed=99) + self.assertEqual(result["1"]["inputs"]["prompt"], "new") + self.assertEqual(result["1"]["inputs"]["seed"], 99) + + def test_patch_seed_noise_seed(self) -> None: + workflow = { + "1": {"class_type": "KSampler", "inputs": {"noise_seed": 0, "prompt": "test"}}, + } + result = agent._patch_comfy_workflow(workflow, prompt=None, seed=7) + self.assertEqual(result["1"]["inputs"]["noise_seed"], 7) + + def test_rejects_editor_format(self) -> None: + workflow = {"nodes": [], "links": []} + with self.assertRaises(agent.ModlyCliError): + agent._patch_comfy_workflow(workflow, prompt="x", seed=None) + + def test_prompt_wrapper(self) -> None: + workflow = {"prompt": {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "old"}}}} + result = agent._patch_comfy_workflow(workflow, prompt="new", seed=None) + self.assertEqual(result["1"]["inputs"]["text"], "new") + + def test_deep_copy_no_mutation(self) -> None: + original = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "old"}}} + agent._patch_comfy_workflow(original, prompt="new", seed=None) + self.assertEqual(original["1"]["inputs"]["text"], "old") + + +class ServeConfigTests(unittest.TestCase): + def test_default_api_dir_checks_all_windows_localappdata_candidates(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + repo = root / "repo" + repo.mkdir() + bad_local = root / "Default" / "AppData" / "Local" + good_api = root / "joshu" / "AppData" / "Local" / "Programs" / "Modly" / "resources" / "api" + good_api.mkdir(parents=True) + (good_api / "main.py").write_text("# api", encoding="utf-8") + + with patch.object(agent, "_repo_root", return_value=repo), patch.object(agent, "_windows_env_paths", return_value=[bad_local, good_api.parents[3]]): + self.assertEqual(agent._default_api_dir(), good_api) + + def test_load_modly_settings_checks_all_windows_appdata_candidates(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + bad_roaming = root / "Default" / "AppData" / "Roaming" + good_roaming = root / "joshu" / "AppData" / "Roaming" + settings = good_roaming / "Modly" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({"workspaceDir": "C:/workspace"}), encoding="utf-8") + + with patch.object(agent, "_windows_env_paths", return_value=[bad_roaming, good_roaming]): + self.assertEqual(agent._load_modly_settings()["workspaceDir"], "C:/workspace") + + def test_resolve_serve_config_explicit_paths(self) -> None: + with tempfile.TemporaryDirectory() as td: + api_dir = Path(td) / "api" + api_dir.mkdir() + (api_dir / "main.py").write_text("# api") + python = Path(td) / "python.exe" + python.write_text("# python") + args = SimpleNamespace( + api_dir=str(api_dir), + python=str(python), + host="0.0.0.0", + port=9999, + models_dir=None, + workspace_dir=None, + extensions_dir=None, + model=None, + hf_token=None, + ) + _api_dir, _python, env, cmd, base_url = agent._resolve_serve_config(args) + self.assertEqual(str(_api_dir), str(api_dir.resolve())) + self.assertEqual(base_url, "http://0.0.0.0:9999") + self.assertTrue(cmd[0].endswith("python.exe")) + self.assertIn("PYTHONUNBUFFERED", env) + + +class ParserTests(unittest.TestCase): + def test_new_subcommands_parse(self) -> None: + parser = agent.build_parser() + cases = [ + ["status"], + ["params"], + ["job", "abc"], + ["cancel", "abc"], + ["comfy-image"], + ["generate-from-workflow", "--prompt", "asset", "--output", "asset.glb"], + ["export", "--path", "Agent/foo.glb", "--output", "foo.glb"], + ["batch", "--input-dir", "imgs", "--output-dir", "meshes"], + ["serve", "--print-command"], + ["ensure-server"], + ] + for argv in cases: + with self.subTest(argv=argv): + args = parser.parse_args(argv) + self.assertTrue(callable(args.func)) + + +if __name__ == "__main__": + unittest.main()