diff --git a/.github/assets/social-preview/social-preview.png b/.github/assets/social-preview/social-preview.png new file mode 100644 index 0000000..1bd2f6d Binary files /dev/null and b/.github/assets/social-preview/social-preview.png differ diff --git a/.github/brand/brand.lock.json b/.github/brand/brand.lock.json deleted file mode 100644 index e6f72db..0000000 --- a/.github/brand/brand.lock.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "schema_version": 2, - "authority": "https://github.com/computer-mcp/.github", - "revision": "60eb771e701716094cbf81146e07e5856d83572aec624a7ef5c7750be32acc41", - "consumer": ".github", - "files": { - "profile/brand/header-light.png": "b60214fdb6aef03b13bbd46bbbfebbde43b8c74eeb3825b300013e17d19494f5", - "profile/brand/header-dark.png": "3a0be729610516dfcd3e882e2c0af351eda5d02dcc83ec12c970f53bcd875355", - "profile/brand/avatar.png": "34ce8f1feb34c6f19f9927679fc3b6532bfaa3e884920d9374d61b70d54d9134" - }, - "entries": { - "profile/README.md": [ - "Computer MCP", - "brand/header-light.png", - "brand/header-dark.png", - "Wherever you chat, your computer is there." - ] - } -} diff --git a/.github/design/fonts/Inter-OFL.txt b/.github/design/fonts/Inter-OFL.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/.github/design/fonts/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/.github/design/fonts/Inter[opsz,wght].ttf b/.github/design/fonts/Inter[opsz,wght].ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/.github/design/fonts/Inter[opsz,wght].ttf differ diff --git a/.github/design/fonts/JetBrainsMono-OFL.txt b/.github/design/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..821a3da --- /dev/null +++ b/.github/design/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/.github/design/fonts/JetBrainsMono[wght].ttf b/.github/design/fonts/JetBrainsMono[wght].ttf new file mode 100644 index 0000000..aa310be Binary files /dev/null and b/.github/design/fonts/JetBrainsMono[wght].ttf differ diff --git a/Brand/app-icon/layers/apple-cli-calendar.svg b/.github/design/icons/apple-cli/layers/calendar.svg similarity index 100% rename from Brand/app-icon/layers/apple-cli-calendar.svg rename to .github/design/icons/apple-cli/layers/calendar.svg diff --git a/Brand/app-icon/layers/apple-cli-mail.svg b/.github/design/icons/apple-cli/layers/mail.svg similarity index 100% rename from Brand/app-icon/layers/apple-cli-mail.svg rename to .github/design/icons/apple-cli/layers/mail.svg diff --git a/Brand/app-icon/layers/apple-cli-notes.svg b/.github/design/icons/apple-cli/layers/notes.svg similarity index 100% rename from Brand/app-icon/layers/apple-cli-notes.svg rename to .github/design/icons/apple-cli/layers/notes.svg diff --git a/Brand/app-icon/layers/apple-cli-prompt.svg b/.github/design/icons/apple-cli/layers/prompt.svg similarity index 100% rename from Brand/app-icon/layers/apple-cli-prompt.svg rename to .github/design/icons/apple-cli/layers/prompt.svg diff --git a/Brand/app-icon/layers/apple-cli-terminal.svg b/.github/design/icons/apple-cli/layers/terminal.svg similarity index 100% rename from Brand/app-icon/layers/apple-cli-terminal.svg rename to .github/design/icons/apple-cli/layers/terminal.svg diff --git a/.github/design/icons/apple-cli/provenance.json b/.github/design/icons/apple-cli/provenance.json new file mode 100644 index 0000000..4d10f94 --- /dev/null +++ b/.github/design/icons/apple-cli/provenance.json @@ -0,0 +1,28 @@ +{ + "icon": "apple-cli", + "skill": "apple-app-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-app-icon-design/scripts/app_icons.py DESIGN.md --name apple-cli --style liquid-glass --out ", + "renderer": "Icon Composer ictool 27.0 (129)", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/apple-cli", + "path": ".github/assets/logo/logo-light.png", + "output": "renditions/macOS-Default.png", + "sha256": "6648ff1ab87e66f4bc727e8865195aacd9e49e619f5af4a7366fbbc7907bdaf2" + }, + { + "repository": "computer-mcp/apple-cli", + "path": ".github/assets/logo/logo-dark.png", + "output": "renditions/macOS-Dark.png", + "sha256": "5396c2bc7dd611ac8427b52f259d63fa16973ddadc3f8a75fdbae0926e14957d" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/icons/apple-cli.svg", + "output": "web/favicon.svg", + "sha256": "0e9cfd1e5abf33ca3a50a7a64e5a3cf7382505ff2cac23d7743f0a4a70a3a559" + } + ] +} diff --git a/Brand/app-icon/layers/crt-base.svg b/.github/design/icons/computer-mcp/layers/crt-base.svg similarity index 100% rename from Brand/app-icon/layers/crt-base.svg rename to .github/design/icons/computer-mcp/layers/crt-base.svg diff --git a/Brand/app-icon/layers/crt-body.svg b/.github/design/icons/computer-mcp/layers/crt-body.svg similarity index 100% rename from Brand/app-icon/layers/crt-body.svg rename to .github/design/icons/computer-mcp/layers/crt-body.svg diff --git a/Brand/app-icon/layers/crt-light.svg b/.github/design/icons/computer-mcp/layers/crt-light.svg similarity index 100% rename from Brand/app-icon/layers/crt-light.svg rename to .github/design/icons/computer-mcp/layers/crt-light.svg diff --git a/Brand/app-icon/layers/grid.svg b/.github/design/icons/computer-mcp/layers/grid.svg similarity index 100% rename from Brand/app-icon/layers/grid.svg rename to .github/design/icons/computer-mcp/layers/grid.svg diff --git a/.github/design/icons/computer-mcp/provenance.json b/.github/design/icons/computer-mcp/provenance.json new file mode 100644 index 0000000..5e7a59d --- /dev/null +++ b/.github/design/icons/computer-mcp/provenance.json @@ -0,0 +1,118 @@ +{ + "icon": "computer-mcp", + "skill": "apple-app-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-app-icon-design/scripts/app_icons.py DESIGN.md --name computer-mcp --style liquid-glass --out ", + "renderer": "Icon Composer ictool 27.0 (129)", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/.github", + "path": "profile/assets/mark-light.png", + "output": "renditions/macOS-Default.png", + "sha256": "71cbd902b28a083e7726abfa5d2fe506c860b5d954fd398686af1478e6250e80" + }, + { + "repository": "computer-mcp/.github", + "path": "profile/assets/mark-dark.png", + "output": "renditions/macOS-Dark.png", + "sha256": "0176b4ea92d4df926edbb9bc5c47e706186a05c23264806a295e31675c68c77e" + }, + { + "repository": "computer-mcp/.github", + "path": "profile/assets/avatar.png", + "output": "web/avatar-1024.png", + "sha256": "34ce8f1feb34c6f19f9927679fc3b6532bfaa3e884920d9374d61b70d54d9134" + }, + { + "repository": "computer-mcp/computer-mcp", + "path": ".github/assets/logo/logo-light.png", + "output": "renditions/macOS-Default.png", + "sha256": "71cbd902b28a083e7726abfa5d2fe506c860b5d954fd398686af1478e6250e80" + }, + { + "repository": "computer-mcp/computer-mcp", + "path": ".github/assets/logo/logo-dark.png", + "output": "renditions/macOS-Dark.png", + "sha256": "0176b4ea92d4df926edbb9bc5c47e706186a05c23264806a295e31675c68c77e" + }, + { + "repository": "computer-mcp/homebrew-tap", + "path": ".github/assets/logo/logo-light.png", + "output": "renditions/macOS-Default.png", + "sha256": "71cbd902b28a083e7726abfa5d2fe506c860b5d954fd398686af1478e6250e80" + }, + { + "repository": "computer-mcp/homebrew-tap", + "path": ".github/assets/logo/logo-dark.png", + "output": "renditions/macOS-Dark.png", + "sha256": "0176b4ea92d4df926edbb9bc5c47e706186a05c23264806a295e31675c68c77e" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": ".github/assets/logo/logo-light.png", + "output": "renditions/macOS-Default.png", + "sha256": "71cbd902b28a083e7726abfa5d2fe506c860b5d954fd398686af1478e6250e80" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": ".github/assets/logo/logo-dark.png", + "output": "renditions/macOS-Dark.png", + "sha256": "0176b4ea92d4df926edbb9bc5c47e706186a05c23264806a295e31675c68c77e" + }, + { + "repository": "computer-mcp/computer-mcp", + "path": "Resources/ComputerMCPApp/AppIcon.icon/Assets/01-01.svg", + "output": "computer-mcp.icon/Assets/01-01.svg", + "sha256": "5695b3bb0134f47ca6950438df98dce38c92591b57af14ff6114135e857b9d0c" + }, + { + "repository": "computer-mcp/computer-mcp", + "path": "Resources/ComputerMCPApp/AppIcon.icon/Assets/02-01.svg", + "output": "computer-mcp.icon/Assets/02-01.svg", + "sha256": "ab43ecd6e819f7f8713ddf9deecf02198c81d6ebcd0f1b86d4f159db506f98c7" + }, + { + "repository": "computer-mcp/computer-mcp", + "path": "Resources/ComputerMCPApp/AppIcon.icon/Assets/03-01.svg", + "output": "computer-mcp.icon/Assets/03-01.svg", + "sha256": "497967f011f5147ccf039ff5fee3993764aad2c45746ce411f5058a392ca65a4" + }, + { + "repository": "computer-mcp/computer-mcp", + "path": "Resources/ComputerMCPApp/AppIcon.icon/Assets/04-01.svg", + "output": "computer-mcp.icon/Assets/04-01.svg", + "sha256": "1323e36e89c20fce714888872965af41b2f5a17738d7a49aa69a198b95d9af54" + }, + { + "repository": "computer-mcp/computer-mcp", + "path": "Resources/ComputerMCPApp/AppIcon.icon/icon.json", + "output": "computer-mcp.icon/icon.json", + "sha256": "5daf706ea7f4aba95985a2efc9a2cf1eeea797d4ef59db1c20920e61db10a2dd" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/favicon.svg", + "output": "web/favicon.svg", + "sha256": "67d61a4dd2abed510df22cb3165427bca3f00ece110ec889ef7136a951d1bb94" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/icon.svg", + "output": "web/favicon.svg", + "sha256": "67d61a4dd2abed510df22cb3165427bca3f00ece110ec889ef7136a951d1bb94" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/favicon-32.png", + "output": "web/favicon-32.png", + "sha256": "8897fa0aac13eb5719376881018579175d3536f0c6e6a21f617049cf8e435156" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/apple-touch-icon.png", + "output": "web/apple-touch-icon.png", + "sha256": "a03354d9ef09076806c88e46acef5ecee3739e0e20137ff8741c57df68cd6c92" + } + ] +} diff --git a/.github/design/icons/github-workflows/provenance.json b/.github/design/icons/github-workflows/provenance.json new file mode 100644 index 0000000..4a52291 --- /dev/null +++ b/.github/design/icons/github-workflows/provenance.json @@ -0,0 +1,22 @@ +{ + "icon": "github-workflows", + "skill": "apple-framework-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-framework-icon-design/scripts/design_icons.py DESIGN.md --out --png", + "renderer": "design_icons.py with rsvg-convert version 2.63.2", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/github-workflows", + "path": ".github/assets/logo/logo-light.png", + "output": "github-workflows.png", + "sha256": "2841cef0438dd244b341ce5af5b355d15b25b1b23be21d4a93c331d1bacfdd4e" + }, + { + "repository": "computer-mcp/github-workflows", + "path": ".github/assets/logo/logo-dark.png", + "output": "github-workflows.png", + "sha256": "2841cef0438dd244b341ce5af5b355d15b25b1b23be21d4a93c331d1bacfdd4e" + } + ] +} diff --git a/.github/design/icons/plugin-claude/provenance.json b/.github/design/icons/plugin-claude/provenance.json new file mode 100644 index 0000000..bd41879 --- /dev/null +++ b/.github/design/icons/plugin-claude/provenance.json @@ -0,0 +1,28 @@ +{ + "icon": "plugin-claude", + "skill": "apple-framework-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-framework-icon-design/scripts/design_icons.py DESIGN.md --out --png", + "renderer": "design_icons.py with rsvg-convert version 2.63.2", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/plugin-claude", + "path": ".github/assets/logo/logo-light.png", + "output": "plugin-claude.png", + "sha256": "551f053dedbab321153824b8862f4b4ed3a3270fd86d6a04332e0f9f17058433" + }, + { + "repository": "computer-mcp/plugin-claude", + "path": ".github/assets/logo/logo-dark.png", + "output": "plugin-claude.png", + "sha256": "551f053dedbab321153824b8862f4b4ed3a3270fd86d6a04332e0f9f17058433" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/icons/plugin-claude.svg", + "output": "plugin-claude.svg", + "sha256": "d2e451cb368fbe69066c6c9afd377a50b37420807029011eeb52f9d45767377d" + } + ] +} diff --git a/.github/design/icons/plugin-codex/provenance.json b/.github/design/icons/plugin-codex/provenance.json new file mode 100644 index 0000000..d16f7b1 --- /dev/null +++ b/.github/design/icons/plugin-codex/provenance.json @@ -0,0 +1,28 @@ +{ + "icon": "plugin-codex", + "skill": "apple-framework-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-framework-icon-design/scripts/design_icons.py DESIGN.md --out --png", + "renderer": "design_icons.py with rsvg-convert version 2.63.2", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/plugin-codex", + "path": ".github/assets/logo/logo-light.png", + "output": "plugin-codex.png", + "sha256": "77b6b77fb6f22573201d621694cbb78c023f61a7be36ad31a154fce87939ea7e" + }, + { + "repository": "computer-mcp/plugin-codex", + "path": ".github/assets/logo/logo-dark.png", + "output": "plugin-codex.png", + "sha256": "77b6b77fb6f22573201d621694cbb78c023f61a7be36ad31a154fce87939ea7e" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/icons/plugin-codex.svg", + "output": "plugin-codex.svg", + "sha256": "3dad79ae64db570ebc68a27edb54da16e4810c9c8239eb1ec334f46671f80d91" + } + ] +} diff --git a/.github/design/icons/plugin-computer-use/provenance.json b/.github/design/icons/plugin-computer-use/provenance.json new file mode 100644 index 0000000..b8dccae --- /dev/null +++ b/.github/design/icons/plugin-computer-use/provenance.json @@ -0,0 +1,28 @@ +{ + "icon": "plugin-computer-use", + "skill": "apple-framework-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-framework-icon-design/scripts/design_icons.py DESIGN.md --out --png", + "renderer": "design_icons.py with rsvg-convert version 2.63.2", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/plugin-computer-use", + "path": ".github/assets/logo/logo-light.png", + "output": "plugin-computer-use.png", + "sha256": "bac0290aabba3eb85c39529887c6351125a229e9f3eded0df82c36ed552e0ed0" + }, + { + "repository": "computer-mcp/plugin-computer-use", + "path": ".github/assets/logo/logo-dark.png", + "output": "plugin-computer-use.png", + "sha256": "bac0290aabba3eb85c39529887c6351125a229e9f3eded0df82c36ed552e0ed0" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/icons/plugin-computer-use.svg", + "output": "plugin-computer-use.svg", + "sha256": "f12762274e9a246cd8e0961480ab3a942516306715393313d6d22c77374c0b3d" + } + ] +} diff --git a/.github/design/icons/plugin-cursor/provenance.json b/.github/design/icons/plugin-cursor/provenance.json new file mode 100644 index 0000000..d8d9eff --- /dev/null +++ b/.github/design/icons/plugin-cursor/provenance.json @@ -0,0 +1,28 @@ +{ + "icon": "plugin-cursor", + "skill": "apple-framework-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-framework-icon-design/scripts/design_icons.py DESIGN.md --out --png", + "renderer": "design_icons.py with rsvg-convert version 2.63.2", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/plugin-cursor", + "path": ".github/assets/logo/logo-light.png", + "output": "plugin-cursor.png", + "sha256": "fa744d0cf8dfa6736eba8b6bab50f3b5761f2d50a96a424eb4b6ed684cf8ecd3" + }, + { + "repository": "computer-mcp/plugin-cursor", + "path": ".github/assets/logo/logo-dark.png", + "output": "plugin-cursor.png", + "sha256": "fa744d0cf8dfa6736eba8b6bab50f3b5761f2d50a96a424eb4b6ed684cf8ecd3" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/icons/plugin-cursor.svg", + "output": "plugin-cursor.svg", + "sha256": "bfd9a052e702eb110e68b3cac73c6604dbe1e6727d3aafe3462666ea75d2dea1" + } + ] +} diff --git a/.github/design/icons/plugin-swift-format/provenance.json b/.github/design/icons/plugin-swift-format/provenance.json new file mode 100644 index 0000000..b018f0c --- /dev/null +++ b/.github/design/icons/plugin-swift-format/provenance.json @@ -0,0 +1,28 @@ +{ + "icon": "plugin-swift-format", + "skill": "apple-framework-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-framework-icon-design/scripts/design_icons.py DESIGN.md --out --png", + "renderer": "design_icons.py with rsvg-convert version 2.63.2", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/plugin-swift-format", + "path": ".github/assets/logo/logo-light.png", + "output": "plugin-swift-format.png", + "sha256": "43c0a6a3087f1c013659be0a882cbca162a594d20e42b2d3b78b430565821fc2" + }, + { + "repository": "computer-mcp/plugin-swift-format", + "path": ".github/assets/logo/logo-dark.png", + "output": "plugin-swift-format.png", + "sha256": "43c0a6a3087f1c013659be0a882cbca162a594d20e42b2d3b78b430565821fc2" + }, + { + "repository": "computer-mcp/computer-mcp.github.io", + "path": "public/brand/icons/plugin-swift-format.svg", + "output": "plugin-swift-format.svg", + "sha256": "a8f315bc88ece6cfffd5baa4cba8c1b21f78ec6f0b6a7ee0093b2f8fc01332c3" + } + ] +} diff --git a/.github/design/icons/plugin-trae/provenance.json b/.github/design/icons/plugin-trae/provenance.json new file mode 100644 index 0000000..6844442 --- /dev/null +++ b/.github/design/icons/plugin-trae/provenance.json @@ -0,0 +1,22 @@ +{ + "icon": "plugin-trae", + "skill": "apple-framework-icon-design", + "skill_commit": "36707036061a2d0ba012184ef0b6d6820082127f", + "command": "python3 skills/apple-framework-icon-design/scripts/design_icons.py DESIGN.md --out --png", + "renderer": "design_icons.py with rsvg-convert version 2.63.2", + "verified_at": "2026-10-23", + "copies": [ + { + "repository": "computer-mcp/plugin-trae", + "path": ".github/assets/logo/logo-light.png", + "output": "plugin-trae.png", + "sha256": "5a822a93923a6c80dcd104a25ab579652078a6782a7c6891d587a0a39d8b23f7" + }, + { + "repository": "computer-mcp/plugin-trae", + "path": ".github/assets/logo/logo-dark.png", + "output": "plugin-trae.png", + "sha256": "5a822a93923a6c80dcd104a25ab579652078a6782a7c6891d587a0a39d8b23f7" + } + ] +} diff --git a/.github/workflows/brand-check.yml b/.github/workflows/brand-check.yml deleted file mode 100644 index 2d3dafd..0000000 --- a/.github/workflows/brand-check.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Brand -on: - workflow_call: -permissions: - contents: read -jobs: - brand: - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Verify locked brand delivery - run: | - python3 -I - <<'PY' - import hashlib, json, sys - from pathlib import Path - - root = Path.cwd().resolve() - lock = json.loads((root / ".github/brand/brand.lock.json").read_text()) - if lock.get("schema_version") != 2 or lock.get("authority") != "https://github.com/computer-mcp/.github": - sys.exit(".github/brand/brand.lock.json is not a Computer MCP brand lock") - errors = [] - for name, expected in lock["files"].items(): - path = (root / name).resolve() - if not path.is_relative_to(root) or not path.is_file(): - errors.append(f"Missing brand file: {name}") - elif hashlib.sha256(path.read_bytes()).hexdigest() != expected: - errors.append(f"Brand file differs from its lock: {name}") - for name, required in lock["entries"].items(): - path = root / name - text = path.read_text() if path.is_file() else "" - errors += [f"{name} lacks {value!r}" for value in required if value not in text] - print("\n".join(errors) or f"Brand lock verified for {lock['consumer']}") - sys.exit(1 if errors else 0) - PY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 664ab52..3468557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,28 +7,32 @@ on: permissions: contents: read jobs: - design: + presentation: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - run: npm ci --prefix Brand --no-fund --no-audit - name: Lint DESIGN.md - run: npm --prefix Brand run lint - - name: Check brand exports - run: python3 Brand/brand.py check - plugin-runtime: - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: showxu/skills/skills/github-readme-design/scripts@36707036061a2d0ba012184ef0b6d6820082127f with: - persist-credentials: false - - name: Test shared plugin runtime, delivery and release - run: python3 -m unittest discover -s Tests - - name: Check embedded runtime digest - run: python3 Scripts/plugin-runtime.py check-workflow - profile: - uses: ./.github/workflows/brand-check.yml + asset: design + command: lint + design: DESIGN.md + - name: Check the organization profile + uses: showxu/skills/skills/github-readme-design/scripts@36707036061a2d0ba012184ef0b6d6820082127f + with: + asset: profile + design: DESIGN.md + catalog: profile/catalog.json + - name: Check this repository's social preview + uses: showxu/skills/skills/github-readme-design/scripts@36707036061a2d0ba012184ef0b6d6820082127f + with: + asset: social-preview + design: DESIGN.md + fonts: .github/design/fonts + catalog: profile/catalog.json + repository: .github + icon: profile/assets/mark-light.png + assets: .github/assets/social-preview diff --git a/.github/workflows/plugin-release.yml b/.github/workflows/plugin-release.yml deleted file mode 100644 index 24efa56..0000000 --- a/.github/workflows/plugin-release.yml +++ /dev/null @@ -1,240 +0,0 @@ -name: Plugin release -on: - workflow_call: - inputs: - ref: - description: Signed annotated version tag, or a commit for a dry run - type: string - required: true - publish: - description: Publish the verified release (false assembles a dry-run bundle) - type: boolean - default: false - build: - description: Build command; the workflow appends the output directory as its final argument - type: string - required: true - ci_artifact: - description: Exact ci.yml artifact name; ${commit} expands to the resolved commit - type: string - required: true - runner: - description: Runner for the isolated caller build - type: string - default: ubuntu-24.04 - outputs: - published: - description: True only when a complete immutable release is public - value: ${{ jobs.publish.outputs.published || 'false' }} - release_id: - description: Published GitHub Release ID (empty for a dry run) - value: ${{ jobs.publish.outputs.release_id }} - tag: - description: Verified version tag (empty for an untagged dry run) - value: ${{ jobs.verify.outputs.tag }} -permissions: {} -concurrency: - group: plugin-release-${{ github.repository }}-${{ inputs.ref }} - cancel-in-progress: false -env: - PLUGIN_RELEASE_SHA256: 'faea0a5f33ef714231c23951050f3851867aec593a7d2369d9fb5e8f8db2ce74' -jobs: - source: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - actions: read - outputs: - commit: ${{ steps.source.outputs.commit }} - workflow_ref: ${{ steps.helper.outputs.workflow_ref }} - workflow_sha: ${{ steps.helper.outputs.workflow_sha }} - helper_artifact_id: ${{ steps.upload.outputs.artifact-id }} - steps: - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: '3.13' - - name: Load the helper from this reusable workflow's immutable revision - id: helper - env: - GH_TOKEN: ${{ github.token }} - GH_REPOSITORY: ${{ github.repository }} - GH_RUN_ID: ${{ github.run_id }} - shell: bash - run: | - python3 -I - <<'PY' - import base64, hashlib, json, os, re - from pathlib import Path - from urllib.request import Request, urlopen - - def get(path): - request = Request('https://api.github.com' + path, headers={ - 'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28'}) - with urlopen(request, timeout=60) as response: - return json.load(response) - - run = get('/repos/' + os.environ['GH_REPOSITORY'] + '/actions/runs/' + os.environ['GH_RUN_ID']) - prefix = 'computer-mcp/.github/.github/workflows/plugin-release.yml@' - references = {(item['path'], item['sha']) for item in run['referenced_workflows'] - if item['path'].startswith(prefix)} - if len(references) != 1: - raise SystemExit('Expected exactly one immutable plugin-release workflow reference') - reference, revision = references.pop() - if not re.fullmatch('[0-9a-f]{40}', revision) or reference != prefix + revision: - raise SystemExit('Pin the reusable release workflow by its full commit SHA') - source = get('/repos/computer-mcp/.github/contents/Scripts/plugin_release.py?ref=' + revision) - data = base64.b64decode(source['content']) - if hashlib.sha256(data).hexdigest() != os.environ['PLUGIN_RELEASE_SHA256']: - raise SystemExit('Release helper differs from this workflow pin') - root = Path(os.environ['RUNNER_TEMP']) / 'plugin-release' - root.mkdir() - (root / 'plugin_release.py').write_bytes(data) - with open(os.environ['GITHUB_OUTPUT'], 'a') as output: - output.write('workflow_ref=' + reference + '\nworkflow_sha=' + revision + '\n') - PY - - name: Resolve and verify the source ref - id: source - env: - GH_TOKEN: ${{ github.token }} - GH_REPOSITORY: ${{ github.repository }} - RELEASE_REF: ${{ inputs.ref }} - RELEASE_PUBLISH: ${{ inputs.publish }} - shell: bash - run: | - python3 -I "$RUNNER_TEMP/plugin-release/plugin_release.py" resolve \ - --repository "$GH_REPOSITORY" --ref "$RELEASE_REF" --publish "$RELEASE_PUBLISH" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - id: upload - with: - name: plugin-release-helper-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/plugin-release/plugin_release.py - if-no-files-found: error - retention-days: 14 - build: - needs: source - runs-on: ${{ inputs.runner }} - timeout-minutes: 30 - permissions: {} - outputs: - artifact_id: ${{ steps.upload.outputs.artifact-id }} - steps: - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: '3.13' - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.source.outputs.commit }} - persist-credentials: false - - name: Build the caller's archives - env: - RELEASE_BUILD: ${{ inputs.build }} - shell: bash - run: | - bash -euo pipefail -c "$RELEASE_BUILD \"\$1\"" plugin-build "$RUNNER_TEMP/plugin-release-build" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - id: upload - with: - name: plugin-build-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/plugin-release-build/ - if-no-files-found: error - retention-days: 14 - verify: - needs: [source, build] - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - actions: read - outputs: - tag: ${{ steps.bundle.outputs.tag }} - asset_digests: ${{ steps.bundle.outputs.asset_digests }} - artifact_id: ${{ steps.upload.outputs.artifact-id }} - steps: - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: '3.13' - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - artifact-ids: ${{ needs.source.outputs.helper_artifact_id }} - path: ${{ runner.temp }}/plugin-release - merge-multiple: true - - name: Verify the helper, compare archive bytes and assemble the bundle - id: bundle - env: - GH_TOKEN: ${{ github.token }} - GH_REPOSITORY: ${{ github.repository }} - RELEASE_REF: ${{ inputs.ref }} - RELEASE_PUBLISH: ${{ inputs.publish }} - RELEASE_BUILD_ARTIFACT_ID: ${{ needs.build.outputs.artifact_id }} - RELEASE_COMMIT: ${{ needs.source.outputs.commit }} - GH_RUN_ID: ${{ github.run_id }} - RELEASE_CI_ARTIFACT: ${{ inputs.ci_artifact }} - RELEASE_WORKFLOW_REF: ${{ needs.source.outputs.workflow_ref }} - RELEASE_WORKFLOW_SHA: ${{ needs.source.outputs.workflow_sha }} - shell: bash - run: | - python3 -I - <<'PY' - import hashlib, os, subprocess, sys - from pathlib import Path - - root = Path(os.environ['RUNNER_TEMP']) / 'plugin-release' - helper = root / 'plugin_release.py' - if helper.is_symlink() or hashlib.sha256(helper.read_bytes()).hexdigest() != os.environ['PLUGIN_RELEASE_SHA256']: - raise SystemExit('Release helper differs from this workflow pin') - subprocess.run([sys.executable, '-I', str(helper), 'verify', - '--repository', os.environ['GH_REPOSITORY'], - '--ref', os.environ['RELEASE_REF'], '--publish', os.environ['RELEASE_PUBLISH'], - '--commit', os.environ['RELEASE_COMMIT'], - '--build-artifact-id', os.environ['RELEASE_BUILD_ARTIFACT_ID'], - '--run-id', os.environ['GH_RUN_ID'], - '--ci-artifact', os.environ['RELEASE_CI_ARTIFACT'], - '--workflow-ref', os.environ['RELEASE_WORKFLOW_REF'], - '--workflow-sha', os.environ['RELEASE_WORKFLOW_SHA'], - '--output', str(root / 'assets')], check=True) - PY - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - id: upload - with: - name: plugin-release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/plugin-release/ - if-no-files-found: error - retention-days: 14 - publish: - if: inputs.publish - needs: verify - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - outputs: - published: ${{ steps.release.outputs.published }} - release_id: ${{ steps.release.outputs.release_id }} - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - artifact-ids: ${{ needs.verify.outputs.artifact_id }} - path: ${{ runner.temp }}/plugin-release - merge-multiple: true - - name: Verify the helper and publish only the verified bundle - id: release - env: - GH_TOKEN: ${{ github.token }} - GH_REPOSITORY: ${{ github.repository }} - RELEASE_ASSET_DIGESTS: ${{ needs.verify.outputs.asset_digests }} - shell: bash - run: | - python3 -I - <<'PY' - import hashlib, os, subprocess, sys - from pathlib import Path - - root = Path(os.environ['RUNNER_TEMP']) / 'plugin-release' - helper = root / 'plugin_release.py' - if helper.is_symlink() or hashlib.sha256(helper.read_bytes()).hexdigest() != os.environ['PLUGIN_RELEASE_SHA256']: - raise SystemExit('Release helper differs from this workflow pin') - subprocess.run([sys.executable, '-I', str(helper), 'publish', - '--repository', os.environ['GH_REPOSITORY'], - '--directory', str(root / 'assets'), - '--asset-digests', os.environ['RELEASE_ASSET_DIGESTS']], check=True) - PY diff --git a/.github/workflows/plugin-runtime.yml b/.github/workflows/plugin-runtime.yml deleted file mode 100644 index 04ec033..0000000 --- a/.github/workflows/plugin-runtime.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Plugin runtime -on: - workflow_call: -permissions: - contents: read -jobs: - runtime: - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Verify shared Python runtime - env: - PLUGIN_RUNTIME_SHA256: '5a9f797bfca4b6ab31a9a648017fb1d09400593a9aab0d2250d426efa51f97cf' - run: | - python3 -I - <<'PY' - import hashlib - import os - from pathlib import Path - import sys - - root = Path.cwd().resolve() - path = (root / "bin/plugin_runtime.py").resolve() - if not path.is_relative_to(root) or not path.is_file(): - sys.exit("Missing plugin runtime: bin/plugin_runtime.py") - if hashlib.sha256(path.read_bytes()).hexdigest() != os.environ["PLUGIN_RUNTIME_SHA256"]: - sys.exit("Plugin runtime differs from the pinned shared runtime: bin/plugin_runtime.py") - print("Plugin runtime matches the pinned shared runtime") - PY diff --git a/.gitignore b/.gitignore index c34d49e..fc0027c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ .agent/ __pycache__/ .DS_Store -Brand/.cache/ -Brand/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index de44e08..f992b63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,15 +68,20 @@ Use this check: ## Task Route -- For the public organization entry point, edit `profile/README.md`. +- For the public organization entry point, edit `profile/catalog.json` and + regenerate `profile/README.md`; `README.md` gives the commands. - For conventions shared by every repository, read `MAINTENANCE.md`. - Before changing shared version, plugin compatibility or dependency rules, read `VERSIONING.md`. - For repository settings, GitHub Apps and Actions credentials, change - `MAINTENANCE.md` with the setting, then run `python3 Scripts/check-settings.py`. -- For visual identity, read `DESIGN.md`; the renderer, its configuration and - the rendered exports live in `Brand/`, and `README.md` describes rendering - and delivery. + `MAINTENANCE.md` with the setting; the organization check in + [computer-mcp/github-workflows](https://github.com/computer-mcp/github-workflows) + compares it with live settings. +- For visual identity, read `DESIGN.md`. Icon sources and their provenance + live in `.github/design/icons/`, fonts in `.github/design/fonts/`, and + `README.md` gives the generator and toolchain commands. +- For a reusable workflow or a check more than one repository needs, work in + `computer-mcp/github-workflows`; this repository keeps only thin callers. - For positioning, the tagline and other text rendered into images, read the main repository's [ProductIdentity](https://github.com/computer-mcp/computer-mcp/blob/master/Documentation/Architecture/ProductIdentity.md). @@ -89,12 +94,15 @@ Use this check: - `AGENTS.md` is the agent guide: first-principles guardrails, task route, authority boundaries, and boundary guardrails. -- `README.md` indexes scope, brand rendering and delivery. +- `README.md` indexes scope, icon generation, profile and social-preview + rendering, and delivery. - `MAINTENANCE.md` is current truth for cross-repository operating conventions. `VERSIONING.md` owns shared version and dependency rules; each repository owns its version sources, release procedure and written exceptions. -- `DESIGN.md` and `Brand/config.json` are current truth for visual identity. - `Brand/Exports/` and `profile/brand/` are rendered from them. +- `DESIGN.md` is current truth for visual identity and `profile/catalog.json` + for the profile's repositories, groups and sentences. `profile/README.md` + and the social previews are rendered from them; consumed icon copies are + recorded in each icon's `provenance.json`. - The main repository's ProductIdentity is current truth for product meaning. - Root governance files and `.github/*` are GitHub-facing governance and the organization's default community files. @@ -149,11 +157,12 @@ source of truth instead of a literal value. - Text rendered into images must match ProductIdentity. Preserve repository visibility and integration ownership. -- Change `DESIGN.md` or `Brand/config.json`, re-render, and commit the exports - with their manifest. Never edit exports by hand. -- Before committing, run `npm --prefix Brand run lint` and - `python3 Brand/brand.py check`. -- Deliver artwork to other repositories only with `python3 Brand/brand.py sync`, - through a pull request in each repository. +- Change `DESIGN.md` or `profile/catalog.json`, regenerate, and commit inputs + and outputs together. Never edit generated files by hand. +- Before committing, lint DESIGN and check the profile with the + `github-readme-design` toolchain. +- Deliver icons and social previews to other repositories through a pull + request in each repository, and record each consumed copy in the icon's + `provenance.json`. - Keep `.agent/` local and untracked. Profile content must be understandable from committed artifacts alone. diff --git a/Brand/Exports/app/AppIcon.icon/Assets/01-01.svg b/Brand/Exports/app/AppIcon.icon/Assets/01-01.svg deleted file mode 100644 index c1ab628..0000000 --- a/Brand/Exports/app/AppIcon.icon/Assets/01-01.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/app/AppIcon.icon/Assets/02-01.svg b/Brand/Exports/app/AppIcon.icon/Assets/02-01.svg deleted file mode 100644 index f6c855a..0000000 --- a/Brand/Exports/app/AppIcon.icon/Assets/02-01.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/app/AppIcon.icon/Assets/03-01.svg b/Brand/Exports/app/AppIcon.icon/Assets/03-01.svg deleted file mode 100644 index 931d03f..0000000 --- a/Brand/Exports/app/AppIcon.icon/Assets/03-01.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/app/AppIcon.icon/Assets/04-01.svg b/Brand/Exports/app/AppIcon.icon/Assets/04-01.svg deleted file mode 100644 index 603c67e..0000000 --- a/Brand/Exports/app/AppIcon.icon/Assets/04-01.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/app/AppIcon.icon/icon.json b/Brand/Exports/app/AppIcon.icon/icon.json deleted file mode 100644 index 3ce7f3e..0000000 --- a/Brand/Exports/app/AppIcon.icon/icon.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "fill": { - "automatic-gradient": "srgb:0.35294,0.29412,0.96078,1.00000" - }, - "groups": [ - { - "name": "Computer", - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.35 - }, - "blur-material": 0.5, - "refractivity": { - "enabled": true, - "strength": 0.6, - "depth": 0 - }, - "layers": [ - { - "image-name": "04-01.svg", - "name": "crt-body", - "fill": { - "automatic-gradient": "srgb:0.96471,0.93333,0.85882,0.65098" - } - } - ] - }, - { - "name": "Light", - "shadow": { - "kind": "layer-color", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.1 - }, - "layers": [ - { - "image-name": "03-01.svg", - "name": "crt-light", - "fill": { - "automatic-gradient": "srgb:1.00000,0.70196,0.27843,1.00000" - } - } - ] - }, - { - "name": "Base", - "shadow": { - "kind": "neutral", - "opacity": 0.4 - }, - "translucency": { - "enabled": true, - "value": 0.35 - }, - "blur-material": 0.5, - "refractivity": { - "enabled": true, - "strength": 0.6, - "depth": 0 - }, - "layers": [ - { - "image-name": "02-01.svg", - "name": "crt-base", - "fill": { - "automatic-gradient": "srgb:0.84706,0.80392,0.69020,0.65098" - } - } - ] - }, - { - "name": "Grid", - "shadow": { - "kind": "none", - "opacity": 0 - }, - "specular": false, - "layers": [ - { - "image-name": "01-01.svg", - "name": "grid", - "fill": { - "solid": "srgb:1.00000,1.00000,1.00000,1.00000" - }, - "opacity": 0.2 - } - ] - } - ], - "supported-platforms": { - "circles": [ - "watchOS" - ], - "squares": "shared" - }, - "features": [ - "refractivity" - ] -} diff --git a/Brand/Exports/headers/apple-cli-dark.png b/Brand/Exports/headers/apple-cli-dark.png deleted file mode 100644 index f26de29..0000000 Binary files a/Brand/Exports/headers/apple-cli-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/apple-cli-light.png b/Brand/Exports/headers/apple-cli-light.png deleted file mode 100644 index 1d4bf54..0000000 Binary files a/Brand/Exports/headers/apple-cli-light.png and /dev/null differ diff --git a/Brand/Exports/headers/computer-mcp-dark.png b/Brand/Exports/headers/computer-mcp-dark.png deleted file mode 100644 index 1679ee8..0000000 Binary files a/Brand/Exports/headers/computer-mcp-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/computer-mcp-light.png b/Brand/Exports/headers/computer-mcp-light.png deleted file mode 100644 index 0ac66a7..0000000 Binary files a/Brand/Exports/headers/computer-mcp-light.png and /dev/null differ diff --git a/Brand/Exports/headers/computer-mcp-zh-CN-dark.png b/Brand/Exports/headers/computer-mcp-zh-CN-dark.png deleted file mode 100644 index e676410..0000000 Binary files a/Brand/Exports/headers/computer-mcp-zh-CN-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/computer-mcp-zh-CN-light.png b/Brand/Exports/headers/computer-mcp-zh-CN-light.png deleted file mode 100644 index dde85ca..0000000 Binary files a/Brand/Exports/headers/computer-mcp-zh-CN-light.png and /dev/null differ diff --git a/Brand/Exports/headers/homebrew-tap-dark.png b/Brand/Exports/headers/homebrew-tap-dark.png deleted file mode 100644 index f606606..0000000 Binary files a/Brand/Exports/headers/homebrew-tap-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/homebrew-tap-light.png b/Brand/Exports/headers/homebrew-tap-light.png deleted file mode 100644 index 2012c11..0000000 Binary files a/Brand/Exports/headers/homebrew-tap-light.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-claude-dark.png b/Brand/Exports/headers/plugin-claude-dark.png deleted file mode 100644 index f9dab07..0000000 Binary files a/Brand/Exports/headers/plugin-claude-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-claude-light.png b/Brand/Exports/headers/plugin-claude-light.png deleted file mode 100644 index 6ec4950..0000000 Binary files a/Brand/Exports/headers/plugin-claude-light.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-codex-dark.png b/Brand/Exports/headers/plugin-codex-dark.png deleted file mode 100644 index f5a84e8..0000000 Binary files a/Brand/Exports/headers/plugin-codex-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-codex-light.png b/Brand/Exports/headers/plugin-codex-light.png deleted file mode 100644 index 49de793..0000000 Binary files a/Brand/Exports/headers/plugin-codex-light.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-computer-use-dark.png b/Brand/Exports/headers/plugin-computer-use-dark.png deleted file mode 100644 index 5989d12..0000000 Binary files a/Brand/Exports/headers/plugin-computer-use-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-computer-use-light.png b/Brand/Exports/headers/plugin-computer-use-light.png deleted file mode 100644 index defb99b..0000000 Binary files a/Brand/Exports/headers/plugin-computer-use-light.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-cursor-dark.png b/Brand/Exports/headers/plugin-cursor-dark.png deleted file mode 100644 index b444f2f..0000000 Binary files a/Brand/Exports/headers/plugin-cursor-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-cursor-light.png b/Brand/Exports/headers/plugin-cursor-light.png deleted file mode 100644 index 1b226b5..0000000 Binary files a/Brand/Exports/headers/plugin-cursor-light.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-swift-format-dark.png b/Brand/Exports/headers/plugin-swift-format-dark.png deleted file mode 100644 index 65ab7b4..0000000 Binary files a/Brand/Exports/headers/plugin-swift-format-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-swift-format-light.png b/Brand/Exports/headers/plugin-swift-format-light.png deleted file mode 100644 index 2ff1d75..0000000 Binary files a/Brand/Exports/headers/plugin-swift-format-light.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-trae-dark.png b/Brand/Exports/headers/plugin-trae-dark.png deleted file mode 100644 index ad0f129..0000000 Binary files a/Brand/Exports/headers/plugin-trae-dark.png and /dev/null differ diff --git a/Brand/Exports/headers/plugin-trae-light.png b/Brand/Exports/headers/plugin-trae-light.png deleted file mode 100644 index 4032216..0000000 Binary files a/Brand/Exports/headers/plugin-trae-light.png and /dev/null differ diff --git a/Brand/Exports/icons/apple-cli.png b/Brand/Exports/icons/apple-cli.png deleted file mode 100644 index c549f2c..0000000 Binary files a/Brand/Exports/icons/apple-cli.png and /dev/null differ diff --git a/Brand/Exports/icons/apple-cli.svg b/Brand/Exports/icons/apple-cli.svg deleted file mode 100644 index 644caf0..0000000 --- a/Brand/Exports/icons/apple-cli.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/icons/homebrew-tap.png b/Brand/Exports/icons/homebrew-tap.png deleted file mode 100644 index 37b7b2c..0000000 Binary files a/Brand/Exports/icons/homebrew-tap.png and /dev/null differ diff --git a/Brand/Exports/icons/homebrew-tap.svg b/Brand/Exports/icons/homebrew-tap.svg deleted file mode 100644 index 88e1344..0000000 --- a/Brand/Exports/icons/homebrew-tap.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/icons/plugin-claude.png b/Brand/Exports/icons/plugin-claude.png deleted file mode 100644 index 55e0b61..0000000 Binary files a/Brand/Exports/icons/plugin-claude.png and /dev/null differ diff --git a/Brand/Exports/icons/plugin-claude.svg b/Brand/Exports/icons/plugin-claude.svg deleted file mode 100644 index d2777a8..0000000 --- a/Brand/Exports/icons/plugin-claude.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/icons/plugin-codex.png b/Brand/Exports/icons/plugin-codex.png deleted file mode 100644 index 8b1905b..0000000 Binary files a/Brand/Exports/icons/plugin-codex.png and /dev/null differ diff --git a/Brand/Exports/icons/plugin-codex.svg b/Brand/Exports/icons/plugin-codex.svg deleted file mode 100644 index 0980f71..0000000 --- a/Brand/Exports/icons/plugin-codex.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/icons/plugin-computer-use.png b/Brand/Exports/icons/plugin-computer-use.png deleted file mode 100644 index f95ce5b..0000000 Binary files a/Brand/Exports/icons/plugin-computer-use.png and /dev/null differ diff --git a/Brand/Exports/icons/plugin-computer-use.svg b/Brand/Exports/icons/plugin-computer-use.svg deleted file mode 100644 index 1f232fb..0000000 --- a/Brand/Exports/icons/plugin-computer-use.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/icons/plugin-cursor.png b/Brand/Exports/icons/plugin-cursor.png deleted file mode 100644 index 1770707..0000000 Binary files a/Brand/Exports/icons/plugin-cursor.png and /dev/null differ diff --git a/Brand/Exports/icons/plugin-cursor.svg b/Brand/Exports/icons/plugin-cursor.svg deleted file mode 100644 index 6abb6f9..0000000 --- a/Brand/Exports/icons/plugin-cursor.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/icons/plugin-swift-format.png b/Brand/Exports/icons/plugin-swift-format.png deleted file mode 100644 index c87f367..0000000 Binary files a/Brand/Exports/icons/plugin-swift-format.png and /dev/null differ diff --git a/Brand/Exports/icons/plugin-swift-format.svg b/Brand/Exports/icons/plugin-swift-format.svg deleted file mode 100644 index aa504df..0000000 --- a/Brand/Exports/icons/plugin-swift-format.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/icons/plugin-trae.png b/Brand/Exports/icons/plugin-trae.png deleted file mode 100644 index ee504b2..0000000 Binary files a/Brand/Exports/icons/plugin-trae.png and /dev/null differ diff --git a/Brand/Exports/icons/plugin-trae.svg b/Brand/Exports/icons/plugin-trae.svg deleted file mode 100644 index 3e4b9ce..0000000 --- a/Brand/Exports/icons/plugin-trae.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/Exports/manifest.json b/Brand/Exports/manifest.json deleted file mode 100644 index e4466c9..0000000 --- a/Brand/Exports/manifest.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "schema_version": 1, - "inputs": { - "DESIGN.md": "17ff4ae03d20d8514fe71bb5d61c758bf9c844ecb4ce11f13d8fb5a30d0f1488", - "Brand/config.json": "636d3c83defbb1af45cffee9a19df862894a69bf42e9c01ee911914070c01e89", - "Brand/render.mjs": "66051eaa35863a104342c206b721afaa4e89b708b4ad9c5ce37b11b63180e7e0", - "Brand/package.json": "c5d5e11a37021178ac682ce2fd64a2f55dc4dfe4f333b64f0079cb1ef54686cd", - "Brand/package-lock.json": "20c5a9a8deb540da6cd2a0bbce74c944c80436b770268b0a84938058b483c169", - "Brand/app-icon/layers/apple-cli-calendar.svg": "6dcd1a69edbde44b6a55215d911f7e7d6c24be1660c8e1ef229eeddf76aa27c8", - "Brand/app-icon/layers/apple-cli-mail.svg": "4ee7ee525558c7854ba80a03846e583aab6301a4ae1c4039c083051a6eeb4726", - "Brand/app-icon/layers/apple-cli-notes.svg": "832b748e671542a134198bbb03597b2ed555bbbd765f6dfbe186c8c7c5fac26e", - "Brand/app-icon/layers/apple-cli-prompt.svg": "25593d655215f129153b46a7bb14870e7242c030a57de34edc64992792097112", - "Brand/app-icon/layers/apple-cli-terminal.svg": "21246452da8dfefb0a851a44f2469a761654c3de0aa5df26bf15165dbf9a1aba", - "Brand/app-icon/layers/crt-base.svg": "ab43ecd6e819f7f8713ddf9deecf02198c81d6ebcd0f1b86d4f159db506f98c7", - "Brand/app-icon/layers/crt-body.svg": "1323e36e89c20fce714888872965af41b2f5a17738d7a49aa69a198b95d9af54", - "Brand/app-icon/layers/crt-light.svg": "497967f011f5147ccf039ff5fee3993764aad2c45746ce411f5058a392ca65a4", - "Brand/app-icon/layers/grid.svg": "5695b3bb0134f47ca6950438df98dce38c92591b57af14ff6114135e857b9d0c" - }, - "renderer": { - "playwright": "1.63.0", - "chromium": "153.0.8010.12", - "node": "v25.8.1", - "platform": "darwin", - "architecture": "arm64", - "ictool": { - "bundle-version": "129", - "short-bundle-version": "27.0" - }, - "app_icons_sha256": "afeef6b0ed8a88dae42d09283633b321e29890a526b775fa506b23d27ea2fd9e", - "framework_icons_sha256": "fd9db95220d5d27f11cace4ee2bd8bc37b8e9a6ce000f070efc11554fd8fc29d", - "framework_generator_sha256": "1cd4b2c098ea4cca8fb7fe9a154f47962d837b044f083d186a144a506e2e7fd2" - }, - "exports": { - "app/AppIcon.icon/Assets/01-01.svg": "5695b3bb0134f47ca6950438df98dce38c92591b57af14ff6114135e857b9d0c", - "app/AppIcon.icon/Assets/02-01.svg": "ab43ecd6e819f7f8713ddf9deecf02198c81d6ebcd0f1b86d4f159db506f98c7", - "app/AppIcon.icon/Assets/03-01.svg": "497967f011f5147ccf039ff5fee3993764aad2c45746ce411f5058a392ca65a4", - "app/AppIcon.icon/Assets/04-01.svg": "1323e36e89c20fce714888872965af41b2f5a17738d7a49aa69a198b95d9af54", - "app/AppIcon.icon/icon.json": "5daf706ea7f4aba95985a2efc9a2cf1eeea797d4ef59db1c20920e61db10a2dd", - "app/app-icon.png": "71cbd902b28a083e7726abfa5d2fe506c860b5d954fd398686af1478e6250e80", - "headers/apple-cli-dark.png": "6be2dc34ef034f93d4eac966a7968a7476706027263e46a8ed4d746859dbde59", - "headers/apple-cli-light.png": "f45ca1abb4436ef023349d7583248837e76e9a898acd12e8988732fe40d8975b", - "headers/computer-mcp-dark.png": "3a0be729610516dfcd3e882e2c0af351eda5d02dcc83ec12c970f53bcd875355", - "headers/computer-mcp-light.png": "b60214fdb6aef03b13bbd46bbbfebbde43b8c74eeb3825b300013e17d19494f5", - "headers/computer-mcp-zh-CN-dark.png": "e257d6196952680a48bd3ffcbfa7a6564e07babadc8c29bed889058f9b8d0942", - "headers/computer-mcp-zh-CN-light.png": "c63b65ad7a8ec26313ade9d53fcf94cc84f4da2753860e84c663eaf9d270b627", - "headers/homebrew-tap-dark.png": "f8820931baf8977af69b624d3f7f39adb6db5a5dc530036f14afda5b16af40fb", - "headers/homebrew-tap-light.png": "888169682bb55a3f6da246867985b06f59f44729b91b0288acc77811414ffede", - "headers/plugin-claude-dark.png": "2c4fccaf8aa17c45a106941d50f85f48ff40d0797b2569e3442a09d13a9d230a", - "headers/plugin-claude-light.png": "10908cb5292ccaa048db0bc4ad39e4c65c5fe4eb904636c5ea4a0e6228d53aeb", - "headers/plugin-codex-dark.png": "b898a5adbb5c4839e342fe87f7fbcfa873252e0a3648f7aff50c4ded3dd8d133", - "headers/plugin-codex-light.png": "2be839fa143e82d24ad9d57f6c600a177aec90f1473d87c810abb6a6899efa98", - "headers/plugin-computer-use-dark.png": "53d684caada0cf9a6a1ad51cb13ac8e594c6131901ed9b5085d5c4ef25b787b2", - "headers/plugin-computer-use-light.png": "0a39db776f527583a764216df0458f03f7a1a0ff073f43de2daa462761ac8ea3", - "headers/plugin-cursor-dark.png": "4e3f6d8edaf3dcc9ab39d6220e316c695e7f78c9210828c0470447293f623d77", - "headers/plugin-cursor-light.png": "7de614de9d82c3b7a0713a598aa3c4d1713a1d8c59e33d205fcc646c3e50db28", - "headers/plugin-swift-format-dark.png": "8572eb615cd68728411977fca8e7300595056ca38f9ffddee2699d6545013e55", - "headers/plugin-swift-format-light.png": "5a79ef81d7fefc32b09af676096405612b4cff9a6a5f377fb82f9497b572db74", - "headers/plugin-trae-dark.png": "734d82de63b9925fd1516d42d197570a2894b45ed5ed38a76f2e581d0821358f", - "headers/plugin-trae-light.png": "bb9b7fa15bf272aa305e70eec318f0715592e55a00968370c950867065acf657", - "icons/apple-cli.png": "593f5673ad49444caf49cae789d6d587ee4de044a95e81c31699a565fac3a63a", - "icons/apple-cli.svg": "0e9cfd1e5abf33ca3a50a7a64e5a3cf7382505ff2cac23d7743f0a4a70a3a559", - "icons/homebrew-tap.png": "84a1347105ffeb0837aa6836fffb5d542915d8d95b6fca27f04ffe7305a0ea91", - "icons/homebrew-tap.svg": "decae21df93a4fce8a7569a5d2a578e78b25fc102297183ddce3edc18e10a41c", - "icons/plugin-claude.png": "03c035a8927ba4aabaf662bc914e65ce9231d09c0ca184b124d25e99562dbb62", - "icons/plugin-claude.svg": "d2e451cb368fbe69066c6c9afd377a50b37420807029011eeb52f9d45767377d", - "icons/plugin-codex.png": "0befd060527f9006b375fbd81e739d13646da590e6ac53f54d99ae13aa2b00cf", - "icons/plugin-codex.svg": "3dad79ae64db570ebc68a27edb54da16e4810c9c8239eb1ec334f46671f80d91", - "icons/plugin-computer-use.png": "15f4a29f897f471d701cbeee77af6d8440fe5669765aabe9f64b8dabb955239d", - "icons/plugin-computer-use.svg": "f12762274e9a246cd8e0961480ab3a942516306715393313d6d22c77374c0b3d", - "icons/plugin-cursor.png": "e2463c259c0dc113d9ecfbfe036ce5f77790e36a5f6eed5d6d880939c9888d17", - "icons/plugin-cursor.svg": "bfd9a052e702eb110e68b3cac73c6604dbe1e6727d3aafe3462666ea75d2dea1", - "icons/plugin-swift-format.png": "f834ed504720fe916c0ba0de81546a0690267fc1ece0c059523e08a23aba0199", - "icons/plugin-swift-format.svg": "a8f315bc88ece6cfffd5baa4cba8c1b21f78ec6f0b6a7ee0093b2f8fc01332c3", - "icons/plugin-trae.png": "5f5773dafcfed8b929c1dd26386f93cb7a4476725c08fe70bc724d9e126beebe", - "icons/plugin-trae.svg": "ae120f5536be39dd528775575898da8717f7f3d08cd2a21fb9163ac6c90dea5e", - "social/apple-cli.png": "414206f5b4f996b47c92a91b91da60942c4c612fbcf182b89b2a880cbfad828e", - "social/computer-mcp-zh-CN.png": "9a66ef45c7077dafb8bc4d348a7107534b1c5e7cacad91ab98fcc83e660937f8", - "social/computer-mcp.png": "ce522f0113e87d6c453b05cebd6d71ddaa8477b3380a03f40c7e9fd72726f12b", - "social/homebrew-tap.png": "90f2ce3932b0b3e7979ee48f348bbb828fa08d44d3072b37d185d976da1f0668", - "social/plugin-claude.png": "c55b173342b0098cb3319009770616c37ae07d7e2cc10e3ca9592ad3564a1e3c", - "social/plugin-codex.png": "068e40fe201504795a8a7db771fcb224b8bd89e77d2a1d2203f168e59775e8e0", - "social/plugin-computer-use.png": "d45fd9e87d9554e47d098edd222eaf27fcf8d56ca123d0cd79d4d3095712caa6", - "social/plugin-cursor.png": "5439826c330e307190798ab7b8476f89426bad099b73cc4dacc588d043f01207", - "social/plugin-swift-format.png": "ea577bc96c3cd1dc5b20dc13efb60df4dc2879537804ba042d80990ca6e4ee4f", - "social/plugin-trae.png": "9cad5baaa4e5b8c4b7b30fe8b41d82a3b32809a08c565a390d544904f049e02b", - "web/apple-touch-icon.png": "a03354d9ef09076806c88e46acef5ecee3739e0e20137ff8741c57df68cd6c92", - "web/avatar.png": "34ce8f1feb34c6f19f9927679fc3b6532bfaa3e884920d9374d61b70d54d9134", - "web/favicon-32.png": "8897fa0aac13eb5719376881018579175d3536f0c6e6a21f617049cf8e435156", - "web/favicon.svg": "67d61a4dd2abed510df22cb3165427bca3f00ece110ec889ef7136a951d1bb94" - } -} diff --git a/Brand/Exports/social/apple-cli.png b/Brand/Exports/social/apple-cli.png deleted file mode 100644 index a288d34..0000000 Binary files a/Brand/Exports/social/apple-cli.png and /dev/null differ diff --git a/Brand/Exports/social/computer-mcp-zh-CN.png b/Brand/Exports/social/computer-mcp-zh-CN.png deleted file mode 100644 index 862a9d7..0000000 Binary files a/Brand/Exports/social/computer-mcp-zh-CN.png and /dev/null differ diff --git a/Brand/Exports/social/computer-mcp.png b/Brand/Exports/social/computer-mcp.png deleted file mode 100644 index 129e18a..0000000 Binary files a/Brand/Exports/social/computer-mcp.png and /dev/null differ diff --git a/Brand/Exports/social/homebrew-tap.png b/Brand/Exports/social/homebrew-tap.png deleted file mode 100644 index 0d96e3c..0000000 Binary files a/Brand/Exports/social/homebrew-tap.png and /dev/null differ diff --git a/Brand/Exports/social/plugin-claude.png b/Brand/Exports/social/plugin-claude.png deleted file mode 100644 index 89041f2..0000000 Binary files a/Brand/Exports/social/plugin-claude.png and /dev/null differ diff --git a/Brand/Exports/social/plugin-codex.png b/Brand/Exports/social/plugin-codex.png deleted file mode 100644 index a463f41..0000000 Binary files a/Brand/Exports/social/plugin-codex.png and /dev/null differ diff --git a/Brand/Exports/social/plugin-computer-use.png b/Brand/Exports/social/plugin-computer-use.png deleted file mode 100644 index 2f61953..0000000 Binary files a/Brand/Exports/social/plugin-computer-use.png and /dev/null differ diff --git a/Brand/Exports/social/plugin-cursor.png b/Brand/Exports/social/plugin-cursor.png deleted file mode 100644 index dbbf968..0000000 Binary files a/Brand/Exports/social/plugin-cursor.png and /dev/null differ diff --git a/Brand/Exports/social/plugin-swift-format.png b/Brand/Exports/social/plugin-swift-format.png deleted file mode 100644 index 455beb4..0000000 Binary files a/Brand/Exports/social/plugin-swift-format.png and /dev/null differ diff --git a/Brand/Exports/social/plugin-trae.png b/Brand/Exports/social/plugin-trae.png deleted file mode 100644 index 0544eec..0000000 Binary files a/Brand/Exports/social/plugin-trae.png and /dev/null differ diff --git a/Brand/Exports/web/apple-touch-icon.png b/Brand/Exports/web/apple-touch-icon.png deleted file mode 100644 index 7cff1b9..0000000 Binary files a/Brand/Exports/web/apple-touch-icon.png and /dev/null differ diff --git a/Brand/Exports/web/favicon-32.png b/Brand/Exports/web/favicon-32.png deleted file mode 100644 index b320938..0000000 Binary files a/Brand/Exports/web/favicon-32.png and /dev/null differ diff --git a/Brand/Exports/web/favicon.svg b/Brand/Exports/web/favicon.svg deleted file mode 100644 index 2154c4b..0000000 --- a/Brand/Exports/web/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brand/brand.py b/Brand/brand.py deleted file mode 100644 index 0cbda19..0000000 --- a/Brand/brand.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -"""Check Computer MCP brand exports and deliver them to consumer repositories. - - python3 Brand/brand.py check exports match the manifest and current inputs - python3 Brand/brand.py sync [--repository NAME] - copy exports into a consumer and rewrite its lock -""" - -import argparse -import hashlib -import json -import shutil -import subprocess -import sys -import textwrap -from pathlib import Path - -BRAND = Path(__file__).resolve().parent -ROOT = BRAND.parent -EXPORTS = BRAND / "Exports" -VERIFIER = ROOT / ".github/workflows/brand-check.yml" -LOCK = ".github/brand/brand.lock.json" -CONFIG = json.loads((BRAND / "config.json").read_text()) - - -def digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def finish(errors, success): - print("\n".join(errors) or success, flush=True) - return 1 if errors else 0 - - -def check(): - manifest = json.loads((EXPORTS / "manifest.json").read_text()) - errors = [ - f"Exports are stale for {name}; run node Brand/render.mjs" - for name, expected in manifest["inputs"].items() - if digest(ROOT / name) != expected - ] - present = {p.relative_to(EXPORTS).as_posix() for p in EXPORTS.rglob("*") if p.is_file() and p.name != ".DS_Store"} - present.discard("manifest.json") - errors += [f"Export not in manifest: {name}" for name in sorted(present - manifest["exports"].keys())] - errors += [f"Missing export: {name}" for name in sorted(manifest["exports"].keys() - present)] - errors += [ - f"Export drift: {name}" - for name, expected in manifest["exports"].items() - if name in present and digest(EXPORTS / name) != expected - ] - return finish(errors, f"{len(present)} brand exports match their manifest") - - -def repository_of(checkout): - url = subprocess.run( - ["git", "-C", str(checkout), "remote", "get-url", "origin"], capture_output=True, text=True, check=True - ).stdout.strip() - return url.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1].rsplit(":", 1)[-1] - - -def resolve(repository): - member = next((m for m in CONFIG["members"] if m["repository"] == repository), None) - spec = CONFIG["consumers"].get(repository) - if spec is None: - if member is None: - raise SystemExit(f"{repository} is not a brand consumer or member") - spec = json.loads(json.dumps(CONFIG["consumers"]["@member"]).replace("{repository}", repository)) - values = {f"@headline.{locale}": text for locale, text in CONFIG["copy"]["headline"].items()} - if member: - values["@title"] = member["title"] - entries = {name: [values.get(item, item) for item in items] for name, items in spec["entries"].items()} - return spec, entries - - -def deliveries(spec): - for target, source in spec["files"].items(): - origin = EXPORTS / source - if source.endswith("/"): - for file in sorted(p for p in origin.rglob("*") if p.is_file()): - yield target + file.relative_to(origin).as_posix(), file - else: - yield target, origin - - -def inside(root, name): - path = (root / name).resolve() - if not path.is_relative_to(root): - raise SystemExit(f"Delivery path escapes the checkout: {name}") - return path - - -def verify(root): - workflow = VERIFIER.read_text() - script = workflow.split("<<'PY'\n", 1)[1].split("\n PY\n", 1)[0] - return subprocess.run([sys.executable, "-I", "-"], input=textwrap.dedent(script), text=True, cwd=root).returncode - - -def sync(checkout, repository): - if check(): - return 1 - root = Path(checkout).resolve() - repository = repository or repository_of(root) - spec, entries = resolve(repository) - lock_path = inside(root, LOCK) - previous = json.loads(lock_path.read_text())["files"] if lock_path.exists() else {} - files = {} - for target, source in deliveries(spec): - path = inside(root, target) - path.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, path) - files[target] = digest(path) - for stale in previous.keys() - files.keys(): - inside(root, stale).unlink(missing_ok=True) - lock = { - "schema_version": 2, - "authority": CONFIG["authority"], - "revision": digest(EXPORTS / "manifest.json"), - "consumer": repository, - "files": files, - "entries": entries, - } - lock_path.parent.mkdir(parents=True, exist_ok=True) - lock_path.write_text(json.dumps(lock, indent=2, ensure_ascii=False) + "\n") - print(f"Delivered {len(files)} brand files to {repository}", flush=True) - return verify(root) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("check") - deliver = commands.add_parser("sync") - deliver.add_argument("checkout") - deliver.add_argument("--repository") - args = parser.parse_args() - return check() if args.command == "check" else sync(args.checkout, args.repository) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/Brand/config.json b/Brand/config.json deleted file mode 100644 index 59eda00..0000000 --- a/Brand/config.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "schema_version": 1, - "authority": "https://github.com/computer-mcp/.github", - "fonts": [ - { - "family": "Inter", - "weight": "100 900", - "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", - "sha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031" - }, - { - "family": "JetBrains Mono", - "weight": "100 800", - "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf", - "sha256": "48715a42ec242c21e9f02692891e147d022299a52e48d5e413e1a942193ffeda" - }, - { - "family": "Noto Sans SC", - "weight": "100 900", - "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf", - "sha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da" - } - ], - "copy": { - "headline": { - "en": "Wherever you chat, your computer is there.", - "zh-CN": "聊天在哪,你的电脑就在哪。" - }, - "request": { - "en": "Set the checkout timeout to 30 seconds, then run the tests.", - "zh-CN": "把结账页的超时改成 30 秒,再跑一下测试。" - }, - "capabilities": ["Files", "Shell", "CLI", "MCP", "Skills", "Computer Use"], - "chat": "ChatGPT", - "command": "swift test", - "result": "128 tests passed", - "packets": ["file.write", "shell.run"] - }, - "members": [ - { - "repository": "computer-mcp", - "title": "Computer MCP", - "line": { - "en": "Wherever you chat, your computer is there.", - "zh-CN": "聊天在哪,你的电脑就在哪。" - } - }, - { - "repository": "plugin-codex", - "request": "Hand the parser refactor to Codex and show me the diff.", - "title": "Codex", - "eyebrow": "Computer MCP plugin", - "line": { "en": "Codex execution and protocol inspection" } - }, - { - "repository": "plugin-computer-use", - "request": "Export the open Keynote deck as a PDF.", - "title": "Computer Use", - "eyebrow": "Computer MCP plugin", - "line": { "en": "Native desktop observation and actions" } - }, - { - "repository": "plugin-claude", - "request": "Ask Claude Code to fix the failing test on this branch.", - "title": "Claude Code", - "eyebrow": "Computer MCP plugin", - "line": { "en": "Claude Code CLI and streaming MCP adapter" } - }, - { - "repository": "plugin-cursor", - "request": "Have Cursor's agent rename the settings screen everywhere.", - "title": "Cursor", - "eyebrow": "Computer MCP plugin", - "line": { "en": "Cursor Agent CLI and ACP integration" } - }, - { - "repository": "plugin-swift-format", - "request": "Format the package and fix the lint warnings.", - "title": "Swift Format", - "eyebrow": "Computer MCP plugin", - "line": { "en": "Verified Swift formatting and linting" } - }, - { - "repository": "plugin-trae", - "request": "Start a TRAE session to add a dark mode.", - "title": "TRAE", - "eyebrow": "Computer MCP plugin", - "line": { "en": "TRAE sessions through ACP" } - }, - { - "repository": "apple-cli", - "request": "Add “Renew passport” to Reminders for Friday.", - "screen": { "ink": ["apple-cli-notes", "apple-cli-calendar", "apple-cli-mail", "apple-cli-terminal"], "inlay": ["apple-cli-prompt"], "box": [205, 205, 614, 614] }, - "title": "Apple CLI", - "eyebrow": "Computer MCP tool", - "line": { "en": "A local macOS CLI and MCP server for Apple apps" } - }, - { - "repository": "homebrew-tap", - "commands": ["brew install --cask computer-mcp/tap/computer-mcp", "brew install computer-mcp/tap/apple-cli"], "installs": ["computer-mcp", "apple-cli"], - "title": "Homebrew Tap", - "eyebrow": "Computer MCP distribution", - "line": { "en": "Install Computer MCP and Apple CLI with Homebrew" } - } - ], - "consumers": { - ".github": { - "files": { - "profile/brand/header-light.png": "headers/computer-mcp-light.png", - "profile/brand/header-dark.png": "headers/computer-mcp-dark.png", - "profile/brand/avatar.png": "web/avatar.png" - }, - "entries": { - "profile/README.md": ["Computer MCP", "brand/header-light.png", "brand/header-dark.png", "@headline.en"] - } - }, - "computer-mcp": { - "files": { - "Documentation/Brand/header-light.png": "headers/computer-mcp-light.png", - "Documentation/Brand/header-dark.png": "headers/computer-mcp-dark.png", - "Documentation/Brand/header-zh-CN-light.png": "headers/computer-mcp-zh-CN-light.png", - "Documentation/Brand/header-zh-CN-dark.png": "headers/computer-mcp-zh-CN-dark.png", - ".github/brand/social.png": "social/computer-mcp.png", - ".github/brand/social-zh-CN.png": "social/computer-mcp-zh-CN.png", - "Resources/ComputerMCPApp/AppIcon.icon/": "app/AppIcon.icon/" - }, - "entries": { - "README.md": ["Computer MCP", "Documentation/Brand/header-light.png", "Documentation/Brand/header-dark.png", "@headline.en"], - "README.zh-CN.md": ["Computer MCP", "Documentation/Brand/header-zh-CN-light.png", "Documentation/Brand/header-zh-CN-dark.png", "@headline.zh-CN"] - } - }, - "computer-mcp.github.io": { - "files": { - "public/brand/icon.svg": "web/favicon.svg", - "public/brand/favicon.svg": "web/favicon.svg", - "public/brand/favicon-32.png": "web/favicon-32.png", - "public/brand/apple-touch-icon.png": "web/apple-touch-icon.png", - "public/brand/social-en.png": "social/computer-mcp.png", - "public/brand/social-zh-CN.png": "social/computer-mcp-zh-CN.png", - "public/brand/icons/plugin-codex.svg": "icons/plugin-codex.svg", - "public/brand/icons/plugin-computer-use.svg": "icons/plugin-computer-use.svg", - "public/brand/icons/plugin-claude.svg": "icons/plugin-claude.svg", - "public/brand/icons/plugin-cursor.svg": "icons/plugin-cursor.svg", - "public/brand/icons/plugin-swift-format.svg": "icons/plugin-swift-format.svg", - "public/brand/icons/plugin-trae.svg": "icons/plugin-trae.svg", - "public/brand/icons/apple-cli.svg": "icons/apple-cli.svg" - }, - "entries": { - "index.html": ["brand/social-en.png", "brand/favicon.svg", "@headline.en", "@headline.zh-CN"] - } - }, - "@member": { - "files": { - "Documentation/Brand/header-light.png": "headers/{repository}-light.png", - "Documentation/Brand/header-dark.png": "headers/{repository}-dark.png", - ".github/brand/social.png": "social/{repository}.png" - }, - "entries": { - "README.md": ["@title", "Documentation/Brand/header-light.png", "Documentation/Brand/header-dark.png"] - } - } - }, - "sizes": { - "header": [1280, 320, 2], - "social": [1280, 640, 1], - "icon": [512, 512] - } -} diff --git a/Brand/package-lock.json b/Brand/package-lock.json deleted file mode 100644 index 51bbf43..0000000 --- a/Brand/package-lock.json +++ /dev/null @@ -1,1466 +0,0 @@ -{ - "name": "computer-mcp-brand", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "computer-mcp-brand", - "dependencies": { - "@google/design.md": "0.4.0", - "playwright": "1.63.0", - "yaml": "2.9.1" - } - }, - "node_modules/@google/design.md": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@google/design.md/-/design.md-0.4.0.tgz", - "integrity": "sha512-7aNIv6hslxIZ9igXq1abbVu+ue/ft/oFMUrAuhzpVFijGr9v+l0CkkCBQsHozucNiZHIBS43XC6l8gYDZRys9Q==", - "dependencies": { - "citty": "^0.1.6", - "remark-frontmatter": "^5.0.0", - "remark-mdx": "^3.1.1", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0", - "yaml": "^2.7.1", - "zod": "^3.24.0" - }, - "bin": { - "design.md": "dist/index.js", - "designmd": "dist/index.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-visit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", - "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fault": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", - "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", - "license": "MIT", - "dependencies": { - "format": "^0.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", - "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-frontmatter": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", - "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "escape-string-regexp": "^5.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-extension-frontmatter": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", - "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", - "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", - "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-edit-map": "^1.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", - "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.1.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-edit-map": "^1.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.3" - } - }, - "node_modules/micromark-extension-frontmatter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", - "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", - "license": "MIT", - "dependencies": { - "fault": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdx-expression": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", - "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-jsx": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", - "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdx-md": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", - "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", - "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", - "license": "MIT", - "dependencies": { - "acorn": "^8.0.0", - "acorn-jsx": "^5.0.0", - "micromark-extension-mdx-expression": "^3.0.0", - "micromark-extension-mdx-jsx": "^3.0.0", - "micromark-extension-mdx-md": "^2.0.0", - "micromark-extension-mdxjs-esm": "^3.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs-esm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", - "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-mdx-expression": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", - "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", - "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-edit-map": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", - "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-events-to-acorn": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", - "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "estree-util-visit": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", - "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.63.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/remark-frontmatter": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", - "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-frontmatter": "^2.0.0", - "micromark-extension-frontmatter": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-mdx": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", - "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", - "license": "MIT", - "dependencies": { - "mdast-util-mdx": "^3.0.0", - "micromark-extension-mdxjs": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position-from-estree": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", - "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/Brand/package.json b/Brand/package.json deleted file mode 100644 index dcf729a..0000000 --- a/Brand/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "computer-mcp-brand", - "private": true, - "type": "module", - "scripts": { - "render": "node render.mjs", - "lint": "design.md lint ../DESIGN.md" - }, - "dependencies": { - "@google/design.md": "0.4.0", - "playwright": "1.63.0", - "yaml": "2.9.1" - } -} diff --git a/Brand/render.mjs b/Brand/render.mjs deleted file mode 100644 index 1ae3e34..0000000 --- a/Brand/render.mjs +++ /dev/null @@ -1,342 +0,0 @@ -// Render every Computer MCP brand asset from DESIGN.md and config.json into Exports/. -// Usage: APP_ICONS_SCRIPT=/path/to/app_icons.py FRAMEWORK_ICONS_SCRIPT=/path/to/design_icons.py node Brand/render.mjs - -import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import YAML from 'yaml'; -import { chromium } from 'playwright'; - -const BRAND = path.dirname(new URL(import.meta.url).pathname); -const ROOT = path.dirname(BRAND); -const EXPORTS = path.join(BRAND, 'Exports'); -const CACHE = path.join(BRAND, '.cache'); -const INPUTS = [ - 'DESIGN.md', 'Brand/config.json', 'Brand/render.mjs', 'Brand/package.json', 'Brand/package-lock.json', - ...fs.readdirSync(path.join(BRAND, 'app-icon', 'layers')).filter((name) => name.endsWith('.svg')).sort() - .map((name) => `Brand/app-icon/layers/${name}`), -]; -const APP_ICONS_SCRIPT = process.env.APP_ICONS_SCRIPT; -if (!APP_ICONS_SCRIPT?.trim()) throw new Error('Set APP_ICONS_SCRIPT to the apple-app-icon-design generator script (app_icons.py). Icon Composer from Xcode is required; IC_TOOL can select its ictool.'); -const FRAMEWORK_ICONS_SCRIPT = process.env.FRAMEWORK_ICONS_SCRIPT; -if (!FRAMEWORK_ICONS_SCRIPT?.trim()) throw new Error('Set FRAMEWORK_ICONS_SCRIPT to the apple-framework-icon-design generator script (design_icons.py).'); - -const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex'); -const config = JSON.parse(fs.readFileSync(path.join(BRAND, 'config.json'), 'utf8')); -const designText = fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8'); -const design = YAML.parse(designText.match(/^---\n([\s\S]*?)\n---\n/)[1]); -const iconography = JSON.parse(designText.split('\n## Iconography\n')[1].match(/```json\n([\s\S]*?)\n```/)[1]); -const c = design.colors, t = design.typography, r = design.rounded; - -async function fonts() { - const directory = path.join(CACHE, 'fonts'); - fs.mkdirSync(directory, { recursive: true }); - const faces = []; - for (const font of config.fonts) { - const file = path.join(directory, `${font.sha256}.ttf`); - if (!fs.existsSync(file)) { - const response = await fetch(font.url); - if (!response.ok) throw new Error(`Font download failed: ${font.family}`); - fs.writeFileSync(file, Buffer.from(await response.arrayBuffer())); - } - if (sha256(fs.readFileSync(file)) !== font.sha256) throw new Error(`Font digest mismatch: ${font.family}`); - faces.push(`@font-face{font-family:'${font.family}';src:url('file://${file}');font-weight:${font.weight};font-display:block}`); - } - return faces.join('\n'); -} - -function typo(name, extra = {}) { - const v = { ...t[name], ...extra }; - return [ - `font-family:'${v.fontFamily}','Noto Sans SC'`, - `font-size:${v.fontSize}`, - `font-weight:${v.fontWeight}`, - `line-height:${v.lineHeight}`, - v.letterSpacing && `letter-spacing:${v.letterSpacing}`, - v.fontVariation && `font-variation-settings:${v.fontVariation}`, - ].filter(Boolean).join(';'); -} - -const dataUri = (data, type = 'image/svg+xml') => `data:${type};base64,${Buffer.from(data).toString('base64')}`; -const esc = (s) => s.replace(/&/g, '&').replace(/ `${body}`; - -// The App icon's computer, drawn flat from its own layers: chin, amber screen, ivory case. -// (cx, top) is the top centre of the case; h is the height of case and chin together. -const LAYER = (name) => { - const svg = fs.readFileSync(path.join(BRAND, 'app-icon', 'layers', `${name}.svg`), 'utf8'); - return svg.slice(svg.indexOf('>') + 1, svg.lastIndexOf('')).replace(/fill="#[0-9A-Fa-f]{6}"/g, ''); -}; -// Bounds in layer coordinates, copied from Brand/app-icon/layers; change them with the layers. -const CASE = { x0: 240, y0: 196, x1: 784, y1: 782 }; // case and chin -const SCREEN = { x: 300, y: 256, w: 424, h: 312 }; // the case's screen opening - -function computer(p, { cx, top, h }, screen = '') { - const s = h / (CASE.y1 - CASE.y0), x = cx - ((CASE.x0 + CASE.x1) / 2) * s, y = top - CASE.y0 * s; - const inset = `left:${x + SCREEN.x * s}px;top:${y + SCREEN.y * s}px;width:${SCREEN.w * s}px;height:${SCREEN.h * s}px`; - return ` - - ${LAYER('crt-base')} - ${LAYER('crt-light')} - ${LAYER('crt-body')} - -
${screen}
`; -} - -function link(p, points, width = 30) { - const d = 'M' + points.map(([x, y]) => `${x} ${y}`).join(' L'); - return ` - - `; -} - -const check = (color) => ``; - -// A member's mark on the computer's screen: its icon's glyph flat in ink, with inlaid parts in the -// screen's amber. Framework glyphs inlay their surface parts; an app icon names its layers in config. -function mark(member, p, size) { - if (member.installs) { - const side = Math.round(size * 0.68); - return `
${member.installs.map((name) => ``).join('')}
`; - } - const ink = c['on-signal']; - const entry = iconography.icons[member.repository]; - if (entry) { - const parts = entry.custom_glyph.layers.map((layer) => { - const paint = layer.surface ? p.signal : ink; - const look = layer.stroke - ? `fill="none" stroke="${paint}" stroke-width="${layer.width ?? 0.16}" stroke-linecap="round" stroke-linejoin="round"` - : `fill="${paint}"${layer.round ? ` stroke="${paint}" stroke-width="${layer.round}" stroke-linejoin="round"` : ''}`; - return layer.circle ? `` : ``; - }); - return `${parts.join('')}`; - } - const { ink: inked, inlay, box } = member.screen; - const art = (names, paint) => names.map((name) => `${LAYER(name)}`).join(''); - return `${art(inked, ink)}${art(inlay, p.signal)}`; -} - -// The code-block component carrying a member's commands, each behind an amber prompt. -const commandCard = (p, commands, style) => `
- ${commands.map((x) => `
$ ${esc(x)}
`).join('')}
`; - -function header(faces, member, mode, locale = 'en') { - const p = palette(mode); - const isApp = member.repository === 'computer-mcp'; - const icon = icons[member.repository]; - const screen = isApp ? '' : mark(member, p, 84); - const ask = esc(member.request ?? config.copy.request[locale] ?? config.copy.request.en); - return page(faces, 1280, 320, ` - body{background:${p.bg};color:${p.fg}} - .id{position:absolute;left:64px;top:0;height:320px;display:flex;align-items:center;gap:36px;z-index:2} - .id img{width:184px;height:184px} - .eyebrow{${typo('label-md')};color:${p.accent};margin-bottom:12px} - h1{${typo('headline-lg')}} - .line{${typo('body-lg', { fontSize: '21px' })};color:${p.muted};margin-top:12px;max-width:560px} - .req{position:absolute;left:688px;top:34px;width:290px;z-index:2;background:${p.raised};border:1px solid ${p.line};border-radius:${r.lg};padding:12px;box-shadow:${p.shadow}} - .req div{${typo('body-md', { fontSize: '15px', lineHeight: 1.35 })};word-break:keep-all;background:${p.signal};color:${c['on-signal']};border-radius:16px 16px 6px 16px;padding:9px 13px} - `, `${link(p, [[972, 79], [1120, 79], [1120, 116]], 22)} - ${computer(p, { cx: 1120, top: 116, h: 184 }, screen)} - ${member.commands ? commandCard(p, member.commands, 'right:302px;top:44px') : `
${ask}
`} -
${member.eyebrow ? `
${esc(member.eyebrow)}
` : ''}

${esc(member.title)}

${esc(member.line[locale])}

`); -} - -function appSocial(faces, member, locale) { - const p = palette('light'); - const zh = locale === 'zh-CN'; - const icon = icons[member.repository]; - const card = { x: 744, y: 64, w: 372 }; - const lx = 1000, top = 268; - const result = `
-
$ ${esc(config.copy.command)}
-
${check(c['on-signal'])} ${esc(config.copy.result)}
`; - return page(faces, 1280, 640, ` - body{background:${p.bg};color:${p.fg}} - .top{position:absolute;left:80px;top:72px;display:flex;align-items:center;gap:24px;z-index:2} - .top img{width:112px;height:112px} - .top b{${typo('headline-md', { fontSize: '38px' })}} - h1{position:absolute;left:80px;top:${zh ? 252 : 240}px;width:${zh ? 600 : 610}px;${zh ? typo('headline-zh', { fontSize: '64px' }) : typo('headline-display', { fontSize: '68px' })};z-index:2} - .chips{position:absolute;left:80px;bottom:72px;display:flex;gap:8px;z-index:2} - .chip{${typo('label-md')};background:${c.neutral};color:${c['on-surface']};border:1px solid ${p.line};border-radius:${r.sm};padding:7px 10px} - .card{position:absolute;z-index:2;background:${p.raised};border:1px solid ${p.line};border-radius:${r.lg};padding:16px 18px;box-shadow:${p.shadow}} - .card .t{${typo('label-md')};color:${p.muted};margin-bottom:10px} - .ask{${typo('body-md', { fontSize: '15px' })};background:${p.signal};color:${c['on-signal']};border-radius:16px 16px 6px 16px;padding:9px 13px;margin-left:auto;width:fit-content} - .pk{position:absolute;z-index:3;${typo('code-md', { fontWeight: 600, lineHeight: 1.2 })};background:${p.raised};color:${p.fg};border:1px solid ${p.line};border-radius:999px;padding:6px 11px;box-shadow:0 6px 14px -6px rgba(10,16,30,.25)} - .pk i{font-style:normal;color:${p.accent}} - `, `${link(p, [[lx, card.y + 60], [lx, top + 4]], 30)} -
${esc(config.copy.chat)}
${esc(config.copy.request[locale])}
- ${config.copy.packets.map((name, i) => `
→ ${esc(name)}
`).join('')} - ${computer(p, { cx: lx, top, h: 300 }, result)} -
${esc(member.title)}
-

${esc(config.copy.headline[locale]).replace(',', ',
')}

-
${config.copy.capabilities.map((x) => `${esc(x)}`).join('')}
`); -} - -function memberSocial(faces, member) { - const p = palette('light'); - const icon = icons[member.repository]; - const B = { x: 760, y: 96, w: 210, h: 150 }, cx = 1060, top = 290; - return page(faces, 1280, 640, ` - body{background:${p.bg};color:${p.fg}} - .top{position:absolute;left:80px;top:72px;display:flex;align-items:center;gap:24px;z-index:2} - .top img{width:112px;height:112px} - .eyebrow{${typo('label-md')};color:${p.accent};margin-bottom:8px} - .top b{${typo('headline-md', { fontSize: '38px' })}} - h1{position:absolute;left:80px;top:250px;width:640px;${typo('headline-display', { fontSize: '62px' })};z-index:2} - .url{position:absolute;left:80px;bottom:76px;${typo('label-lg')};color:${p.muted}} - .bubble{position:absolute;z-index:2;background:${p.raised};border:1px solid ${p.line};border-radius:28px;box-shadow:${p.shadow};display:flex;align-items:center;justify-content:center;gap:16px} - .bubble i{width:18px;height:18px;border-radius:50%;background:${c['on-surface']}} - `, `${member.commands ? `${link(p, [[1000, 145], [cx, 145], [cx, top + 4]], 34)} - ${commandCard(p, member.commands, 'right:256px;top:110px')}` : `${link(p, [[B.x + B.w - 24, B.y + B.h / 2], [cx, B.y + B.h / 2], [cx, top + 4]], 34)} - - -
`} - ${computer(p, { cx, top, h: 286 }, mark(member, p, 120))} -
${esc(member.eyebrow)}
${esc(member.title)}
-

${esc(member.line.en)}

-
github.com/computer-mcp/${member.repository}
`); -} - -const written = new Set(); - -function write(name, data) { - const file = path.join(EXPORTS, name); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, data); - written.add(name); -} - -function copyDirectory(source, target) { - for (const entry of fs.readdirSync(source, { withFileTypes: true })) { - const file = path.join(source, entry.name), name = `${target}/${entry.name}`; - if (entry.isDirectory()) copyDirectory(file, name); - else write(name, fs.readFileSync(file)); - } -} - -// App icons from the app_icons entries. The App's document, rendition and web icons serve the App and -// the website; another app icon becomes that member's icon. -function generateAppIcons(work) { - let renderer; - for (const name of Object.keys(iconography.app_icons)) { - const output = path.join(work, name); - execFileSync('python3', [APP_ICONS_SCRIPT, path.join(ROOT, 'DESIGN.md'), '--name', name, '--style', 'liquid-glass', '--out', output], { stdio: 'inherit' }); - const manifest = JSON.parse(fs.readFileSync(path.join(output, 'manifest.json'), 'utf8')); - renderer = typeof manifest.renderer === 'string' ? JSON.parse(manifest.renderer) : manifest.renderer; - const png = fs.readFileSync(path.join(output, 'renditions', 'macOS-Default.png')); - icons[name] = dataUri(png, 'image/png'); - if (name === 'computer-mcp') { - copyDirectory(path.join(output, `${name}.icon`), 'app/AppIcon.icon'); - write('app/app-icon.png', png); - for (const file of ['favicon.svg', 'favicon-32.png', 'apple-touch-icon.png']) { - write(`web/${file}`, fs.readFileSync(path.join(output, 'web', file))); - } - write('web/avatar.png', fs.readFileSync(path.join(output, 'web', 'avatar-1024.png'))); - } else { - write(`icons/${name}.svg`, fs.readFileSync(path.join(output, 'web', 'favicon.svg'))); - write(`icons/${name}.png`, fs.readFileSync(path.join(output, 'web', 'icon-512.png'))); - } - } - return renderer; -} - -// Component icons from the icons entries; their PNGs are rasterized with the other exports. -function generateFrameworkIcons(work) { - const output = path.join(work, 'framework'); - execFileSync('python3', [FRAMEWORK_ICONS_SCRIPT, path.join(ROOT, 'DESIGN.md'), '--out', output], { stdio: 'inherit' }); - return Object.fromEntries(Object.keys(iconography.icons).map((name) => [name, fs.readFileSync(path.join(output, `${name}.svg`), 'utf8')])); -} - -const icons = {}; -fs.mkdirSync(CACHE, { recursive: true }); -const work = fs.mkdtempSync(path.join(CACHE, 'icons-')); -let appRenderer, frameworkSvgs; -try { - fs.rmSync(EXPORTS, { recursive: true, force: true }); - appRenderer = generateAppIcons(work); - frameworkSvgs = generateFrameworkIcons(work); -} finally { - fs.rmSync(work, { recursive: true, force: true }); -} -for (const [name, svg] of Object.entries(frameworkSvgs)) icons[name] = dataUri(svg); -const faces = await fonts(); -const browser = await chromium.launch(); -const context = await browser.newContext(); - -async function shot(name, html, [w, h, scale = 1], transparent = false) { - const tmp = path.join(CACHE, 'render.html'); - fs.writeFileSync(tmp, html); - const pg = await context.newPage({ deviceScaleFactor: scale }); - await pg.setViewportSize({ width: w, height: h }); - await pg.goto('file://' + tmp); - await pg.evaluate(async () => { - await Promise.all([...document.fonts].map((f) => f.load())); - await document.fonts.ready; - await Promise.all([...document.images].map((i) => i.decode())); - }); - const png = await pg.screenshot({ clip: { x: 0, y: 0, width: w, height: h }, omitBackground: transparent }); - await pg.close(); - fs.rmSync(tmp); - write(name, png); -} - -const raster = (faces, svg, [w, h]) => page(faces, w, h, 'body{background:transparent}img{width:100%;height:100%;display:block}', ``); - -const S = config.sizes; - -for (const member of config.members) { - const repo = member.repository; - const svg = frameworkSvgs[repo]; - if (svg) { - write(`icons/${repo}.svg`, svg); - await shot(`icons/${repo}.png`, raster(faces, svg, S.icon), S.icon, true); - } - for (const locale of Object.keys(member.line)) { - const suffix = locale === 'en' ? '' : `-${locale}`; - for (const mode of ['light', 'dark']) await shot(`headers/${repo}${suffix}-${mode}.png`, header(faces, member, mode, locale), S.header); - await shot(`social/${repo}${suffix}.png`, repo === 'computer-mcp' ? appSocial(faces, member, locale) : memberSocial(faces, member), S.social); - } -} - -const pkg = JSON.parse(fs.readFileSync(path.join(BRAND, 'node_modules/playwright/package.json'), 'utf8')); -const manifest = { - schema_version: 1, - inputs: Object.fromEntries(INPUTS.map((name) => [name, sha256(fs.readFileSync(path.join(ROOT, name)))])), - renderer: { - playwright: pkg.version, chromium: browser.version(), node: process.version, platform: process.platform, architecture: process.arch, - ictool: appRenderer, app_icons_sha256: sha256(fs.readFileSync(APP_ICONS_SCRIPT)), - framework_icons_sha256: sha256(fs.readFileSync(FRAMEWORK_ICONS_SCRIPT)), - framework_generator_sha256: sha256(fs.readFileSync(path.join(path.dirname(FRAMEWORK_ICONS_SCRIPT), 'stack_logo.py'))), - }, - exports: Object.fromEntries([...written].sort().map((name) => [name, sha256(fs.readFileSync(path.join(EXPORTS, name)))])), -}; -await browser.close(); -fs.writeFileSync(path.join(EXPORTS, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); -console.log(`Rendered ${written.size} brand assets`); diff --git a/DESIGN.md b/DESIGN.md index 5b6d15a..dd92fdb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -67,7 +67,7 @@ typography: lineHeight: 1.2 letterSpacing: 0.004em headline-zh: - fontFamily: Noto Sans SC + fontFamily: PingFang SC fontSize: 64px fontWeight: 600 lineHeight: 1.14 @@ -107,6 +107,24 @@ typography: fontSize: 13px fontWeight: 400 lineHeight: 1.6 + social-preview-title: + fontFamily: Inter + fontSize: 72px + fontWeight: 600 + lineHeight: 80px + fontVariation: "'opsz' 32" + social-preview-owner: + fontFamily: Inter + fontSize: 26px + fontWeight: 600 + lineHeight: 34px + fontVariation: "'opsz' 28" + social-preview-body: + fontFamily: Inter + fontSize: 30px + fontWeight: 400 + lineHeight: 42px + fontVariation: "'opsz' 32" rounded: none: 0px sm: 6px @@ -289,6 +307,29 @@ components: focus-ring-dark: backgroundColor: "{colors.focus-dark}" width: 2px + social-preview: + backgroundColor: "{colors.surface}" + textColor: "{colors.on-surface}" + typography: "{typography.social-preview-title}" + size: 288px + padding: 80px + social-preview-owner: + textColor: "{colors.accent-text}" + typography: "{typography.social-preview-owner}" + social-preview-about: + textColor: "{colors.on-surface-muted}" + typography: "{typography.social-preview-body}" + social-preview-gap: + width: 64px + height: 16px + profile-header: + size: 128px + profile-member-header: + size: 64px + profile-row: + size: 40px + profile-member-row: + size: 28px --- # Computer MCP @@ -313,7 +354,7 @@ developers and people who want ChatGPT to code and operate their computer for th concrete, unhurried. Density: generous on marketing surfaces, compact in reference material. This file owns visual tokens and rules for every public surface: the App icon, component icons, -README headers, social cards, the organization profile and the website. Product meaning, the +README logos, social previews, the organization profile and the website. Product meaning, the tagline and voice belong to the App's Product Identity document. The App's own interface follows the system appearance and Apple's Human Interface Guidelines; the brand appears only in its icon and About window. @@ -351,10 +392,11 @@ system font stack on the web. Headlines are semibold with slightly tight trackin and navigation are set in the same sans in sentence case. Monospace is reserved for real code: tool names, paths, commands and output. -Apple's font license does not cover brand artwork, so text rendered into images — headers, social -cards and icons — uses bundled open fonts that match the system faces: **Inter** (OFL) for Latin, -**Noto Sans SC** (OFL) for Chinese and **JetBrains Mono** (OFL) for code. The tokens above carry -these artwork faces; web text maps them to the system stack. +Apple's font license does not cover brand artwork, so text rendered into images — social previews +and terminal demos — uses the open fonts bundled in `.github/design/fonts/` with their licenses: +**Inter** (OFL) for Latin and **JetBrains Mono** (OFL) for code. The Latin and code tokens above +carry these artwork faces and web text maps them to the system stack; `headline-zh` is the web's +Chinese headline in PingFang SC. ## Layout @@ -364,19 +406,23 @@ spacing follows a 4px unit. Reference pages use a single 720px reading column. - **Hero:** copy on the left; on the right, the chat card sits high and the computer sits low, with the link leaving the request, bending once and entering the top of the computer. Packets sit on the vertical run of the link. -- **README header (1280 × 320, light and dark):** icon and name on the left, one supporting line; - on the right the request bubble, the link turning down into the top of the computer, and the - member's glyph on its amber screen (the App's screen is plain amber). -- **Social card (1280 × 640):** icon and name at the top left with the headline below. The App card - adds capability chips at the bottom and, on the right, the chat card, the link with its packets - and the computer whose screen shows the command and its passing result. A member card carries its family eyebrow and its repository address, and links a typing bubble into the - computer showing its glyph. +- **README first screen:** centered, in order: the repository's logo from `.github/assets/logo/` + in light and dark, its name, its accepted sentence, its badges and a navigation row, as + `github-readme-design` lays it out. +- **Social preview (1280 × 640):** the icon on the left at the social-preview size, then the + owner, the title and the repository's complete About sentence, rendered by the + `github-readme-design` toolchain from the social-preview components and `profile/catalog.json`. +- **Organization profile:** generated from `profile/catalog.json` with the profile components: + the umbrella mark, the promise and a secondary line, navigation, and the repositories by the + reader's job. - **Website:** compact sticky navigation with the App icon, product name, core links, language - switch and download action; the hero; capability examples for files and commands, CLI, MCP, - Skills, Computer Use and coding-agent hand-off; how hosts connect and how permissions work on each - computer; a dated, sourced comparison with related products; three setup steps with a first safe - tool call; FAQ, closing line and organization footer. The homepage is Chinese by default with a - complete English version. + switch and download action; the hero; three highlights that lead to the sections on worktrees, + the sandbox and permissions; a project with its worktrees; the sandbox boundary on an ink band, + showing what programs can write, read and not reach, network access through the egress proxy, + and Full access beside it; Privacy & Security with a permission request; the plugins with their + icons; one chat connected to two computers; a dated, sourced comparison with related products; + three setup steps with a first tool call; FAQ, closing line and organization footer. The + homepage is Chinese by default with a complete English version. ## Elevation & Depth @@ -395,30 +441,29 @@ layers with continuous corners; component icons take the framework generator's s ## Icon Family -- **Component icons:** plugins and the Homebrew tap are framework slabs, generated from the `icons` - entries in [Iconography](#iconography) with the `apple-framework-icon-design` generator. Each slab - takes the main color of the product it serves, and its three bands deepen from it: Claude's - terracotta, Codex's blue, Cursor's graphite, Computer Use's sky-to-pink, Swift's orange, - Homebrew's amber, and TRAE's black with green. The face carries the App's design grid and one - glyph that follows the product's own mark: the Claude spark, the Codex cloud with a prompt, the - Cursor cube, the Computer Use pointer, the Swift bird, the Homebrew mug and the TRAE pixel face. +- **Component icons:** plugins are framework slabs, generated from the `icons` entries in + [Iconography](#iconography) with the `apple-framework-icon-design` generator. Each slab takes the + main color of the product it serves, and its three bands deepen from it: Claude's terracotta, + Codex's blue, Cursor's graphite, Computer Use's sky-to-pink, Swift's orange, and TRAE's black + with green. The face carries the App's design grid and one glyph that follows the product's own + mark: the Claude spark, the Codex cloud with a prompt, the Cursor cube, the Computer Use pointer, + the Swift bird and the TRAE pixel face. The shared workflows are a slab in the App's violet with + a cycle. - **App icons:** executables have Liquid Glass app icons on the App's violet field and design grid, generated from the `app_icons` entries with the `apple-app-icon-design` generator. The App is an original compact computer in ivory glass with an amber screen and a recessed chin, delivered as an Icon Composer document plus a legacy `AppIcon.icns` for macOS 14 and 15. Apple CLI is four glass tiles in the colors of Notes, Calendar and Mail, the fourth a dark terminal with a prompt. -- **Screen glyph:** in a member's compositions the computer's screen shows its icon's glyph flat in - ink, with inlaid details in the screen's amber: a framework glyph's surface parts, and Apple - CLI's prompt. The Homebrew tap's screen shows the icons of the App and Apple CLI it installs. - Icons never carry the chat card or the link. +- **Umbrella mark and distribution:** the App icon is the organization's mark and avatar. The + Homebrew tap distributes the App and Apple CLI and uses the App icon. - **Never:** SF Symbols or SF Pro inside brand artwork. The swift-sdk fork keeps its upstream identity. ## Iconography App icons are Liquid Glass icons generated from the `app_icons` entries with the -`apple-app-icon-design` generator. Their layers are original artwork in `Brand/app-icon/layers/`, -and both share its design grid. The App's layers are the computer's recessed chin, its amber screen +`apple-app-icon-design` generator. Their layers are original artwork in +`.github/design/icons//layers/`, and both use the App's design grid. The App's layers are the computer's recessed chin, its amber screen light and its ivory glass case; Apple CLI's are its three app tiles, its terminal tile and its prompt. Accepted lint findings, each by design: four groups keep the grid and three materials apart in each icon (the App's chin, screen light and case; Apple CLI's app tiles, terminal glass @@ -442,7 +487,7 @@ face units. "name": "Grid", "layers": [ { - "source": "Brand/app-icon/layers/grid.svg", + "source": ".github/design/icons/computer-mcp/layers/grid.svg", "name": "grid", "fill": { "solid": "#FFFFFF" @@ -460,7 +505,7 @@ face units. "name": "Base", "layers": [ { - "source": "Brand/app-icon/layers/crt-base.svg", + "source": ".github/design/icons/computer-mcp/layers/crt-base.svg", "name": "crt-base", "fill": { "automatic-gradient": "#D8CDB0A6" @@ -486,7 +531,7 @@ face units. "name": "Light", "layers": [ { - "source": "Brand/app-icon/layers/crt-light.svg", + "source": ".github/design/icons/computer-mcp/layers/crt-light.svg", "name": "crt-light", "fill": { "automatic-gradient": "#FFB347" @@ -506,7 +551,7 @@ face units. "name": "Computer", "layers": [ { - "source": "Brand/app-icon/layers/crt-body.svg", + "source": ".github/design/icons/computer-mcp/layers/crt-body.svg", "name": "crt-body", "fill": { "automatic-gradient": "#F6EEDBA6" @@ -543,7 +588,7 @@ face units. "name": "Grid", "layers": [ { - "source": "Brand/app-icon/layers/grid.svg", + "source": ".github/design/icons/computer-mcp/layers/grid.svg", "name": "grid", "fill": { "solid": "#FFFFFF" @@ -561,22 +606,22 @@ face units. "name": "Apps", "layers": [ { - "source": "Brand/app-icon/layers/apple-cli-notes.svg", - "name": "apple-cli-notes", + "source": ".github/design/icons/apple-cli/layers/notes.svg", + "name": "notes", "fill": { "automatic-gradient": "#FFD60A" } }, { - "source": "Brand/app-icon/layers/apple-cli-calendar.svg", - "name": "apple-cli-calendar", + "source": ".github/design/icons/apple-cli/layers/calendar.svg", + "name": "calendar", "fill": { "automatic-gradient": "#FF453A" } }, { - "source": "Brand/app-icon/layers/apple-cli-mail.svg", - "name": "apple-cli-mail", + "source": ".github/design/icons/apple-cli/layers/mail.svg", + "name": "mail", "fill": { "automatic-gradient": "#0A84FF" } @@ -595,8 +640,8 @@ face units. "name": "Terminal", "layers": [ { - "source": "Brand/app-icon/layers/apple-cli-terminal.svg", - "name": "apple-cli-terminal", + "source": ".github/design/icons/apple-cli/layers/terminal.svg", + "name": "terminal", "fill": { "automatic-gradient": "#1D1B26D9" } @@ -621,8 +666,8 @@ face units. "name": "Prompt", "layers": [ { - "source": "Brand/app-icon/layers/apple-cli-prompt.svg", - "name": "apple-cli-prompt", + "source": ".github/design/icons/apple-cli/layers/prompt.svg", + "name": "prompt", "fill": { "automatic-gradient": "#F6EEDB" } @@ -1051,110 +1096,6 @@ face units. ], "glyph_side": "oklch(0.86 0.06 45)" }, - "homebrew-tap": { - "hue": 70, - "bands": 3, - "glyph_rotation": -45, - "texture": { - "d": "M0 -0.6 V0.6 M-0.6 0 H0.6 M-0.25 -0.25 V0.25 M-0.25 -0.25 H0.25 M0.25 -0.25 V0.25 M-0.25 0.25 H0.25 M0.25 0.25 V0.452 Q0.25 0.47 0.268 0.47 H0.31 Q0.47 0.47 0.47 0.31 V0.25 Z M0.25 -0.25 V-0.452 Q0.25 -0.47 0.268 -0.47 H0.31 Q0.47 -0.47 0.47 -0.31 V-0.25 Z M-0.25 0.25 V0.452 Q-0.25 0.47 -0.268 0.47 H-0.31 Q-0.47 0.47 -0.47 0.31 V0.25 Z M-0.25 -0.25 V-0.452 Q-0.25 -0.47 -0.268 -0.47 H-0.31 Q-0.47 -0.47 -0.47 -0.31 V-0.25 Z", - "width": 0.009 - }, - "texture_opacity": 0.16, - "custom_glyph": { - "layers": [ - { - "d": "M0.36 -0.10 H0.60 A0.18 0.18 0 0 1 0.78 0.08 V0.30 A0.18 0.18 0 0 1 0.60 0.48 H0.36", - "stroke": "#FFFFFF", - "width": 0.15 - }, - { - "d": "M-0.64 -0.30 H0.36 V0.62 A0.18 0.18 0 0 1 0.18 0.80 H-0.46 A0.18 0.18 0 0 1 -0.64 0.62 Z", - "fill": "#FFFFFF", - "round": 0.03 - }, - { - "circle": [ - -0.44, - -0.4, - 0.21 - ], - "fill": "#FFFFFF" - }, - { - "circle": [ - -0.12, - -0.5, - 0.25 - ], - "fill": "#FFFFFF" - }, - { - "circle": [ - 0.18, - -0.4, - 0.2 - ], - "fill": "#FFFFFF" - }, - { - "d": "M-0.52 -0.14 H0.24 V0.58 A0.10 0.10 0 0 1 0.14 0.68 H-0.42 A0.10 0.10 0 0 1 -0.52 0.58 Z", - "fill": { - "colors": [ - "oklch(0.84 0.15 82)", - "oklch(0.72 0.16 62)" - ], - "from": [ - 0, - -0.14 - ], - "to": [ - 0, - 0.68 - ] - }, - "surface": true - }, - { - "circle": [ - -0.26, - 0.34, - 0.05 - ], - "fill": "#FFFFFF", - "surface": true - }, - { - "circle": [ - 0.02, - 0.12, - 0.04 - ], - "fill": "#FFFFFF", - "surface": true - }, - { - "circle": [ - -0.08, - 0.48, - 0.035 - ], - "fill": "#FFFFFF", - "surface": true - } - ] - }, - "glyph_scale": 0.34, - "face_colors": [ - "oklch(0.86 0.15 88)", - "oklch(0.76 0.16 72)" - ], - "band_colors": [ - "oklch(0.68 0.15 64)", - "oklch(0.59 0.13 57)", - "oklch(0.5 0.11 52)" - ], - "glyph_side": "oklch(0.88 0.05 75)" - }, "plugin-trae": { "hue": 155, "bands": 3, @@ -1188,6 +1129,29 @@ face units. ], "glyph_side": "oklch(0.55 0.14 158)", "texture_color": "oklch(0.8 0.08 158)" + }, + "github-workflows": { + "hue": 275, + "bands": 3, + "glyph_rotation": -45, + "texture": { + "d": "M0 -0.6 V0.6 M-0.6 0 H0.6 M-0.25 -0.25 V0.25 M-0.25 -0.25 H0.25 M0.25 -0.25 V0.25 M-0.25 0.25 H0.25 M0.25 0.25 V0.452 Q0.25 0.47 0.268 0.47 H0.31 Q0.47 0.47 0.47 0.31 V0.25 Z M0.25 -0.25 V-0.452 Q0.25 -0.47 0.268 -0.47 H0.31 Q0.47 -0.47 0.47 -0.31 V-0.25 Z M-0.25 0.25 V0.452 Q-0.25 0.47 -0.268 0.47 H-0.31 Q-0.47 0.47 -0.47 0.31 V0.25 Z M-0.25 -0.25 V-0.452 Q-0.25 -0.47 -0.268 -0.47 H-0.31 Q-0.47 -0.47 -0.47 -0.31 V-0.25 Z", + "width": 0.009 + }, + "texture_opacity": 0.16, + "glyph": "cycle", + "glyph_scale": 0.34, + "face_colors": [ + "oklch(0.66 0.17 282)", + "oklch(0.53 0.23 276)" + ], + "band_colors": [ + "oklch(0.47 0.23 276)", + "oklch(0.41 0.21 277)", + "oklch(0.35 0.18 278)" + ], + "glyph_color": "#FFFFFF", + "glyph_side": "oklch(0.82 0.06 280)" } } } @@ -1198,12 +1162,9 @@ face units. - **Primary button:** violet fill, white label in the interface sans, 12px radius. - **Secondary button:** raised surface with a 1px line border. - **Chat card:** raised surface, 20px radius, a title row naming the chat, and the request as an - amber bubble with ink text. Each member's request is one it serves, from `Brand/config.json`. -- **Command card:** the code block carrying real commands behind amber prompts, with a 1px line - border in dark mode; it takes the chat card's place for the Homebrew tap, which people reach - from a terminal. + amber bubble with ink text. - **Computer:** the App icon's machine drawn flat — ivory case with a hairline edge, recessed chin, - amber screen carrying the member glyph or, for the App, the command and its result. Capability + amber screen carrying the work in progress, such as a command and its result. Capability chips beside the composition name Files, Shell, CLI, MCP, Skills and Computer Use. - **Link and packets:** a link-rail band with a 6px amber line; packets are raised pills naming the tool, such as `file.write`. @@ -1215,8 +1176,7 @@ face units. - Do keep amber for the active path and violet for actions and links; never set amber text on a light surface. -- Do show both the request — a chat, or a command card — and the computer whenever the link - appears. +- Do show both the chat and the computer whenever the link appears. - Do draw the computer from the App icon's layers; never redraw or restyle it per surface. - Do render every image text with the bundled OFL fonts, in both light and dark. - Don't use gradients on cards or the computer, or rotate them. @@ -1233,9 +1193,9 @@ keyboard operation and reduced-motion support. ## Iteration Guide -Change tokens here first, then regenerate every asset with the organization renderer and deliver -them through each repository's brand lock. A new component gets its own Iconography entry — a slab in the -colors of the product it serves, with a glyph that follows that product's mark — and the same glyph -on the computer's screen. Check the App icon in -every macOS rendition, including Clear and Tinted, before delivery. The repository README lists the -render, check and delivery commands. +Change tokens here first. Regenerate icons with their generators and the organization profile and +each repository's social preview with the `github-readme-design` toolchain, then deliver the +consumed copies through each repository's own pull request and record them in the icon's +`provenance.json`. A new component gets its own Iconography entry: a slab in the colors of the +product it serves, with a glyph that follows that product's mark. Check the App icon in every macOS +rendition, including Clear and Tinted, before delivery. The repository README lists the commands. diff --git a/Documentation/Reference/PluginRelease.md b/Documentation/Reference/PluginRelease.md deleted file mode 100644 index 42cd299..0000000 --- a/Documentation/Reference/PluginRelease.md +++ /dev/null @@ -1,164 +0,0 @@ -# Plugin release workflow - -The reusable [workflow](../../.github/workflows/plugin-release.yml) builds and -publishes reproducible plugin packages. [VERSIONING.md](../../VERSIONING.md#plugin-release-contents) -owns the contents, receipt schema, provenance and recovery rules. Codex uses its -documented native artifact promotion exception. - -## Caller - -Add `.github/workflows/release.yml` in the plugin repository, substituting the -full standards commit SHA and the exact artifact name from its `ci.yml`: - -```yaml -name: Release -on: - workflow_dispatch: - inputs: - ref: - description: Version tag, or commit for a dry run - type: string - required: true - publish: - description: Publish the verified release - type: boolean - default: false -permissions: {} -jobs: - release: - permissions: - contents: write - actions: read - uses: computer-mcp/.github/.github/workflows/plugin-release.yml@ # master - with: - ref: ${{ inputs.ref }} - publish: ${{ inputs.publish }} - build: python3 Scripts/build_package.py - ci_artifact: - notify-catalog: - needs: release - if: needs.release.outputs.published == 'true' - permissions: {} - uses: ./.github/workflows/notify-catalog.yml - secrets: - CATALOG_APP_PRIVATE_KEY: ${{ secrets.CATALOG_APP_PRIVATE_KEY }} -``` - -`notify-catalog.yml` is the caller repository's existing reusable notification -workflow. It receives the catalog App private key; the release workflow has no -secret inputs. The caller grants the permission ceiling needed by these jobs: - -| Job | Permissions | Data flow | -| --- | --- | --- | -| `source` | `contents: read`, `actions: read` | Load the pinned helper, resolve the source commit and upload the helper | -| `build` | `{}` | Check out that commit, execute the caller's build and upload its archives | -| `verify` | `contents: read`, `actions: read` | On a fresh runner, check the helper pin, read the source manifest and both artifacts as data, compare archive bytes and assemble the bundle | -| `publish` | `contents: write` | Download the verified bundle by ID, recompute every digest and publish | - -The build job receives no secrets, does not persist checkout credentials and -has no access to the verification runner's files or job outputs. Only its -archive artifact ID crosses into verification. Verification and publication -execute only the pinned helper; neither checks out or executes caller code. - -Plugin repositories must enable GitHub release immutability in their repository -settings before publishing. The helper checks `/repos/{owner}/{repo}/immutable-releases` -through the API before any Release write and fails clearly unless it reports -`enabled: true`. Server enforcement protects immutable release assets even if -another publisher makes the draft public after a draft precheck. Enabling the -setting does not lock previously published releases. The helper also requires the -Release's own `immutable: true` when it finds an existing public release and in -the response after publishing a draft. A public but mutable release fails with -an error requiring the repository owner to handle it; the workflow does not -change it or report it as published. - -| Input | Contract | -| --- | --- | -| `ref` | Required. `vX.Y.Z` (including `-alpha.N`, `-beta.N`, `-rc.N`) for publication; a version tag or commit for a dry run | -| `publish` | Boolean, default `false` | -| `build` | Required shell command run at the source checkout; a quoted absolute output-directory argument is appended. It must create that directory with only the plugin ZIP archives | -| `ci_artifact` | Required exact artifact name from `ci.yml`; `${commit}` expands to the resolved full commit SHA. For matrix jobs, choose one exact matrix member | -| `runner` | Build runner, default `ubuntu-24.04`; source resolution, verification and publication use fresh `ubuntu-24.04` runners. Python 3.13 is supplied for source resolution, building and verification | - -| Output | Contract | -| --- | --- | -| `published` | String `true` after publishing or verifying a complete public release with `immutable: true`; `false` for a dry run | -| `release_id` | Published GitHub Release ID, empty for a dry run | -| `tag` | Verified version tag, empty for an untagged commit dry run | - -The release workflow must be pinned by its full commit SHA. It loads its helper -from that immutable workflow revision, found in the caller run's -`referenced_workflows` metadata. An embedded SHA-256 checks the helper both -before verification and before publication; caller code cannot supply an -executable publisher. Verification passes the uploaded bundle's exact artifact -ID and a SHA-256 map for all release files through job outputs. Publication -recomputes and compares every file digest, validates the receipt and checks -`SHA256SUMS` before any Release write. - -## Run and recover - -1. Obtain successful default-branch push CI for the candidate commit. Set - `ci_artifact` to its exact artifact name, including any matrix suffix. -2. Dispatch with that commit and `publish: false`. Inspect the - `plugin-release--` Actions artifact: `assets/` contains the - verified ZIPs, deterministic `release-receipt.json` and `SHA256SUMS`. - An untagged dry run reports the signed annotated tag still required. -3. Perform the plugin repository's manual archive acceptance. Create the signed - annotated version tag on that same commit. Record the manual results and - limits in a draft Release's notes, targeting the full commit SHA. -4. Dispatch with the version tag and `publish: true`. The workflow verifies the - tag signature through GitHub, the manifest version and `plugin_api`, the CI - source and archive set, then rebuilds and assembles the same release files. - It creates a draft if none exists, uploads the receipt first, completes and - verifies all assets, and publishes. The dependent job requests catalog - reconciliation only after `published` is `true`. - -A failed publication can be retried with **Re-run failed jobs**. If verification -already succeeded, the publish job downloads the same bundle artifact ID and -uses the saved digest outputs; it does not derive a new artifact name from the -retry's run attempt. - -When verification runs again, a complete receipt in a draft freezes its -provenance. Verification validates the recorded historical CI run attempt -through the API: `.github/workflows/ci.yml`, event `push`, the repository's -default branch, status `completed`, conclusion `success` and the exact source -commit. It downloads the recorded artifact by ID, checks its digest and creation -within that attempt, compares it against the isolated build and reproduces the -same receipt and checksums. It refuses an invalid or unavailable recorded -attempt. A new CI attempt or a new release workflow pin does not change frozen -provenance. If receipt upload itself was incomplete, no source has been frozen -yet. - -Missing assets are uploaded; matching completed assets are retained. Only -expected `starter` assets in a draft are removed and uploaded again. A completed -asset conflict requires inspecting the unpublished draft and its intended -source; the workflow does not replace it. If the recorded CI artifact expired -or was removed, delete the unpublished draft, obtain fresh successful CI -artifacts and dispatch again. This deliberately starts a new assembly with new -provenance. Published tags and assets remain immutable. - -An already public release must report `immutable: true`. It is checked against -its frozen receipt and exact asset set and reported as published without writes. -Verification still rebuilds the archives; it can compare against the public -archive bytes after CI retention has expired. Notification failure needs only a -rerun of the notification job or a manual dispatch of `notify-catalog.yml`; -publication need not be repeated. - -## Maintaining the shared workflow - -Release logic lives in `Scripts/plugin_release.py`, behind a four-operation -GitHub API interface. The offline tests use an in-memory API and inject failures -before and after draft creation, every asset upload, starter deletion and -publication. They also exercise provenance recovery, immutable public releases, -source checks, archive comparison, malicious build isolation, publish-only -retries and the workflow's helper boundary. The fake server fixes each release's -immutability when it is published: older mutable releases stay mutable when the -repository setting is enabled. It rejects mutations of immutable releases, -including visibility changes between draft prechecks and mutation requests. - -When changing the helper, update `PLUGIN_RELEASE_SHA256` in the workflow to its -SHA-256. The repository CI runs the offline suite and verifies that digest: - -```sh -python3 -m unittest discover -s Tests -actionlint .github/workflows/*.yml -``` diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 1ffb12d..75df9b3 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -11,7 +11,24 @@ release procedure and written exceptions. MCP Swift SDK fork's default branch is its maintained release branch. - Changes land through pull requests with passing checks and are squash merged. Merged branches are deleted automatically. -- Commits are signed. +- Maintainer commits use the owner's name `showxu` and noreply email + `10173746+showxu@users.noreply.github.com`, and are SSH-signed as described + under [Signing](#signing). Commits that GitHub makes, such as squash merges + and the Computer MCP Updater App's proposals, are signed by GitHub. + Dependabot may author changes. +- Pull request titles and commit subjects follow Conventional Commits: + `type(scope)!: subject`, with optional scope and `!`. Types are `build`, + `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style` + and `test`. +- Commits and pull requests carry no tool or agent attribution, such as + generated-by lines or co-author trailers for tools; real human co-authors may + be credited. +- Committed content contains no paths from a maintainer's machine, such as + `/Users//`, and no references into local `.agent/` task state. +- The [source policy](https://github.com/computer-mcp/github-workflows#source-policy) + checks pull request titles, added content and added or modified paths, and + requires default-branch commits to be verified and committed by GitHub or the + owner. - Each public repository enforces these rules with a `default branch` ruleset that has no bypass: it blocks deletion and force pushes, requires signed commits, and accepts only squash-merged pull requests whose required checks @@ -28,6 +45,26 @@ release procedure and written exceptions. pull request opened with the workflow token waits for manual approval before its checks run. +## Signing + +Configure signing in each repository checkout with the owner's registered SSH +signing key; do not change global Git configuration: + +```sh +git config user.name showxu +git config user.email 10173746+showxu@users.noreply.github.com +git config gpg.format ssh +git config user.signingkey ~/.ssh/signing_key.pub +git config commit.gpgsign true +git config tag.gpgsign true +git config gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers +``` + +Replace the key and allowed-signers paths with the local signing +configuration. The allowed-signers file holds the signer's email followed by +the SSH public key, which is registered as a signing key on GitHub. Verify +commits with `git verify-commit HEAD`. + ## Licenses - The App, the plugins and the website use the Functional Source License 1.1 @@ -47,10 +84,12 @@ refresh the plugin catalog through the Computer MCP Release Notifier GitHub App. ## GitHub settings -`python3 Scripts/check-settings.py` compares the organization's live settings -with this section and the rulesets above, and lists every difference. Run it -with an organization owner's `gh` login after changing a repository setting, -an App or an Actions credential, and update this section in the same change. +The [organization check](https://github.com/computer-mcp/github-workflows#organization-check) +compares the organization's live settings with this section and the rulesets +above, and lists every difference; it also compares `profile/catalog.json` +with the public repositories and their About sentences. Run it with an +organization owner's `gh` login after changing a repository setting, an App or +an Actions credential, and update this section in the same change. - Every repository allows only squash merges and deletes merged branches. Its workflows, like the organization default, get a read-only token that cannot @@ -78,8 +117,16 @@ an App or an Actions credential, and update this section in the same change. - The root `README.md` is the entry point. `Documentation/Architecture/` holds current design truth, `Documentation/Reference/` operating detail and `Documentation/Decisions/` accepted rationale. -- Scripts live in `Scripts/`. Executable scripts use kebab-case names with an - extension; imported Python modules use snake_case. +- Repository-specific automation lives in lowercase `scripts/`, created only + when needed. Executable scripts use kebab-case names with an extension; + imported Python modules use snake_case. Reusable workflows and the checks + more than one repository needs live in + [computer-mcp/github-workflows](https://github.com/computer-mcp/github-workflows), + which repositories pin by full commit SHA; this repository holds only rules, + data, documents and thin callers. +- GitHub presentation assets live in `.github/assets/`: `logo/` with + `logo-light.png` and `logo-dark.png`, `social-preview/social-preview.png`, + and `terminal/` or `screenshots/` where the product has them. - `AGENTS.md` carries the shared first-principles and canonical-artifact sections, then routes agents through the repository's own documents. `.agent/` stays local and untracked. diff --git a/PluginRuntime/README.md b/PluginRuntime/README.md deleted file mode 100644 index 1ff544f..0000000 --- a/PluginRuntime/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Shared Python plugin runtime - -[plugin_runtime.py](plugin_runtime.py) is the canonical stdlib runtime shared by -the Claude, Cursor and TRAE plugins. It provides stdio MCP, bounded JSON and -schema validation, process ownership and cleanup, cancellation, event buffers -and native-version checks. Each plugin owns its vendor adapter, version -baseline and supported Python versions. - -## Adoption and updates - -From the organization standards checkout at the revision the plugin will pin: - -```sh -python3 Scripts/plugin-runtime.py sync -python3 Scripts/plugin-runtime.py check -``` - -`sync` copies the canonical file to `bin/plugin_runtime.py` and checks it; -`check` is read-only and fails if that file is missing or differs byte for byte. -Both commands require an existing checkout directory and keep delivery paths -inside it. - -Adapters whose native assertion lives in `cli-tree.json` import -`expected_version` and pass its result to `verify_version`: - -```python -from plugin_runtime import expected_version, verify_version - -verify_version(executable, expected_version(TREE), job, cwd, retain_process) -``` - -`expected_version(tree)` returns the `stdout` of its `--version` executable -check. A missing assertion raises `Failure('invalid_configuration', 'Package -lacks its native version assertion')` before any process starts. Claude and -Cursor use this form. TRAE keeps its direct version-string call: - -```python -verify_version(executable, NATIVE_VERSION, job, cwd, retain_process) -``` - -`verify_version(executable, expected, job, cwd, retain_process=None)` runs the -owned native `--version` process, compares its exact output with `expected`, -and preserves the runtime's deadlines, output bound, cancellation and cleanup. -The optional callback retains the version process for adapter shutdown. - -Add a job to the plugin's CI, pinning the reusable workflow by the full -40-character commit SHA of the organization revision that supplied the file, -as consumers pin `brand-check.yml`: - -```yaml -jobs: - runtime: - uses: computer-mcp/.github/.github/workflows/plugin-runtime.yml@ # master -``` - -The workflow has no inputs or secrets. With read-only `contents` permission, -it checks out the caller without persisting credentials and compares -`bin/plugin_runtime.py` with the SHA-256 embedded in that pinned workflow. -The copied runtime and workflow pin are updated together in the consumer's -pull request, with its own adapter tests passing. - -## Maintaining the canonical runtime - -After changing the canonical file, update `PLUGIN_RUNTIME_SHA256` in -`.github/workflows/plugin-runtime.yml` to its SHA-256. Check the tools and -embedded digest: - -```sh -python3 -m unittest discover -s Tests -python3 Scripts/plugin-runtime.py check-workflow -``` - -The organization CI runs both commands. Consumer behavior is verified with the -plugins' own suites; those repositories own their test entry points. diff --git a/PluginRuntime/plugin_runtime.py b/PluginRuntime/plugin_runtime.py deleted file mode 100644 index 65975a8..0000000 --- a/PluginRuntime/plugin_runtime.py +++ /dev/null @@ -1,722 +0,0 @@ -"""Private stdio, process ownership and validation primitives for this plugin.""" -from collections import deque -from dataclasses import dataclass -import hashlib -import json -import math -import os -from pathlib import Path -import queue -import select -import selectors -import shutil -import signal -import subprocess -import sys -import threading -import time -import tomllib -import uuid - -MAX_FRAME = 1_048_576 -MAX_RESULT = 196_608 -SUPPORTED_MCP = ('2024-11-05', '2025-03-26', '2025-06-18') -WORK_URI = 'computer-mcp://runtime/work/v1' -WORK_METADATA = 'io.github.computer-mcp/work' -CONTINUATION_METADATA = 'io.github.computer-mcp/continuation' -WORK_INVOCATION = 'io.github.computer-mcp/work-invocation' - - -class Failure(Exception): - def __init__(self, code, message): - super().__init__(message) - self.code = code - - -def encoded(value): - return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8') - - -def decoded(raw): - def unique(pairs): - value = {} - for k, v in pairs: - if k in value: - raise ValueError('Duplicate JSON object key') - value[k] = v - return value - def invalid_number(_): - raise ValueError('Non-finite JSON number') - def finite_float(text): - value = float(text) - if not math.isfinite(value): - raise ValueError('JSON floating-point number is not finite') - return value - value = json.loads(raw.decode('utf-8'), object_pairs_hook=unique, parse_constant=invalid_number, parse_float=finite_float) - pending = [value] - while pending: - item = pending.pop() - if isinstance(item,str): item.encode('utf-8') - elif isinstance(item,dict): - pending.extend(item.keys()) - pending.extend(item.values()) - elif isinstance(item,list): pending.extend(item) - return value - - -def schema(properties, required=()): - return {'type':'object', 'properties':properties, 'required':list(required), 'additionalProperties':False} - - -def text_schema(limit=131072, minimum=1): - return {'type':'string', 'minLength':minimum, 'maxLength':limit} - - -def integer_schema(minimum, maximum, default=None): - result = {'type':'integer', 'minimum':minimum, 'maximum':maximum} - if default is not None: - result['default'] = default - return result - - -def validate(value, spec, location='arguments', depth=0): - if depth > 20: - raise Failure('invalid_arguments', 'Input exceeds the nesting limit') - kind = spec.get('type') - valid = { - 'object':lambda: isinstance(value, dict), - 'array':lambda: isinstance(value, list), - 'string':lambda: isinstance(value, str), - 'integer':lambda: type(value) is int, - 'number':lambda: type(value) is int or (type(value) is float and math.isfinite(value)), - 'boolean':lambda: type(value) is bool, - 'null':lambda: value is None, - } - if kind in valid and not valid[kind](): - raise Failure('invalid_arguments', f'{location} must be {kind}') - if 'enum' in spec and value not in spec['enum']: - raise Failure('invalid_arguments', f'{location} is not an allowed value') - if isinstance(value, str): - if '\0' in value or len(value.encode('utf-8')) > MAX_FRAME: - raise Failure('invalid_arguments', f'{location} contains NUL or exceeds its byte bound') - if not spec.get('minLength', 0) <= len(value) <= spec.get('maxLength', MAX_FRAME): - raise Failure('invalid_arguments', f'{location} exceeds its length bounds') - if type(value) in (int, float): - if (type(value) is float and not math.isfinite(value)) or value < spec.get('minimum', -math.inf) or value > spec.get('maximum', math.inf): - raise Failure('invalid_arguments', f'{location} exceeds its numeric bounds') - if isinstance(value, dict): - props = spec.get('properties', {}) - missing = set(spec.get('required', ())) - value.keys() - if missing: - raise Failure('invalid_arguments', f'{location} is missing required fields: {", ".join(sorted(missing))}') - if spec.get('additionalProperties') is False and set(value) - props.keys(): - raise Failure('invalid_arguments', f'{location} has unknown fields') - for k, v in value.items(): - validate(v, props.get(k, {}), f'{location}.{k}', depth+1) - if isinstance(value, list): - if not spec.get('minItems', 0) <= len(value) <= spec.get('maxItems', 4096): - raise Failure('invalid_arguments', f'{location} exceeds its item bound') - for v in value: - validate(v, spec.get('items', {}), location+'[]', depth+1) - - -class Job: - def __init__(self, work_invocation=None): - self.cancelled = threading.Event() - self.work_invocation = work_invocation - def cancel(self): - self.cancelled.set() - def check(self): - if self.cancelled.is_set(): - raise Failure('cancelled', 'Operation cancelled') - - -class LineReader: - """Bound bytes before parsing, and bound queued frames before admitting more.""" - def __init__(self, stream, limit=MAX_FRAME): - self.stream, self.limit = stream, limit - self.queue = queue.Queue(maxsize=8) - self.stopped = threading.Event() - self.thread = threading.Thread(target=self._read, daemon=True) - self.thread.start() - def _put(self, item): - while not self.stopped.is_set(): - try: - self.queue.put(item, timeout=.05) - return - except queue.Full: - pass - def _read(self): - buf = bytearray() - try: - with selectors.DefaultSelector() as selector: - selector.register(self.stream, selectors.EVENT_READ) - while not self.stopped.is_set(): - if not selector.select(.05): - continue - chunk = os.read(self.stream.fileno(), 16384) - if not chunk: - if buf: - self._put(bytes(buf)) - break - buf.extend(chunk) - while True: - index = buf.find(b'\n') - if index < 0: - if len(buf) > self.limit: - raise Failure('frame_too_large', 'A protocol line exceeds its byte bound') - break - if index+1 > self.limit: - raise Failure('frame_too_large', 'A protocol line exceeds its byte bound') - self._put(bytes(buf[:index+1])) - del buf[:index+1] - except Exception as error: - self._put(error if isinstance(error, Failure) else Failure('stream_failed', 'Protocol stream capture failed')) - finally: - self._put(None) - def next(self, deadline, job=None): - while True: - if job: - job.check() - remaining = deadline-time.monotonic() - if remaining <= 0: - raise Failure('timeout', 'Operation exceeded its deadline') - try: - item = self.queue.get(timeout=min(.05, remaining)) - except queue.Empty: - continue - if isinstance(item, Exception): - raise item - return item - def close(self): - self.stopped.set() - self.thread.join(timeout=.5) - - -def write_bytes(fd, data, deadline, cancelled=None): - view = memoryview(data) - with selectors.DefaultSelector() as selector: - selector.register(fd, selectors.EVENT_WRITE) - while view: - if cancelled is not None and cancelled.is_set(): - raise Failure('cancelled', 'Operation cancelled') - remaining = deadline-time.monotonic() - if remaining <= 0: - raise Failure('timeout', 'Protocol writer exceeded its deadline') - if not selector.select(min(.05, remaining)): - continue - try: - count = os.write(fd, view) - except BlockingIOError: - continue - except (BrokenPipeError, OSError) as error: - raise Failure('connection_closed', 'Protocol peer closed its input') from error - view = view[count:] - - -def vendor_environment(): - # Private host provenance and inherited callback authority do not belong to a vendor. - return {k:v for k,v in os.environ.items() if not k.startswith('COMPUTER_MCP_')} - - -def supervise(read_fd, receipt_fd, command): - """Pin the vendor group until retirement and report cleanup separately from exit.""" - stopped = threading.Event() - for signum in (signal.SIGTERM, signal.SIGINT): - signal.signal(signum, lambda *_: stopped.set()) - child = None - exit_code = 70 - confirmed = False - try: - child = subprocess.Popen(command, close_fds=True, start_new_session=True) - while not stopped.is_set(): - status = os.waitid(os.P_PID, child.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) - if status is not None: - break - ready, _, _ = select.select([read_fd], [], [], .05) - if ready and not os.read(read_fd, 1): - break - except Exception: - os.write(2, b'Owned vendor process could not start or be supervised.\n') - finally: - try: - if child is not None: - # Do not reap the leader before signalling its group: its PID pins ownership. - for sig, delay in ((signal.SIGTERM, .15), (signal.SIGKILL, 0)): - try: - os.killpg(child.pid, sig) - except ProcessLookupError: - pass - except PermissionError: - report = subprocess.run( - ['/bin/ps', '-g', str(child.pid), '-o', 'pid=,stat='], - capture_output=True, text=True, timeout=1) - rows = [line.split() for line in report.stdout.splitlines() if line.strip()] - if report.stderr.strip() or report.returncode not in (0, 1) or any( - len(row) != 2 or not row[1].startswith('Z') for row in rows - ): - raise Failure('cleanup_unconfirmed', 'Owned group could not be retired') - if delay: - time.sleep(delay) - child.wait(timeout=2) - exit_code = child.returncode if child.returncode >= 0 else 128-child.returncode - confirmed = True - except Exception: - exit_code = 70 - os.write(2, b'Owned vendor process cleanup is unconfirmed.\n') - finally: - try: - os.write(receipt_fd, encoded({'cleanup_confirmed': confirmed})+b'\n') - except OSError: - pass - os.close(receipt_fd) - os.close(read_fd) - return exit_code - - -class Process: - """A supervisor owns vendor descendants and watches an adapter-only lifeline.""" - def __init__(self, command, cwd): - read_fd, self.life = os.pipe() - self.receipt, receipt_write = os.pipe() - try: - self.child = subprocess.Popen( - [sys.executable, str(Path(__file__).resolve()), '--supervise', str(read_fd), str(receipt_write), '--', *command], - cwd=cwd, env=vendor_environment(), close_fds=True, pass_fds=(read_fd, receipt_write), - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - start_new_session=True, - ) - except BaseException: - os.close(self.life) - os.close(self.receipt) - raise - finally: - os.close(read_fd) - os.close(receipt_write) - self.writer_lock = threading.Lock() - self.close_lock = threading.Lock() - self.closed = False - self.cleanup_failure = None - self.stderr = bytearray() - self.stderr_truncated = False - self.stop_stderr = threading.Event() - self.output, self.stderr_thread = None, None - try: - os.set_blocking(self.child.stdin.fileno(), False) - self.output = LineReader(self.child.stdout) - self.stderr_thread = threading.Thread(target=self._stderr, daemon=True) - self.stderr_thread.start() - except BaseException: - self.close() - raise - def _stderr(self): - try: - with selectors.DefaultSelector() as selector: - selector.register(self.child.stderr, selectors.EVENT_READ) - while not self.stop_stderr.is_set(): - if not selector.select(.05): - continue - raw = os.read(self.child.stderr.fileno(), 16384) - if not raw: - return - capacity = 65536-len(self.stderr) - self.stderr.extend(raw[:capacity]) - self.stderr_truncated |= len(raw) > capacity - except (ValueError, OSError): - pass - def send(self, message, deadline, job=None): - data = encoded(message)+b'\n' - if len(data) > MAX_FRAME: - raise Failure('frame_too_large', 'Outbound protocol frame exceeds its byte bound') - with self.writer_lock: - if self.closed: - raise Failure('connection_closed', 'Vendor process is closed') - write_bytes(self.child.stdin.fileno(), data, deadline, job.cancelled if job else None) - def end_input(self): - with self.writer_lock: - if not self.child.stdin.closed: - self.child.stdin.close() - def read(self, deadline, job=None): - return self.output.next(deadline, job) - def finish(self, deadline, job=None): - while True: - if job: - job.check() - remaining = deadline-time.monotonic() - if remaining <= 0: - raise Failure('timeout', 'Vendor did not exit before its deadline') - try: - return self.child.wait(timeout=min(.05,remaining)) - except subprocess.TimeoutExpired: - pass - @staticmethod - def confirm_cleanup(descriptor): - try: - raw = os.read(descriptor, 4096) - value = decoded(raw) - if not isinstance(value, dict) or value.get('cleanup_confirmed') is not True: - raise ValueError('Cleanup not confirmed') - except (ValueError, UnicodeError, OSError, RecursionError) as error: - raise Failure('cleanup_unconfirmed', 'Supervisor did not confirm owned process cleanup') from error - - def close(self): - with self.close_lock: - if self.closed: - if self.cleanup_failure: - raise self.cleanup_failure - return - self.closed = True - try: - try: - os.close(self.life) - try: - self.child.wait(timeout=3) - except subprocess.TimeoutExpired: - self.child.terminate() - try: - self.child.wait(timeout=2) - except subprocess.TimeoutExpired as error: - self.child.kill() - self.child.wait(timeout=2) - raise Failure('cleanup_unconfirmed', 'Vendor supervisor did not confirm shutdown') from error - self.confirm_cleanup(self.receipt) - finally: - os.close(self.receipt) - if self.output is not None: self.output.close() - self.stop_stderr.set() - if self.stderr_thread is not None and self.stderr_thread.ident is not None: - self.stderr_thread.join(timeout=.5) - with self.writer_lock: - for stream in (self.child.stdin, self.child.stdout, self.child.stderr): - stream.close() - except Exception as error: - self.cleanup_failure = error if isinstance(error,Failure) else Failure('cleanup_unconfirmed','Owned process cleanup could not be confirmed') - if self.cleanup_failure is error: raise - raise self.cleanup_failure from error - - -def resolve_executable(explicit, environment_key, default): - candidate = explicit or os.environ.get(environment_key) - if candidate: - if not os.path.isabs(candidate): - raise Failure('invalid_configuration', 'The configured vendor executable must be absolute') - else: - candidate = shutil.which(default) - if not candidate or not os.path.isfile(candidate) or not os.access(candidate, os.X_OK): - raise Failure('dependency_unavailable', f'Install {default} separately or configure --executable') - return candidate - - -def load_package(entry): - root = Path(entry).resolve().parent.parent - manifest = tomllib.loads((root/'computer-mcp-plugin.toml').read_text()) - tree = json.loads((root/'cli-tree.json').read_text()) - return manifest, tree - - -def expected_version(tree): - expected = next((x.get('stdout') for x in tree.get('executable_checks', []) if x.get('args') == ['--version']), None) - if expected is None: - raise Failure('invalid_configuration', 'Package lacks its native version assertion') - return expected - - -def verify_version(executable, expected, job, cwd, retain_process=None): - process = Process([executable, '--version'], cwd) - deadline = time.monotonic()+5 - output = bytearray() - try: - if retain_process is not None: retain_process(process) - process.end_input() - while True: - raw = process.read(deadline, job) - if raw is None: - break - output.extend(raw) - if len(output) > 65536: - raise Failure('incompatible_vendor', 'Native version output exceeded its bound') - if process.finish(deadline, job) != 0 or bytes(output) != expected.encode(): - raise Failure('incompatible_vendor', 'Vendor version differs from the verified plugin baseline') - finally: - process.close() - - -class EventBuffer: - def __init__(self, max_events=256, max_bytes=262144): - self.lock = threading.Lock() - self.items = deque() - self.max_events, self.max_bytes = max_events, max_bytes - self.bytes, self.cursor, self.omitted = 0, 0, 0 - def append(self, value): - data = encoded(value) - with self.lock: - self.cursor += 1 - if len(data) > self.max_bytes//2: - value = {'omitted':True, 'reason':'event_byte_bound', 'bytes':len(data), 'sha256':hashlib.sha256(data).hexdigest()} - data = encoded(value) - self.omitted += 1 - self.items.append((self.cursor, value, len(data))) - self.bytes += len(data) - while len(self.items) > self.max_events or self.bytes > self.max_bytes: - self.bytes -= self.items.popleft()[2] - def page(self, after=0, limit=128, max_bytes=196608): - with self.lock: - if after > self.cursor: - raise Failure('invalid_cursor', 'Cursor is newer than this event stream') - oldest = self.items[0][0] if self.items else self.cursor+1 - rows, total, next_cursor = [], 0, after - for cursor, value, size in self.items: - if cursor <= after: - continue - row = {'cursor':cursor,'event':value} - row_size = len(encoded(row)) - if not rows and row_size > max_bytes: - row = {'cursor':cursor,'event':{'omitted':True,'reason':'page_byte_bound','bytes':size}} - row_size = len(encoded(row)) - if len(rows) >= limit or total+row_size > max_bytes: - break - rows.append(row) - total += row_size - next_cursor = cursor - return {'events':rows,'next_cursor':next_cursor,'oldest_cursor':oldest,'has_more':next_cursor1024 - or any(ord(c)<32 or 127<=ord(c)<=159 for c in kind) - or not isinstance(argument,str) or not argument - or len(('/'+argument.replace('~','~0').replace('/','~1')).encode('utf-8'))>1024 - or any(ord(c)<32 or 127<=ord(c)<=159 for c in argument) - or argument not in self.input_schema.get('required',[]) - or self.input_schema.get('properties',{}).get(argument,{}).get('type') not in {'string','integer'}): - raise ValueError('Continuation requires a resource kind and required scalar handle') - def definition(self): - read_only = self.risk == 'read-only' - metadata = {'io.github.computer-mcp/risk':self.risk} - if self.continuation is not None: - kind, argument = self.continuation - pointer = '/' + argument.replace('~','~0').replace('/','~1') - metadata[CONTINUATION_METADATA] = {'format_version':1,'selectors':[{'kind':kind,'handles':{'id':pointer}}]} - return {'name':self.name,'description':self.description,'inputSchema':self.input_schema, - '_meta':metadata, - 'annotations':{'readOnlyHint':read_only,'destructiveHint':self.risk in {'destructive','full-shell'}, - 'idempotentHint':read_only,'openWorldHint':not read_only}} - - -class MCPServer: - def __init__(self, name, version, tools, shutdown, work=None): - self.name, self.version = name, version - self.tools = {t.name:t for t in tools} - self.shutdown = shutdown - self.stop = threading.Event() - self.jobs, self.threads = {}, set() - self.lock, self.write_lock = threading.Lock(), threading.Lock() - self.initialized = False - self.work = work - self.work_instance = str(uuid.uuid4()) - self.work_revision = 0 - self.work_last = None - def work_resource(self): - resources = self.work() - if not isinstance(resources,list) or len(resources)>1024: - raise Failure('work_unavailable','Complete work observation is unavailable') - keys = set() - rows = [] - def identifier(value): - return isinstance(value,str) and 0=2**63: - raise Failure('work_unavailable','Work observation revision is exhausted') - snapshot = {'format_version':1,'instance_id':self.work_instance,'revision':revision,'resources':rows} - body = encoded(snapshot) - if len(body)>524288: - raise Failure('work_unavailable','Complete work observation exceeds its byte bound') - self.work_revision, self.work_last = revision, rows - return {'contents':[{'uri':WORK_URI,'mimeType':'application/json','text':body.decode('utf-8')}]} - def emit(self, message): - try: - raw = encoded(message)+b'\n' - except (ValueError,UnicodeError,RecursionError): - try: - self.id_key(message.get('id')) - identifier = message['id'] - except ValueError: - identifier = None - raw = encoded({'jsonrpc':'2.0','id':identifier,'error':{'code':-32603,'message':'Response cannot be encoded'}})+b'\n' - if len(raw) > MAX_FRAME: - raw = encoded({'jsonrpc':'2.0','id':message.get('id'),'error':{'code':-32603,'message':'Response exceeds the protocol bound'}})+b'\n' - try: - with self.write_lock: - write_bytes(1, raw, time.monotonic()+5, self.stop) - except (Failure, OSError): - self.stop.set() - def rpc_error(self, request_id, code, message): - self.emit({'jsonrpc':'2.0','id':request_id,'error':{'code':code,'message':message}}) - def call(self, request_id, key, tool, arguments, job): - try: - validate(arguments, tool.input_schema) - job.check() - value = tool.handler(arguments, job) - body = encoded(value) - if len(body) > 524288: - raise Failure('result_too_large', 'Result exceeds its structured byte bound; use event pagination') - is_error = bool(value.get('is_error', False)) if isinstance(value, dict) else False - except Failure as error: - value, is_error = {'error':{'code':error.code,'message':str(error)[:1024]}}, True - except Exception: - value, is_error = {'error':{'code':'internal_error','message':'Adapter operation failed; no automatic replay was attempted'}}, True - summary = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',',':')) - if len(summary.encode()) > 8192: - summary = 'Structured result available in structuredContent.result; events are separately paginated.' - self.emit({'jsonrpc':'2.0','id':request_id,'result':{'content':[{'type':'text','text':summary}],'structuredContent':{'result':value},'isError':is_error}}) - with self.lock: - self.jobs.pop(key, None) - self.threads.discard(threading.current_thread()) - @staticmethod - def id_key(value): - if type(value) not in (int,str) or (isinstance(value,str) and (not value or len(value)>256)): - raise ValueError('Invalid request ID') - if isinstance(value,str): value.encode('utf-8') - return type(value).__name__, value - def dispatch(self, message): - if not isinstance(message, dict) or message.get('jsonrpc') != '2.0' or not isinstance(message.get('method'),str): - self.rpc_error(None,-32600,'Invalid JSON-RPC request') - return - method, params = message['method'], message.get('params', {}) - if 'id' not in message: - if method == 'notifications/cancelled' and isinstance(params,dict): - try: key = self.id_key(params.get('requestId')) - except ValueError: return - with self.lock: job = self.jobs.get(key) - if job: job.cancel() - return - request_id = message['id'] - try: key = self.id_key(request_id) - except ValueError: - self.rpc_error(None,-32600,'Invalid request ID'); return - with self.lock: active = key in self.jobs - if active: - self.rpc_error(request_id,-32600,'Request ID is already active'); return - if not isinstance(params,dict): - self.rpc_error(request_id,-32602,'Parameters must be an object'); return - if method == 'initialize': - if self.initialized: - self.rpc_error(request_id,-32600,'Connection is already initialized'); return - requested = params.get('protocolVersion') - client = params.get('clientInfo') - if not isinstance(requested,str) or not isinstance(params.get('capabilities'),dict) or not isinstance(client,dict) or not all(isinstance(client.get(k),str) and client[k] for k in ('name','version')): - self.rpc_error(request_id,-32602,'Initialize requires protocolVersion, capabilities and typed clientInfo'); return - self.initialized = True - capabilities = {'tools':{'listChanged':False}} - if self.work is not None: capabilities['resources'] = {'subscribe':False,'listChanged':False} - self.emit({'jsonrpc':'2.0','id':request_id,'result':{'protocolVersion':requested if requested in SUPPORTED_MCP else SUPPORTED_MCP[-1],'capabilities':capabilities,'serverInfo':{'name':self.name,'version':self.version}}}) - elif method == 'ping': - self.emit({'jsonrpc':'2.0','id':request_id,'result':{}}) - elif not self.initialized: - self.rpc_error(request_id,-32002,'Initialize the MCP connection first') - elif method == 'tools/list': - if params.get('cursor'): - self.rpc_error(request_id,-32602,'This finite catalog has no continuation cursor'); return - definitions = [t.definition() for t in self.tools.values()] - if self.work is not None: - for definition in definitions: - definition['_meta'][WORK_METADATA] = {'format_version':1,'uri':WORK_URI} - self.emit({'jsonrpc':'2.0','id':request_id,'result':{'tools':definitions}}) - elif method == 'resources/list' and self.work is not None: - if params.get('cursor'): - self.rpc_error(request_id,-32602,'This finite resource catalog has no continuation cursor'); return - self.emit({'jsonrpc':'2.0','id':request_id,'result':{'resources':[{'uri':WORK_URI,'name':'Runtime work','mimeType':'application/json','description':'Complete connection-owned live work and cleanup observation.'}]}}) - elif method == 'resources/read' and self.work is not None: - if params.get('uri') != WORK_URI: - self.rpc_error(request_id,-32602,'Unknown resource'); return - try: result = self.work_resource() - except Failure as error: - self.rpc_error(request_id,-32000,str(error)); return - except Exception: - self.rpc_error(request_id,-32000,'Complete work observation is unavailable'); return - self.emit({'jsonrpc':'2.0','id':request_id,'result':result}) - elif method == 'tools/call': - name = params.get('name') - if not isinstance(name,str) or name not in self.tools: - self.rpc_error(request_id,-32602,'Unknown tool'); return - args = params.get('arguments', {}) - origin = None - if self.work is not None: - meta = params.get('_meta',{}) - if not isinstance(meta,dict): - self.rpc_error(request_id,-32602,'Tool metadata must be an object'); return - if WORK_INVOCATION in meta: - try: origin = str(uuid.UUID(meta[WORK_INVOCATION])) - except (ValueError,TypeError,AttributeError): - self.rpc_error(request_id,-32602,'Work invocation reference must be a UUID'); return - with self.lock: - if key in self.jobs: - self.rpc_error(request_id,-32600,'Request ID is already active'); return - if len(self.jobs)>=16: - self.rpc_error(request_id,-32000,'Concurrent request capacity reached'); return - job = Job(work_invocation=origin) - thread = threading.Thread(target=self.call,args=(request_id,key,self.tools[name],args,job)) - self.jobs[key] = job - self.threads.add(thread) - thread.start() - else: - self.rpc_error(request_id,-32601,'Method not found') - def serve(self): - os.set_blocking(1,False) - for sig in (signal.SIGTERM,signal.SIGINT): - signal.signal(sig,lambda *_:self.stop.set()) - reader = LineReader(sys.stdin.buffer) - try: - while not self.stop.is_set(): - try: raw = reader.next(time.monotonic()+.1) - except Failure as error: - if error.code=='timeout': continue - self.rpc_error(None,-32600,str(error)); break - if raw is None: break - try: message = decoded(raw) - except (ValueError,UnicodeError,RecursionError): - self.rpc_error(None,-32700,'Invalid JSON'); continue - self.dispatch(message) - finally: - self.stop.set() - reader.close() - with self.lock: - jobs, threads = list(self.jobs.values()), list(self.threads) - for job in jobs: job.cancel() - self.shutdown() - for thread in threads: thread.join(timeout=5) - - -if __name__ == '__main__': - if len(sys.argv) < 6 or sys.argv[1] != '--supervise' or sys.argv[4] != '--': - raise SystemExit('Private process supervisor requires an inherited lifeline') - raise SystemExit(supervise(int(sys.argv[2]), int(sys.argv[3]), sys.argv[5:])) diff --git a/README.md b/README.md index 5c24c5d..9e5b150 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,85 @@ # Computer MCP organization This repository owns the organization profile, the visual identity and the -conventions every Computer MCP repository shares. +conventions every Computer MCP repository shares. It holds rules, data and +documents; reusable workflows and their checks live in +[computer-mcp/github-workflows](https://github.com/computer-mcp/github-workflows). -- `profile/README.md` is the public organization entry point. -- `MAINTENANCE.md` holds the cross-repository conventions. The community files - (`CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `GOVERNANCE.md`, `SECURITY.md`, - `SUPPORT.md`, `.github/ISSUE_TEMPLATE/` and the pull request template) apply - to every repository without its own version. +- `profile/README.md` is the public organization entry point, generated from + `profile/catalog.json`. The catalog lists each public repository with its + outcome sentence, icon, kind, title and About sentence, grouped by the + reader's job. `profile/assets/` holds the organization's mark and avatar. +- `MAINTENANCE.md` holds the cross-repository conventions and the declared + GitHub settings. The community files (`CODE_OF_CONDUCT.md`, + `CONTRIBUTING.md`, `GOVERNANCE.md`, `SECURITY.md`, `SUPPORT.md`, + `.github/ISSUE_TEMPLATE/` and the pull request template) apply to every + repository without its own version. - [VERSIONING.md](VERSIONING.md) holds shared version, tag, release-record, plugin compatibility and dependency rules. -- `Scripts/check-settings.py` compares the organization's live GitHub settings, - Apps and Actions credentials with `MAINTENANCE.md`. - `DESIGN.md` is the design system: tokens, components, the icon family and - composition rules. -- `Brand/` holds the renderer, its configuration, the App icon's original layers - in `Brand/app-icon/layers/` and the rendered `Exports/` with their - `manifest.json`. -- `.github/workflows/brand-check.yml` is the reusable workflow each repository - calls to verify its brand lock. -- [PluginRuntime/](PluginRuntime/README.md) holds the shared Python plugin - runtime and adoption instructions. `Scripts/plugin-runtime.py` syncs and - checks copies; `.github/workflows/plugin-runtime.yml` verifies consumers - against the runtime digest fixed by their workflow pin. -- `.github/workflows/plugin-release.yml` verifies and publishes reproducible - plugin releases. The [consumer guide](Documentation/Reference/PluginRelease.md) - describes its caller, inputs, outputs and recovery operations. + composition rules, with the icon entries in its Iconography section. +- `.github/design/icons//` holds each icon's original layers, where it + has any, and its `provenance.json`; `.github/design/fonts/` holds the bundled + fonts with their licenses. +- `.github/assets/social-preview/social-preview.png` is this repository's own + social preview. Product meaning and copy belong to the main repository's [ProductIdentity](https://github.com/computer-mcp/computer-mcp/blob/master/Documentation/Architecture/ProductIdentity.md). -Text rendered into images (`Brand/config.json` `copy` and `members`) must match it. +Text in the catalog and in rendered images must match it, and each +repository's `about` equals its GitHub About. -## Updating the brand +## Updating icons -The icons come from two generators in the -[showxu/skills](https://github.com/showxu/skills) repository. Install Icon -Composer from Xcode and set `APP_ICONS_SCRIPT` to the `apple-app-icon-design` -generator (`skills/apple-app-icon-design/scripts/app_icons.py`), which renders -the App icons; it finds Icon Composer's `ictool` in the selected Xcode -installation, and `IC_TOOL` selects another export-capable renderer. Set -`FRAMEWORK_ICONS_SCRIPT` to the `apple-framework-icon-design` generator -(`skills/apple-framework-icon-design/scripts/design_icons.py`), which renders -the component icons. The manifest records the generators' SHA-256 digests next -to the renderer versions. Python 3 and `rsvg-convert` are also required. +Icons come from two generators in the +[showxu/skills](https://github.com/showxu/skills) repository: +`skills/apple-app-icon-design/scripts/app_icons.py` renders the App icons +through Icon Composer from Xcode, and +`skills/apple-framework-icon-design/scripts/design_icons.py` renders the +component icons. Python 3 and `rsvg-convert` are also required. -1. Edit `DESIGN.md` or `Brand/config.json`. -2. Render: `npm ci --prefix Brand`, `npx --prefix Brand playwright install --only-shell chromium`, - then `node Brand/render.mjs`. -3. Preview each macOS rendition of the App icon with Icon Composer's `ictool - Brand/Exports/app/AppIcon.icon --export-image --platform macOS --rendition - --output-file Brand/.cache/app-icon-preview.png --width 1024 --height 1024 - --scale 1 --design-generation ` - for Default, Dark, ClearLight, ClearDark, TintedLight and TintedDark, using - the `design_generation` from the Iconography entry. Review the other App - icons with the generator's contact sheet: - `python3 "$APP_ICONS_SCRIPT" DESIGN.md --name --style liquid-glass --out Brand/.cache/`. -4. Check: `npm --prefix Brand run lint`, `python3 "$APP_ICONS_SCRIPT" DESIGN.md --name --lint` - for each App icon, `python3 "$FRAMEWORK_ICONS_SCRIPT" DESIGN.md --lint` and - `python3 Brand/brand.py check`. -5. Commit `DESIGN.md`, `Brand/` and the exports together. +1. Edit the entry in DESIGN.md's Iconography section, and the layers in + `.github/design/icons//layers/` for an App icon. +2. Lint: `python3 /apple-framework-icon-design/scripts/design_icons.py DESIGN.md --lint` + and `python3 /apple-app-icon-design/scripts/app_icons.py DESIGN.md --name --lint`. +3. Render into an ignored directory: `design_icons.py DESIGN.md --out --png`, + or `app_icons.py DESIGN.md --name --style liquid-glass --out `, + and review the App icon in every macOS rendition with Icon Composer. +4. Copy each consumed file into its repository through that repository's + pull request and record it in the icon's `provenance.json`: the + repository, path, generator output path and SHA-256, with the skill + commit, command, renderer and verification date. -## Delivering to a repository +Member logos are `.github/assets/logo/logo-light.png` and `logo-dark.png`. A +component icon uses the generator's PNG for both; an App icon uses its macOS +Default and Dark renditions. The Homebrew tap and the website use the App +icon. -`python3 Brand/brand.py sync ` copies the files that repository uses, -removes files its previous lock delivered and no longer needs, rewrites its -`.github/brand/brand.lock.json`, and verifies the lock. Images that ship with a -repository's packaged documentation live in `Documentation/Brand/`; social -previews stay in `.github/brand/`. Commit the result through a pull request in -that repository. Each repository's CI calls the shared check: +## Profile and social previews -```yaml -jobs: - brand: - uses: computer-mcp/.github/.github/workflows/brand-check.yml@ +The `github-readme-design` toolchain in the skills repository +(`skills/github-readme-design/scripts`) renders the profile and every social +preview from DESIGN, the catalog and the bundled fonts. Install it into an +ignored directory with `npm ci --ignore-scripts`, then: + +```sh +node /src/cli.ts design lint --design DESIGN.md +node /src/cli.ts profile render --design DESIGN.md --catalog profile/catalog.json --out profile/README.md +node /src/cli.ts social-preview render --design DESIGN.md --fonts .github/design/fonts \ + --catalog profile/catalog.json --repository --icon \ + --out /.github/assets/social-preview/social-preview.png ``` +`profile check` and `social-preview check` compare the committed files +without overwriting them. A repository's social preview is committed in that +repository and rendered with `--repository .github` for this one. + ## Social previews and avatar GitHub has no API for a repository's social preview or the organization -avatar. After a repository's brand pull request merges, upload its -`.github/brand/social.png` in the repository's settings under Social preview. -A public repository's preview is current when the image at its GraphQL -`openGraphImageUrl` has the same SHA-256 as that file. Upload -`profile/brand/avatar.png` as the organization avatar in the organization's -settings. +avatar. After a repository's pull request merges, upload its +`.github/assets/social-preview/social-preview.png` in the repository's +settings under Social preview. A public repository's preview is current when +the image at its GraphQL `openGraphImageUrl` has the same SHA-256 as that +file. Upload `profile/assets/avatar.png` as the organization avatar in the +organization's settings. diff --git a/Scripts/check-settings.py b/Scripts/check-settings.py deleted file mode 100755 index b441d50..0000000 --- a/Scripts/check-settings.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -"""Compare the organization's live GitHub settings with MAINTENANCE.md. - - python3 Scripts/check-settings.py list every difference, using an organization owner's gh login -""" - -import json -import re -import subprocess -import sys -from pathlib import Path - -ORG = "computer-mcp" -MAINTENANCE = Path(__file__).resolve().parent.parent / "MAINTENANCE.md" -SECTION = "## GitHub settings" -COLUMNS = ["App", "Permissions", "Installed on", "Credentials", "Stored in"] -READ_ONLY = {"default_workflow_permissions": "read", "can_approve_pull_request_reviews": False} -MERGES = { - "allow_squash_merge": True, - "allow_merge_commit": False, - "allow_rebase_merge": False, - "delete_branch_on_merge": True, -} -RULES = {"deletion", "non_fast_forward", "required_signatures", "pull_request", "required_status_checks"} -MERGE_QUEUE_REPOS = {"computer-mcp"} - - -class ApiError(Exception): - pass - - -def gh(path): - result = subprocess.run(["gh", "api", path], capture_output=True, text=True) - if result.returncode: - raise ApiError(f"gh api {path}: {result.stderr.strip() or result.stdout.strip()}") - return json.loads(result.stdout) - - -def gh_list(path): - """Every item of a paginated list endpoint.""" - items, page = [], 1 - separator = "&" if "?" in path else "?" - while True: - batch = gh(f"{path}{separator}per_page=100&page={page}") - items += batch - if len(batch) < 100: - return items - page += 1 - - -def expected_apps(): - text = MAINTENANCE.read_text() - if SECTION not in text: - raise SystemExit(f"MAINTENANCE.md has no {SECTION!r} section") - section = text.split(SECTION, 1)[1].split("\n## ", 1)[0] - rows = [line.strip().strip("|").split("|") for line in section.splitlines() if line.startswith("|")] - if not rows or [cell.strip() for cell in rows[0]] != COLUMNS: - raise SystemExit(f"MAINTENANCE.md: the GitHub Apps table must have the columns {', '.join(COLUMNS)}") - apps = {} - for row in rows[2:]: - cells = [re.findall(r"`([^`]+)`", cell) for cell in row] - try: - (slug,), permissions, installed, (prefix,), stored = cells - permissions = dict(permission.split(": ") for permission in permissions) - except ValueError: - raise SystemExit(f"MAINTENANCE.md: malformed GitHub Apps row: |{'|'.join(row)}|") - apps[slug] = {"permissions": permissions, "installed": sorted(installed), "prefix": prefix, "stored": stored} - return apps - - -def ruleset_problems(repo): - ids = [ruleset["id"] for ruleset in gh(f"repos/{ORG}/{repo}/rulesets?includes_parents=false") if ruleset["name"] == "default branch"] - if not ids: - return [f"{repo}: no default branch ruleset"] - ruleset = gh(f"repos/{ORG}/{repo}/rulesets/{ids[0]}") - rules = {rule["type"]: rule.get("parameters", {}) for rule in ruleset["rules"]} - problems = [] - if ruleset["enforcement"] != "active": - problems.append(f"{repo}: default branch ruleset is {ruleset['enforcement']}") - if ruleset["conditions"]["ref_name"]["include"] != ["~DEFAULT_BRANCH"]: - problems.append(f"{repo}: default branch ruleset targets {ruleset['conditions']['ref_name']['include']}") - if ruleset["bypass_actors"]: - problems.append(f"{repo}: default branch ruleset has bypass actors") - if missing := RULES - rules.keys(): - problems.append(f"{repo}: default branch ruleset lacks {', '.join(sorted(missing))}") - else: - if rules["pull_request"]["allowed_merge_methods"] != ["squash"]: - problems.append(f"{repo}: default branch ruleset allows {rules['pull_request']['allowed_merge_methods']}") - if not rules["required_status_checks"]["required_status_checks"]: - problems.append(f"{repo}: default branch ruleset requires no checks") - if repo in MERGE_QUEUE_REPOS: - queue = rules.get("merge_queue") - if queue is None: - problems.append(f"{repo}: default branch ruleset has no merge queue") - else: - if queue["merge_method"] != "SQUASH": - problems.append(f"{repo}: merge queue uses {queue['merge_method']}") - if queue["grouping_strategy"] != "ALLGREEN": - problems.append(f"{repo}: merge queue groups with {queue['grouping_strategy']}") - if rules["required_status_checks"]["strict_required_status_checks_policy"]: - problems.append(f"{repo}: required checks also require an up-to-date branch") - return problems - - -def merge_queue_problems(repo, notes): - """Every merge queue in any ruleset, including inherited ones, must be the declared one.""" - try: - listed = gh_list(f"repos/{ORG}/{repo}/rulesets?includes_parents=true") - except ApiError: - notes.append(f"{repo}: rulesets not readable, so merge queues were not checked") - return [] - problems = [] - for item in listed: - try: - ruleset = gh(f"{item['_links']['self']['href'].split('/api.github.com/', 1)[-1]}") - except ApiError: - notes.append(f"{repo}: ruleset {item['name']!r} not readable, so its merge queue was not checked") - continue - if not any(rule["type"] == "merge_queue" for rule in ruleset["rules"]): - continue - declared = repo in MERGE_QUEUE_REPOS and ruleset["name"] == "default branch" and ruleset["source"] == f"{ORG}/{repo}" - if not declared: - problems.append(f"{repo}: ruleset {ruleset['name']!r} from {ruleset['source']} has an undeclared merge queue") - return problems - - -def app_problems(apps, notes): - problems = [] - installed = {installation["app_slug"]: installation for installation in gh(f"orgs/{ORG}/installations?per_page=100")["installations"]} - for slug in sorted(installed.keys() - apps.keys()): - problems.append(f"{slug}: installed but not listed in MAINTENANCE.md") - for slug, app in apps.items(): - installation = installed.get(slug) - if not installation: - problems.append(f"{slug}: not installed") - continue - permissions = {name: access for name, access in installation["permissions"].items() if name != "metadata"} - if permissions != app["permissions"]: - problems.append(f"{slug}: permissions {permissions}, expected {app['permissions']}") - if installation["events"]: - problems.append(f"{slug}: subscribes to {', '.join(installation['events'])}") - if installation["repository_selection"] != "selected": - problems.append(f"{slug}: installed on all repositories") - continue - try: - listed = gh(f"user/installations/{installation['id']}/repositories?per_page=100")["repositories"] - except ApiError: - notes.append( - f"{slug}: installed repositories not checked; GitHub lists them only to a personal access token, " - "so compare them on the App's installation page" - ) - continue - repositories = sorted(repository["name"] for repository in listed) - if repositories != app["installed"]: - problems.append(f"{slug}: installed on {', '.join(repositories)}, expected {', '.join(app['installed'])}") - return problems - - -def main(): - apps = expected_apps() - credentials = {} - for app in apps.values(): - for repo in app["stored"]: - secrets, variables = credentials.setdefault(repo, (set(), set())) - secrets.add(f"{app['prefix']}_PRIVATE_KEY") - variables.add(f"{app['prefix']}_CLIENT_ID") - - try: - problems, notes = [], [] - if (flow := gh(f"orgs/{ORG}/actions/permissions/workflow")) != READ_ONLY: - problems.append(f"organization: workflow token {flow}") - for kind in ("secrets", "variables"): - if names := [item["name"] for item in gh(f"orgs/{ORG}/actions/{kind}?per_page=100")[kind]]: - problems.append(f"organization: Actions {kind} {', '.join(names)}") - - repos = {repo["name"]: repo for repo in gh_list(f"orgs/{ORG}/repos?type=all") if not repo["archived"]} - listed = set(credentials).union(*(app["installed"] for app in apps.values())) - for repo in sorted(listed - repos.keys()): - problems.append(f"{repo}: listed in MAINTENANCE.md but not in the organization") - for repo in sorted(repos): - detail = gh(f"repos/{ORG}/{repo}") - if (merges := {name: detail[name] for name in MERGES}) != MERGES: - problems.append(f"{repo}: merge settings {merges}") - if (flow := gh(f"repos/{ORG}/{repo}/actions/permissions/workflow")) != READ_ONLY: - problems.append(f"{repo}: workflow token {flow}") - for kind, expected in zip(("secrets", "variables"), credentials.get(repo, (set(), set()))): - names = {item["name"] for item in gh(f"repos/{ORG}/{repo}/actions/{kind}?per_page=100")[kind]} - if names != expected: - problems.append(f"{repo}: Actions {kind} {sorted(names)}, expected {sorted(expected)}") - if not detail["private"]: - problems += ruleset_problems(repo) - problems += merge_queue_problems(repo, notes) - problems += app_problems(apps, notes) - except ApiError as error: - raise SystemExit(str(error)) - - print("\n".join(problems + notes) or "GitHub settings match MAINTENANCE.md", flush=True) - if problems: - return 1 - if notes: - print("Other GitHub settings match MAINTENANCE.md", flush=True) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/Scripts/plugin-runtime.py b/Scripts/plugin-runtime.py deleted file mode 100755 index 77030bb..0000000 --- a/Scripts/plugin-runtime.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Deliver and verify the shared Computer MCP Python plugin runtime. - - python3 Scripts/plugin-runtime.py sync - python3 Scripts/plugin-runtime.py check - python3 Scripts/plugin-runtime.py check-workflow -""" - -import argparse -import hashlib -from pathlib import Path -import re -import shutil -import sys - -ROOT = Path(__file__).resolve().parent.parent -CANONICAL = ROOT / "PluginRuntime/plugin_runtime.py" -WORKFLOW = ROOT / ".github/workflows/plugin-runtime.yml" -TARGET = "bin/plugin_runtime.py" - - -def digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def runtime_path(checkout): - root = Path(checkout).resolve() - if not root.is_dir(): - raise ValueError(f"Checkout is not a directory: {checkout}") - path = (root / TARGET).resolve() - if not path.is_relative_to(root): - raise ValueError(f"Runtime path escapes the checkout: {TARGET}") - return path - - -def check(checkout): - path = runtime_path(checkout) - if not path.is_file(): - raise ValueError(f"Missing plugin runtime: {TARGET}") - if digest(path) != digest(CANONICAL): - raise ValueError(f"Plugin runtime differs from the canonical file: {TARGET}") - print("Plugin runtime matches the canonical file", flush=True) - return 0 - - -def sync(checkout): - path = runtime_path(checkout) - path.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(CANONICAL, path) - return check(checkout) - - -def check_workflow(): - values = re.findall(r"^ +PLUGIN_RUNTIME_SHA256: (.+)$", WORKFLOW.read_text(), re.MULTILINE) - if len(values) != 1 or not re.fullmatch(r"'[0-9a-f]{64}'", values[0]): - raise ValueError("Workflow must embed exactly one quoted PLUGIN_RUNTIME_SHA256 digest") - if values[0][1:-1] != digest(CANONICAL): - raise ValueError("Workflow PLUGIN_RUNTIME_SHA256 differs from the canonical runtime") - print("Workflow digest matches the canonical runtime", flush=True) - return 0 - - -def main(): - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - commands = parser.add_subparsers(dest="command", required=True) - for command in ("sync", "check"): - commands.add_parser(command).add_argument("checkout") - commands.add_parser("check-workflow") - args = parser.parse_args() - try: - if args.command == "check-workflow": - return check_workflow() - return sync(args.checkout) if args.command == "sync" else check(args.checkout) - except (OSError, ValueError) as error: - print(str(error), file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/Scripts/plugin_release.py b/Scripts/plugin_release.py deleted file mode 100644 index 3a185a6..0000000 --- a/Scripts/plugin_release.py +++ /dev/null @@ -1,581 +0,0 @@ -"""Verify reproducible plugin archives and publish their verified release bundle. - -The GitHub boundary has four operations: get JSON, list paginated JSON, -download bytes, and send a mutation. Tests supply an in-memory implementation. -""" - -import argparse -import base64 -from datetime import datetime -import hashlib -import io -import json -import os -from pathlib import Path, PurePosixPath -import re -import stat -import sys -import tomllib -from urllib.error import HTTPError -from urllib.parse import quote, urlencode, urlsplit -from urllib.request import HTTPRedirectHandler, Request, build_opener -import zipfile - - -RECEIPT = "release-receipt.json" -SUMS = "SHA256SUMS" -WORKFLOW = "computer-mcp/.github/.github/workflows/plugin-release.yml@" -SHA = r"[0-9a-f]{40}" -HASH = r"[0-9a-f]{64}" -VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\.[1-9][0-9]*)?" -ARCHIVE = r"[A-Za-z0-9][A-Za-z0-9_.-]*\.zip" - - -class ReleaseError(ValueError): - pass - - -class APIError(ReleaseError): - def __init__(self, status, path): - self.status = status - super().__init__(f"GitHub API returned {status}: {path}") - - -def require(condition, message): - if not condition: - raise ReleaseError(message) - - -def sha256(data): - return hashlib.sha256(data).hexdigest() - - -def canonical(value): - return (json.dumps(value, sort_keys=True, indent=2, ensure_ascii=True) + "\n").encode() - - -def positive(value): - return type(value) is int and value > 0 - - -def digest_value(value): - require(isinstance(value, str) and re.fullmatch("sha256:" + HASH, value), - "Expected a GitHub SHA-256 digest") - return value[7:] - - -class SafeRedirect(HTTPRedirectHandler): - def redirect_request(self, request, fp, code, message, headers, new_url): - require(urlsplit(new_url).scheme == "https", "Refusing a non-HTTPS redirect") - redirected = super().redirect_request(request, fp, code, message, headers, new_url) - if redirected is not None: - redirected.remove_header("Authorization") - return redirected - - -class GitHub: - """REST transport; credentials are sent only to the GitHub API or uploader.""" - - def __init__(self, token): - self.token = token - self.opener = build_opener(SafeRedirect()) - - def _request(self, method, path, data=None, content_type="application/json", binary=False): - host = "https://uploads.github.com" if method == "POST" and "/assets?" in path else "https://api.github.com" - require(path.startswith("/repos/") and not path.startswith("//"), "Invalid GitHub API path") - headers = {"Authorization": f"Bearer {self.token}", "X-GitHub-Api-Version": "2022-11-28", - "Accept": "application/octet-stream" if binary else "application/vnd.github+json", - "Content-Type": content_type} - body = data if isinstance(data, bytes) else json.dumps(data).encode() if data is not None else None - try: - with self.opener.open(Request(host + path, data=body, headers=headers, method=method), timeout=60) as response: - content = response.read() - except HTTPError as error: - raise APIError(error.code, path) from None - return content if binary else json.loads(content) if content else None - - def get(self, path): - return self._request("GET", path) - - def list(self, path, key=None): - result = [] - for page in range(1, 10001): - separator = "&" if "?" in path else "?" - response = self.get(f"{path}{separator}per_page=100&page={page}") - items = response[key] if key else response - require(isinstance(items, list), "Invalid GitHub list response") - result.extend(items) - if len(items) < 100: - return result - raise ReleaseError("GitHub pagination exceeded its limit") - - def download(self, path): - return self._request("GET", path, binary=True) - - def send(self, method, path, data=None, content_type="application/json"): - return self._request(method, path, data, content_type) - - -def repository_path(repository): - require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository), "Invalid repository") - return f"/repos/{repository}" - - -def resolve(api, repository, ref, publish): - root = repository_path(repository) - if re.fullmatch("v" + VERSION, ref): - tag = api.get(f"{root}/git/ref/tags/{quote(ref, safe='')}") - require(tag["object"]["type"] == "tag", "Release requires an annotated tag") - annotated = api.get(f"{root}/git/tags/{tag['object']['sha']}") - require(annotated.get("tag") == ref and annotated["object"]["type"] == "commit", - "Annotated tag must point directly to the release commit") - verification = annotated.get("verification", {}) - require(verification.get("verified") is True and verification.get("reason") == "valid", - "Tag signature must pass GitHub verification") - commit = annotated["object"]["sha"] - tag_name = ref - else: - require(not publish, "Publishing requires a signed annotated vX.Y.Z tag") - require(re.fullmatch(r"[0-9a-f]{7,40}", ref), "Dry-run ref must be a version tag or commit") - commit = api.get(f"{root}/commits/{ref}")["sha"] - tag_name = None - require(re.fullmatch(SHA, commit), "Invalid resolved commit") - return commit, tag_name - - -def manifest_info(manifest, tag): - plugin_id = manifest.get("id") - version = manifest.get("version") - require(isinstance(plugin_id, str) and re.fullmatch(r"[a-z0-9][a-z0-9._-]*", plugin_id), - "Manifest requires a valid plugin id") - require(isinstance(version, str) and re.fullmatch(VERSION, version), "Manifest requires a valid version") - require(tag is None or tag == "v" + version, "Manifest version differs from the tag") - require(positive(manifest.get("plugin_api")), "Manifest plugin_api must be a positive integer") - compatibility = manifest.get("compatibility", {}) - require(isinstance(compatibility, dict), "Manifest compatibility must be a table") - declared = compatibility.get("artifacts") - names = None - if declared is not None: - require(isinstance(declared, list) and declared, "Manifest artifacts must be a nonempty array") - names = [item.get("name") if isinstance(item, dict) else None for item in declared] - require(all(isinstance(name, str) and re.fullmatch(ARCHIVE, name) for name in names), - "Manifest artifact names must be ZIP basenames") - require(len(set(names)) == len(names), "Manifest artifact names must be unique") - names = sorted(names) - return {"plugin_id": plugin_id, "version": version, "plugin_api": manifest["plugin_api"], - "compatibility": compatibility}, names - - -def archive_names(names, declared): - require(bool(names), "No plugin archives found") - require(len(names) == len(set(names)) and all(re.fullmatch(ARCHIVE, name) for name in names), - "Archive names must be unique ZIP basenames") - require(sorted(names) == declared if declared is not None else len(names) == 1, - "Archives differ from compatibility.artifacts (or the required single archive)") - - -def zip_entries(data): - result = {} - with zipfile.ZipFile(io.BytesIO(data)) as archive: - for entry in archive.infolist(): - path = PurePosixPath(entry.filename) - require(not path.is_absolute() and ".." not in path.parts and "\\" not in entry.filename - and "\x00" not in entry.filename, "Unsafe ZIP member") - require(entry.filename not in result, "Duplicate ZIP member") - mode = entry.external_attr >> 16 - require(stat.S_IFMT(mode) in (0, stat.S_IFREG, stat.S_IFDIR), "ZIP contains a special file") - if not entry.is_dir(): - result[entry.filename] = archive.read(entry) - return result - - -def ci_archives(data, declared): - contents = zip_entries(data) - archives = {} - for path, content in contents.items(): - if path.endswith(".zip"): - name = PurePosixPath(path).name - require(name not in archives, "Duplicate CI archive basename") - archives[name] = content - archive_names(list(archives), declared) - return archives - - -def check_run(run, repository, branch, commit): - require(run.get("event") == "push" and run.get("head_branch") == branch - and run.get("head_sha") == commit and run.get("path") == ".github/workflows/ci.yml" - and run.get("head_repository", {}).get("full_name") == repository, - "CI must be this repository's default-branch push run of ci.yml for the exact commit") - - -def select_ci(api, repository, commit, artifact_pattern): - root = repository_path(repository) - branch = api.get(root)["default_branch"] - query = urlencode({"event": "push", "branch": branch, "head_sha": commit}) - runs = api.list(f"{root}/actions/workflows/ci.yml/runs?{query}", "workflow_runs") - candidates = [] - for run in runs: - try: - check_run(run, repository, branch, commit) - except ReleaseError: - continue - if run.get("status") == "completed" and run.get("conclusion") == "success": - candidates.append(run) - require(candidates, "No successful authoritative CI run found") - run = api.get(f"{root}/actions/runs/{max(candidates, key=lambda item: item['id'])['id']}") - check_run(run, repository, branch, commit) - require(run.get("status") == "completed" and run.get("conclusion") == "success" - and positive(run.get("run_attempt")), "Latest CI attempt must have succeeded") - attempt = api.get(f"{root}/actions/runs/{run['id']}/attempts/{run['run_attempt']}") - check_run(attempt, repository, branch, commit) - require(attempt.get("id") == run["id"] and attempt.get("run_attempt") == run["run_attempt"] - and attempt.get("status") == "completed" and attempt.get("conclusion") == "success", - "Latest CI attempt must have succeeded") - name = artifact_pattern.replace("${commit}", commit) - require(name and "${" not in name, "ci_artifact must be an exact name, optionally containing ${commit}") - # The artifacts endpoint spans attempts. Creation within the latest attempt - # excludes outputs retained from a previous attempt or a partial rerun. - started = datetime.fromisoformat(attempt["run_started_at"].replace("Z", "+00:00")) - finished = datetime.fromisoformat(attempt["updated_at"].replace("Z", "+00:00")) - artifacts = api.list(f"{root}/actions/runs/{run['id']}/artifacts", "artifacts") - matching = [item for item in artifacts if item.get("name") == name - and started <= datetime.fromisoformat(item["created_at"].replace("Z", "+00:00")) <= finished] - require(len(matching) == 1, "Expected exactly one named CI artifact from the latest attempt") - artifact = matching[0] - require(not artifact.get("expired"), "Selected CI artifact has expired; rerun CI before releasing") - digest_value(artifact.get("digest")) - require(positive(artifact.get("id")), "Invalid CI artifact ID") - return {"run_id": run["id"], "run_attempt": run["run_attempt"], - "artifacts": [{"id": artifact["id"], "digest": artifact["digest"]}]} - - -def get_ci_archives(api, repository, commit, ci, declared, recovering=False): - root = repository_path(repository) - recovery = "Recorded CI artifact is expired or missing; delete the unpublished draft release, then rerun release.yml to select fresh CI provenance" - require(len(ci["artifacts"]) == 1, "Rebuild releases require one source CI artifact") - attempt = None - if recovering: - branch = api.get(root)["default_branch"] - try: - attempt = api.get(f"{root}/actions/runs/{ci['run_id']}/attempts/{ci['run_attempt']}") - except APIError as error: - raise ReleaseError(f"Cannot validate recorded CI attempt: {error}") from None - check_run(attempt, repository, branch, commit) - require(attempt.get("id") == ci["run_id"] and attempt.get("run_attempt") == ci["run_attempt"] - and attempt.get("status") == "completed" and attempt.get("conclusion") == "success", - "Recorded CI attempt must exist and have succeeded") - source = ci["artifacts"][0] - try: - artifact = api.get(f"{root}/actions/artifacts/{source['id']}") - if artifact.get("expired"): - raise APIError(410, "recorded CI artifact") - require(artifact.get("id") == source["id"] and artifact.get("digest") == source["digest"], - "CI artifact digest or ID conflicts with provenance") - require(artifact.get("workflow_run", {}).get("id") == ci["run_id"] - and artifact.get("workflow_run", {}).get("head_sha") == commit, - "CI artifact belongs to another run or commit") - if attempt is not None: - created = datetime.fromisoformat(artifact["created_at"].replace("Z", "+00:00")) - require(datetime.fromisoformat(attempt["run_started_at"].replace("Z", "+00:00")) <= created - <= datetime.fromisoformat(attempt["updated_at"].replace("Z", "+00:00")), - "CI artifact was not created within the recorded CI attempt") - data = api.download(f"{root}/actions/artifacts/{source['id']}/zip") - except APIError as error: - if recovering and error.status in (404, 410): - raise ReleaseError(recovery) from None - raise - require(sha256(data) == digest_value(source["digest"]), "Downloaded CI artifact digest mismatch") - return ci_archives(data, declared) - - -def find_release(api, root, tag): - if tag is None: - return None - matches = [release for release in api.list(f"{root}/releases") if release.get("tag_name") == tag] - require(len(matches) <= 1, "Multiple releases for the tag") - return matches[0] if matches else None - - -def check_immutable_release(release): - require(release.get("immutable") is True, - "Release is public but mutable; the repository owner must handle it (immutable: true required)") - - -def release_assets(api, root, release): - assets = api.list(f"{root}/releases/{release['id']}/assets") - names = [asset["name"] for asset in assets] - require(len(names) == len(set(names)), "Duplicate release asset names") - return {asset["name"]: asset for asset in assets} - - -def read_asset(api, root, asset): - require(asset.get("state") == "uploaded", "Release asset is incomplete") - data = api.download(f"{root}/releases/assets/{asset['id']}") - require(sha256(data) == digest_value(asset.get("digest")) and len(data) == asset.get("size"), - "Release asset download differs from its API digest or size") - return data - - -def validate_receipt(data): - receipt = json.loads(data) - require(isinstance(receipt, dict) and set(receipt) == { - "schema", "plugin_id", "version", "plugin_api", "compatibility", "tag", "commit", - "archives", "ci", "workflow", "reproduction"}, "Invalid release receipt fields") - require(type(receipt["schema"]) is int and receipt["schema"] == 1, "Unsupported receipt schema") - require(receipt["tag"] is None or isinstance(receipt["tag"], str) and re.fullmatch("v" + VERSION, receipt["tag"]), - "Invalid receipt tag") - _, declared = manifest_info({"id": receipt["plugin_id"], "version": receipt["version"], - "plugin_api": receipt["plugin_api"], "compatibility": receipt["compatibility"]}, receipt["tag"]) - require(isinstance(receipt["commit"], str) and re.fullmatch(SHA, receipt["commit"]), "Invalid receipt commit") - require(receipt["reproduction"] == "rebuild", "Shared workflow requires rebuild reproduction") - ci = receipt["ci"] - require(isinstance(ci, dict) and set(ci) == {"run_id", "run_attempt", "artifacts"} - and all(positive(ci[key]) for key in ("run_id", "run_attempt")), "Invalid receipt CI provenance") - require(isinstance(ci["artifacts"], list) and len(ci["artifacts"]) == 1, - "Rebuild receipt requires one CI artifact") - source = ci["artifacts"][0] - require(isinstance(source, dict) and set(source) == {"id", "digest"} and positive(source["id"]), - "Invalid receipt artifact provenance") - digest_value(source["digest"]) - workflow = receipt["workflow"] - require(isinstance(workflow, dict) and set(workflow) == {"ref", "sha"} - and isinstance(workflow["sha"], str) and re.fullmatch(SHA, workflow["sha"]) - and workflow["ref"] == WORKFLOW + workflow["sha"], "Invalid receipt workflow provenance") - archives = receipt["archives"] - require(isinstance(archives, list) and archives, "Receipt requires archive records") - for item in archives: - require(isinstance(item, dict) and set(item) == {"name", "size", "sha256"} - and isinstance(item["name"], str) and re.fullmatch(ARCHIVE, item["name"]) - and positive(item["size"]) and isinstance(item["sha256"], str) and re.fullmatch(HASH, item["sha256"]), - "Invalid receipt archive record") - names = [item["name"] for item in archives] - archive_names(names, declared) - require(names == sorted(names) and data == canonical(receipt), "Receipt must use canonical schema 1 encoding") - return receipt - - -def checksums(files): - return "".join(f"{sha256(data)} {name}\n" for name, data in sorted(files.items())).encode() - - -def assemble(receipt, archives): - files = dict(archives) - require(set(files) == {item["name"] for item in receipt["archives"]}, "Receipt archive set differs") - for item in receipt["archives"]: - data = files[item["name"]] - require(len(data) == item["size"] and sha256(data) == item["sha256"], "Archive differs from receipt") - files[RECEIPT] = canonical(receipt) - validate_receipt(files[RECEIPT]) - files[SUMS] = checksums(files) - return files - - -def check_assets(assets, files, draft, complete=False): - require(set(assets) <= set(files), "Release contains unexpected assets") - if complete: - require(set(assets) == set(files), "Release asset set is incomplete") - for name, asset in assets.items(): - if asset.get("state") == "starter" and draft and not complete: - continue - require(asset.get("state") == "uploaded" and asset.get("digest") == "sha256:" + sha256(files[name]) - and asset.get("size") == len(files[name]), f"Completed release asset conflicts: {name}") - - -def get_build_archives(api, repository, artifact_id, run_id, declared): - root = repository_path(repository) - require(positive(artifact_id) and positive(run_id), "Invalid build artifact or release run ID") - artifact = api.get(f"{root}/actions/artifacts/{artifact_id}") - require(artifact.get("id") == artifact_id and not artifact.get("expired") - and artifact.get("workflow_run", {}).get("id") == run_id, - "Build artifact must be an available artifact from this release run") - data = api.download(f"{root}/actions/artifacts/{artifact_id}/zip") - require(sha256(data) == digest_value(artifact.get("digest")), "Downloaded build artifact digest mismatch") - archives = zip_entries(data) - archive_names(list(archives), declared) - return archives - - -def verify(api, repository, ref, publish, source_commit, build_artifact_id, run_id, ci_artifact, workflow_ref, workflow_sha, - output): - root = repository_path(repository) - commit, tag = resolve(api, repository, ref, publish) - require(commit == source_commit, "Release ref moved after source resolution") - source = api.get(f"{root}/contents/computer-mcp-plugin.toml?ref={commit}") - require(source.get("type") == "file" and source.get("encoding") == "base64", - "Release source manifest must be a file with base64 contents") - manifest = tomllib.loads(base64.b64decode(source["content"]).decode()) - info, declared = manifest_info(manifest, tag) - release = find_release(api, root, tag) - if release: - if release["draft"]: - require(release["target_commitish"] == commit, "Draft release target differs from the verified commit") - else: - check_immutable_release(release) - assets = release_assets(api, root, release) if release else {} - receipt = None - if RECEIPT in assets and assets[RECEIPT].get("state") == "uploaded": - receipt = validate_receipt(read_asset(api, root, assets[RECEIPT])) - require(all(receipt[key] == value for key, value in info.items()) - and receipt["tag"] == tag and receipt["commit"] == commit, - "Frozen receipt differs from release source") - if release and not release["draft"]: - require(receipt is not None, "Published release lacks a complete receipt; it cannot be modified") - expected = {item["name"] for item in receipt["archives"]} | {RECEIPT, SUMS} - require(set(assets) == expected, "Published release asset set differs") - archives = {item["name"]: read_asset(api, root, assets[item["name"]]) for item in receipt["archives"]} - else: - if receipt is None: - ci = select_ci(api, repository, commit, ci_artifact) - else: - ci = receipt["ci"] - archives = get_ci_archives(api, repository, commit, ci, declared, recovering=receipt is not None) - rebuilt = get_build_archives(api, repository, build_artifact_id, run_id, declared) - require(rebuilt == archives, "Rebuilt archives differ byte for byte from the accepted archives") - for data in archives.values(): - contents = zip_entries(data) - require("computer-mcp-plugin.toml" in contents, "Archive lacks the root plugin manifest") - require(tomllib.loads(contents["computer-mcp-plugin.toml"].decode()) == manifest, - "Archive manifest differs from the release source") - if receipt is None: - receipt = dict(info, schema=1, tag=tag, commit=commit, - archives=[{"name": name, "size": len(data), "sha256": sha256(data)} - for name, data in sorted(archives.items())], ci=ci, - workflow={"ref": workflow_ref, "sha": workflow_sha}, reproduction="rebuild") - files = assemble(receipt, archives) - if release: - check_assets(assets, files, release["draft"], complete=not release["draft"]) - require(not output.exists(), "Release output directory must not exist") - output.mkdir(parents=True) - for name, data in files.items(): - (output / name).write_bytes(data) - return {"commit": commit, "tag": tag or "", "asset_digests": json.dumps( - {name: sha256(data) for name, data in files.items()}, sort_keys=True, separators=(",", ":"))} - - -def load_bundle(directory, expected_digests): - require(directory.is_dir() and not directory.is_symlink(), "Invalid verified bundle file set") - paths = list(directory.iterdir()) - require(all(path.is_file() and not path.is_symlink() for path in paths), "Invalid verified bundle file set") - files = {path.name: path.read_bytes() for path in paths} - require({name: sha256(data) for name, data in files.items()} == json.loads(expected_digests), - "Bundle differs from the verify job's digests") - require(RECEIPT in files, "Invalid verified bundle file set") - receipt = validate_receipt(files[RECEIPT]) - expected = {item["name"] for item in receipt["archives"]} | {RECEIPT, SUMS} - require(set(files) == expected, "Invalid verified bundle file set") - require(files == assemble(receipt, {item["name"]: files[item["name"]] for item in receipt["archives"]}), - "Verified bundle checksums differ") - return receipt, files - - -def publish_bundle(api, repository, directory, expected_digests): - receipt, files = load_bundle(directory, expected_digests) - root = repository_path(repository) - require(receipt["tag"] is not None, "Cannot publish an untagged dry-run bundle") - commit, tag = resolve(api, repository, receipt["tag"], True) - require(commit == receipt["commit"], "Release tag moved after verification") - try: - immutable = api.get(f"{root}/immutable-releases") - except APIError as error: - raise ReleaseError(f"Cannot verify repository release immutability: {error}") from None - require(immutable.get("enabled") is True, - "Enable release immutability in the plugin repository before publishing") - release = find_release(api, root, tag) - if release is None: - release = api.send("POST", f"{root}/releases", { - "tag_name": tag, "target_commitish": commit, "name": tag, "draft": True, - "prerelease": "-" in receipt["version"]}) - release_id = release["id"] - - def current(): - observed = api.get(f"{root}/releases/{release_id}") - require(observed["tag_name"] == tag, "Release tag changed") - if not observed["draft"]: - check_immutable_release(observed) - return observed - - def draft(): - observed = current() - require(observed["draft"], "Release became public; rerun to verify its immutable assets") - require(observed["target_commitish"] == commit, "Draft release target differs from the verified commit") - - release = current() - assets = release_assets(api, root, release) - check_assets(assets, files, release["draft"], complete=not release["draft"]) - if not release["draft"]: - return {"published": "true", "release_id": str(release_id), "tag": tag} - draft() - # Receipt-first upload freezes provenance before any archive is exposed. - for name in [RECEIPT, *sorted(item["name"] for item in receipt["archives"]), SUMS]: - assets = release_assets(api, root, release) - check_assets(assets, files, True) - asset = assets.get(name) - if asset and asset["state"] == "uploaded": - continue - if asset: - draft() - api.send("DELETE", f"{root}/releases/assets/{asset['id']}") - draft() - content_type = "application/zip" if name.endswith(".zip") else "application/json" if name == RECEIPT else "text/plain" - uploaded = api.send("POST", f"{root}/releases/{release_id}/assets?{urlencode({'name': name})}", - files[name], content_type) - check_assets({name: uploaded}, {name: files[name]}, True, complete=True) - draft() - check_assets(release_assets(api, root, release), files, True, complete=True) - require(resolve(api, repository, tag, True) == (commit, tag), "Release tag moved during publication") - draft() - # Publishing changes only visibility; release notes remain operator-owned. - published = api.send("PATCH", f"{root}/releases/{release_id}", {"draft": False}) - require(published.get("draft") is False and published.get("tag_name") == tag, "Release did not become public") - check_immutable_release(published) - check_assets(release_assets(api, root, published), files, False, complete=True) - return {"published": "true", "release_id": str(release_id), "tag": tag} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - commands = parser.add_subparsers(dest="command", required=True) - for name in ("resolve", "verify", "publish"): - command = commands.add_parser(name) - command.add_argument("--repository", required=True) - if name != "publish": - command.add_argument("--ref", required=True) - command.add_argument("--publish", choices=("true", "false"), required=True) - if name == "verify": - command.add_argument("--output", type=Path, required=True) - for flag in ("build-artifact-id", "run-id"): - command.add_argument("--" + flag, type=int, required=True) - for flag in ("commit", "ci-artifact", "workflow-ref", "workflow-sha"): - command.add_argument("--" + flag, required=True) - if name == "publish": - command.add_argument("--directory", type=Path, required=True) - command.add_argument("--asset-digests", required=True) - args = parser.parse_args() - try: - api = GitHub(os.environ["GH_TOKEN"]) - if args.command == "resolve": - commit, tag = resolve(api, args.repository, args.ref, args.publish == "true") - result = {"commit": commit, "tag": tag or ""} - elif args.command == "verify": - result = verify(api, args.repository, args.ref, args.publish == "true", args.commit, args.build_artifact_id, - args.run_id, args.ci_artifact, args.workflow_ref, args.workflow_sha, - args.output.resolve()) - if args.publish == "false": - print("Dry run: no Release writes. Publication requires a signed annotated v" + - json.loads((args.output / RECEIPT).read_bytes())["version"] + - " tag at the verified commit; record manual acceptance in the Release notes.") - else: - result = publish_bundle(api, args.repository, args.directory, args.asset_digests) - if "GITHUB_OUTPUT" in os.environ: - with open(os.environ["GITHUB_OUTPUT"], "a") as output: - for key, value in result.items(): - output.write(f"{key}={value}\n") - print(json.dumps(result, sort_keys=True)) - return 0 - except (OSError, ValueError, KeyError, TypeError, zipfile.BadZipFile) as error: - print(str(error), file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/Tests/test_plugin_release.py b/Tests/test_plugin_release.py deleted file mode 100644 index fc56334..0000000 --- a/Tests/test_plugin_release.py +++ /dev/null @@ -1,1167 +0,0 @@ -"""Offline release verification, publication, interruption and recovery tests.""" - -import base64 -from contextlib import redirect_stderr, redirect_stdout -import copy -import importlib.util -import io -import json -import os -from pathlib import Path -import re -import shlex -import stat -import subprocess -import sys -import tempfile -import textwrap -import unittest -from unittest.mock import patch -from urllib.parse import parse_qs, urlsplit -from urllib.request import Request -import zipfile - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "Scripts/plugin_release.py" -WORKFLOW = ROOT / ".github/workflows/plugin-release.yml" -spec = importlib.util.spec_from_file_location("plugin_release", SCRIPT) -release = importlib.util.module_from_spec(spec) -spec.loader.exec_module(release) -REPOSITORY = "example/plugin-widget" -API_ROOT = "/repos/" + REPOSITORY -COMMIT = "a" * 40 -WORKFLOW_SHA = "b" * 40 -WORKFLOW_REF = release.WORKFLOW + WORKFLOW_SHA -TAG = "v0.2.0" -MANIFEST = b'id = "widget"\nversion = "0.2.0"\nplugin_api = 1\n[compatibility]\nminimum_host = "0.1.0"\n' - - -def workflow_job(name): - return re.search(r"(?ms)^ " + name + r":\n(.*?)(?=^ [a-z_]+:\n|\Z)", WORKFLOW.read_text())[1] - - -def python_step(job): - return textwrap.dedent(workflow_job(job).split("<<'PY'\n", 1)[1].split("\n PY", 1)[0]) - - -def zip_bytes(files): - output = io.BytesIO() - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive: - for name, data in sorted(files.items()): - entry = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) - entry.create_system = 3 - entry.external_attr = (stat.S_IFREG | 0o644) << 16 - archive.writestr(entry, data) - return output.getvalue() - - -class FakeGitHub: - """The same four-operation API boundary as the real REST transport.""" - - def __init__(self, archives): - self.calls = [] - self.writes = [] - self.manifest = MANIFEST - self.runs = {10: self.run()} - self.attempts = {(10, 1): self.run()} - self.artifacts = {} - self.artifact_bytes = {} - self.releases = {} - self.assets = {} - self.asset_bytes = {} - self.next_id = 100 - self.fail = None - self.tag_type = "tag" - self.tag_commit = COMMIT - self.verified = True - self.reason = "valid" - self.immutability = {"enabled": True, "enforced_by_owner": False} - self.install_artifact(20, archives) - self.install_build_artifact(archives) - - def run(self, **changes): - run = {"id": 10, "run_attempt": 1, "event": "push", "head_branch": "main", - "head_sha": COMMIT, "head_repository": {"full_name": REPOSITORY}, - "path": ".github/workflows/ci.yml", "status": "completed", "conclusion": "success", - "run_started_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:10:00Z"} - run.update(changes) - return run - - def install_artifact(self, artifact_id, archives, **changes): - data = zip_bytes(dict({"dist/" + name: content for name, content in archives.items()}, - **{"package-receipt.json": b'{"builder": "metadata"}\n'})) - artifact = {"id": artifact_id, "name": "widget-" + COMMIT + "-3.13", "expired": False, - "digest": "sha256:" + release.sha256(data), "created_at": "2026-01-01T00:05:00Z", - "workflow_run": {"id": 10, "head_sha": COMMIT}} - artifact.update(changes) - self.artifacts[artifact_id] = artifact - self.artifact_bytes[artifact_id] = data - - def rerun_ci(self, archives): - self.runs[10] = self.run(run_attempt=2, run_started_at="2026-01-02T00:00:00Z", - updated_at="2026-01-02T00:10:00Z") - self.attempts[10, 2] = copy.deepcopy(self.runs[10]) - self.install_artifact(21, archives, created_at="2026-01-02T00:05:00Z") - - def install_build_artifact(self, archives): - data = zip_bytes(archives) - self.artifacts[30] = {"id": 30, "name": "plugin-build-12-1", "expired": False, - "digest": "sha256:" + release.sha256(data), "workflow_run": {"id": 12}} - self.artifact_bytes[30] = data - - def _record(self, method, path): - self.calls.append((method, path)) - assert path.startswith(API_ROOT), path - return path[len(API_ROOT):] - - def get(self, path): - route = self._record("GET", path) - if route == "": - result = {"default_branch": "main"} - elif route == "/immutable-releases": - result = self.immutability - elif route.startswith("/contents/computer-mcp-plugin.toml?"): - assert parse_qs(urlsplit(route).query) == {"ref": [self.tag_commit]} - result = {"type": "file", "encoding": "base64", "content": base64.b64encode(self.manifest).decode()} - elif route.startswith("/git/ref/tags/"): - result = {"object": {"type": self.tag_type, "sha": "c" * 40}} - elif route.startswith("/git/tags/"): - result = {"tag": TAG, "object": {"type": "commit", "sha": self.tag_commit}, - "verification": {"verified": self.verified, "reason": self.reason}} - elif route.startswith("/commits/"): - result = {"sha": COMMIT} - elif route.startswith("/actions/runs/"): - parts = route.split("/") - run_id = int(parts[3]) - if "/attempts/" in route: - key = run_id, int(parts[5]) - if key not in self.attempts: - raise release.APIError(404, path) - result = self.attempts[key] - else: - result = self.runs[run_id] - elif route.startswith("/actions/artifacts/"): - artifact_id = int(route.split("/")[3]) - if artifact_id not in self.artifacts: - raise release.APIError(404, path) - result = self.artifacts[artifact_id] - elif route.startswith("/releases/"): - result = self.releases[int(route.split("/")[2])] - else: - raise AssertionError(("GET", route)) - return copy.deepcopy(result) - - def list(self, path, key=None): - route = self._record("LIST", path) - if route.startswith("/actions/workflows/ci.yml/runs?"): - assert key == "workflow_runs" - result = list(self.runs.values()) - elif route.endswith("/artifacts"): - assert key == "artifacts" - run_id = int(route.split("/")[3]) - result = [item for item in self.artifacts.values() if item["workflow_run"]["id"] == run_id] - elif route == "/releases": - result = list(self.releases.values()) - elif route.endswith("/assets"): - release_id = int(route.split("/")[2]) - result = [item for item in self.assets.values() if item["release_id"] == release_id] - else: - raise AssertionError(("LIST", route)) - return copy.deepcopy(result) - - def download(self, path): - route = self._record("DOWNLOAD", path) - if route.startswith("/actions/artifacts/"): - return self.artifact_bytes[int(route.split("/")[3])] - if route.startswith("/releases/assets/"): - return self.asset_bytes[int(route.split("/")[3])] - raise AssertionError(("DOWNLOAD", route)) - - def _failure(self, step, when): - if self.fail == (step, when): - self.fail = None - raise release.APIError(502, "injected " + step + " " + when) - - def add_draft(self): - self.releases[50] = {"id": 50, "tag_name": TAG, "target_commitish": COMMIT, "name": TAG, - "draft": True, "immutable": False, "prerelease": False, - "body": "Operator acceptance notes"} - return self.releases[50] - - def publish_release(self, release_id=50): - item = self.releases[release_id] - if item["draft"]: - item.update(draft=False, immutable=self.immutability.get("enabled") is True) - return item - - def add_asset(self, name, data=b"", state="uploaded", release_id=50): - self.next_id += 1 - asset = {"id": self.next_id, "release_id": release_id, "name": name, "state": state, - "digest": "sha256:" + release.sha256(data), "size": len(data)} - self.assets[asset["id"]] = asset - self.asset_bytes[asset["id"]] = data - return asset - - def send(self, method, path, data=None, content_type="application/json"): - route = self._record(method, path) - if method == "POST" and route == "/releases": - step = "draft" - elif method == "POST" and "/assets?" in route: - step = parse_qs(urlsplit(route).query)["name"][0] - elif method == "PATCH": - step = "publish" - elif method == "DELETE": - step = "delete" - else: - raise AssertionError((method, route)) - self._failure(step, "before") - if step != "draft": - release_id = (self.assets[int(route.split("/")[3])]["release_id"] if step == "delete" - else int(route.split("/")[2])) - if self.releases[release_id].get("immutable") is True: - raise release.APIError(422, path) - self.writes.append((step, copy.deepcopy(data))) - if step == "draft": - result = self.add_draft() - result.update(data) - elif step == "publish": - result = self.releases[int(route.split("/")[2])] - if data.get("draft") is False: - self.publish_release(result["id"]) - result.update(data) - elif step == "delete": - asset_id = int(route.split("/")[3]) - del self.assets[asset_id] - del self.asset_bytes[asset_id] - result = None - else: - release_id = int(route.split("/")[2]) - assert not any(item["name"] == step for item in self.assets.values()) - result = self.add_asset(step, data, release_id=release_id) - self._failure(step, "after") - return copy.deepcopy(result) - - -class ReleaseTests(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory(prefix="plugin release ") - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - self.checkout = self.root / "checkout" - self.checkout.mkdir() - self.output = self.root / "assets" - self.set_archives(MANIFEST, {"widget.zip": zip_bytes({"computer-mcp-plugin.toml": MANIFEST, "bin/tool": b"owned"})}) - self.api = FakeGitHub(self.archives) - - def set_archives(self, manifest, archives): - self.manifest = manifest - self.archives = archives - if hasattr(self, "api"): - self.api.manifest = manifest - self.api.install_build_artifact(archives) - (self.checkout / "computer-mcp-plugin.toml").write_bytes(manifest) - (self.checkout / "build.py").write_text( - "import base64, os, sys\nfrom pathlib import Path\n" - "assert 'GH_TOKEN' not in os.environ and 'GITHUB_TOKEN' not in os.environ\n" - "output = Path(sys.argv[1]); output.mkdir()\n" + - "\n".join(f"(output / {name!r}).write_bytes(base64.b64decode({base64.b64encode(data)!r}))" - for name, data in archives.items())) - - def verify(self, **overrides): - arguments = dict(api=self.api, repository=REPOSITORY, ref=TAG, publish=True, - source_commit=COMMIT, build_artifact_id=30, run_id=12, - ci_artifact="widget-${commit}-3.13", workflow_ref=WORKFLOW_REF, - workflow_sha=WORKFLOW_SHA, output=self.output) - arguments.update(overrides) - self.api.manifest = (self.checkout / "computer-mcp-plugin.toml").read_bytes() - result = release.verify(**arguments) - self.asset_digests = result["asset_digests"] - return result - - def publish(self): - return release.publish_bundle(self.api, REPOSITORY, self.output, self.asset_digests) - - def files(self): - return {path.name: path.read_bytes() for path in self.output.iterdir()} - - def frozen_draft(self): - self.verify() - self.api.add_draft() - self.api.add_asset(release.RECEIPT, (self.output / release.RECEIPT).read_bytes()) - - def verify_again(self, **changes): - return self.verify(output=self.root / "recovered", **changes) - - def assert_no_writes(self, function, pattern): - before = copy.deepcopy(self.api.writes) - with self.assertRaisesRegex(release.ReleaseError, pattern): - function() - self.assertEqual(self.api.writes, before) - - def run_build(self, command=None): - runner = self.root / "build-runner" - runner.mkdir() - script = textwrap.dedent(re.search(r" run: \|\n((?: .*\n)+)", workflow_job("build"))[1]) - environment = {"PATH": os.environ["PATH"], "RUNNER_TEMP": str(runner), - "GITHUB_OUTPUT": str(runner / "output"), "GITHUB_ENV": str(runner / "env"), - "GITHUB_PATH": str(runner / "path"), - "RELEASE_BUILD": command or shlex.quote(sys.executable) + " build.py"} - subprocess.run(["bash", "-euo", "pipefail", "-c", script], cwd=self.checkout, env=environment, check=True) - output = runner / "plugin-release-build" - self.api.install_build_artifact({path.name: path.read_bytes() for path in output.iterdir()}) - return runner - - def run_helper_step(self, job, runner, environment): - helper = runner / "plugin-release/plugin_release.py" - - def run_pinned(arguments, check): - self.assertTrue(check) - self.assertEqual(arguments[:3], [sys.executable, "-I", str(helper)]) - with patch.object(sys, "argv", arguments[2:]), patch.object(release, "GitHub", return_value=self.api): - status = release.main() - if status: - raise subprocess.CalledProcessError(status, arguments) - - output = runner / "output" - with patch.dict(os.environ, {"RUNNER_TEMP": str(runner), "GH_TOKEN": "fake", - "GH_REPOSITORY": REPOSITORY, "GITHUB_OUTPUT": str(output), - "PLUGIN_RELEASE_SHA256": release.sha256(SCRIPT.read_bytes()), **environment}), \ - patch.object(subprocess, "run", side_effect=run_pinned), \ - redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): - exec(compile(python_step(job), str(WORKFLOW), "exec"), {}) - return output.read_text() - - def test_first_publication_order_contents_checksums_and_notes(self): - outputs = self.verify() - self.assertEqual(self.api.writes, []) - result = self.publish() - self.assertEqual(result, {"published": "true", "release_id": "50", "tag": TAG}) - self.assertIs(self.api.releases[50]["immutable"], True) - self.assertEqual([step for step, _ in self.api.writes], - ["draft", release.RECEIPT, "widget.zip", release.SUMS, "publish"]) - self.assertEqual(self.api.releases[50]["body"], "Operator acceptance notes") - files = self.files() - receipt = json.loads(files[release.RECEIPT]) - self.assertEqual(set(receipt), {"schema", "plugin_id", "version", "plugin_api", "compatibility", - "tag", "commit", "archives", "ci", "workflow", "reproduction"}) - self.assertEqual(receipt["schema"], 1) - self.assertEqual(receipt["ci"], {"run_id": 10, "run_attempt": 1, - "artifacts": [{"id": 20, "digest": self.api.artifacts[20]["digest"]}]}) - self.assertEqual(receipt["workflow"], {"ref": WORKFLOW_REF, "sha": WORKFLOW_SHA}) - self.assertEqual(receipt["reproduction"], "rebuild") - self.assertEqual(json.loads(outputs["asset_digests"]), {name: release.sha256(data) for name, data in files.items()}) - self.assertEqual(files[release.SUMS], release.checksums({name: data for name, data in files.items() - if name != release.SUMS})) - self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] for item in self.api.assets.values()}, files) - - def test_failures_before_and_after_every_publication_step_are_recoverable(self): - self.verify() - expected = self.files() - for step in ("draft", release.RECEIPT, "widget.zip", release.SUMS, "publish"): - for when in ("before", "after"): - with self.subTest(step=step, when=when): - self.api = FakeGitHub(self.archives) - self.api.fail = (step, when) - with self.assertRaises(release.APIError): - self.publish() - if self.api.releases and self.api.releases[50]["draft"]: - self.assertNotEqual(self.api.writes[-1][0], "publish") - result = self.publish() - self.assertEqual(result["published"], "true") - self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] - for item in self.api.assets.values()}, expected) - - def test_publish_only_retry_downloads_the_original_artifact_id_on_a_fresh_runner(self): - self.verify() - expected = self.files() - artifact_id = "901" - self.api.artifact_bytes[int(artifact_id)] = zip_bytes({"plugin_release.py": SCRIPT.read_bytes(), - **{"assets/" + name: data for name, data in expected.items()}}) - job = workflow_job("publish") - download_id = re.search(r"artifact-ids: (.+)", job)[1] - digests = re.search(r"RELEASE_ASSET_DIGESTS: (.+)", job)[1] - self.assertIn("artifact_id: ${{ steps.upload.outputs.artifact-id }}", workflow_job("verify")) - - def attempt(number): - context = {"github.run_id": "12", "github.run_attempt": str(number), - "needs.verify.outputs.artifact_id": artifact_id, - "needs.verify.outputs.asset_digests": self.asset_digests} - - def evaluate(value): - return re.sub(r"\$\{\{ (.*?) \}\}", lambda match: context[match[1]], value) - - directory = self.root / f"publish-attempt-{number}" - root = directory / "plugin-release" - root.mkdir(parents=True) - data = self.api.download(API_ROOT + "/actions/artifacts/" + evaluate(download_id) + "/zip") - for name, content in release.zip_entries(data).items(): - path = root / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(content) - - return self.run_helper_step("publish", directory, {"RELEASE_ASSET_DIGESTS": evaluate(digests)}) - - self.api.fail = (release.SUMS, "before") - with self.assertRaises(subprocess.CalledProcessError): - attempt(1) - self.assertIn("published=true", attempt(2)) - self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] - for item in self.api.assets.values()}, expected) - self.assertEqual(self.api.calls.count(("DOWNLOAD", API_ROOT + "/actions/artifacts/901/zip")), 2) - - def test_partial_upload_recovery_freezes_ci_attempt_artifact_and_workflow(self): - self.verify() - original = self.files() - self.api.fail = (release.SUMS, "before") - with self.assertRaises(release.APIError): - self.publish() - changed = {"widget.zip": self.archives["widget.zip"] + b"new CI bytes"} - self.api.rerun_ci(changed) - self.api.calls.clear() - self.verify_again(workflow_ref=release.WORKFLOW + "d" * 40, workflow_sha="d" * 40) - recovered = {path.name: path.read_bytes() for path in (self.root / "recovered").iterdir()} - self.assertEqual(recovered, original) - self.assertIn(("GET", API_ROOT + "/actions/runs/10/attempts/1"), self.api.calls) - self.assertFalse(any("/workflows/" in path or path.endswith("/actions/runs/10") - or "/attempts/2" in path for _, path in self.api.calls)) - self.assertIn(("DOWNLOAD", API_ROOT + "/actions/artifacts/20/zip"), self.api.calls) - self.output = self.root / "recovered" - self.publish() - self.assertEqual([name for name, _ in self.api.writes].count(release.RECEIPT), 1) - - def test_recovery_accepts_recorded_success_when_latest_attempt_and_newer_run_fail(self): - self.frozen_draft() - expected = self.files() - self.api.rerun_ci({"widget.zip": b"newer bytes"}) - self.api.runs[10]["conclusion"] = "failure" - self.api.runs[11] = self.api.run(id=11, event="workflow_dispatch", conclusion="failure") - self.api.calls.clear() - self.verify_again() - self.assertEqual({path.name: path.read_bytes() for path in (self.root / "recovered").iterdir()}, expected) - self.assertIn(("GET", API_ROOT + "/actions/runs/10/attempts/1"), self.api.calls) - self.assertFalse(any("/workflows/" in path or "/attempts/2" in path or "/actions/runs/11" in path - for _, path in self.api.calls)) - - def test_recovery_refuses_invalid_or_unavailable_recorded_attempt(self): - self.frozen_draft() - for field, value in (("event", "workflow_dispatch"), ("head_branch", "other"), - ("head_sha", "d" * 40), ("path", ".github/workflows/other.yml"), - ("head_repository", {"full_name": "fork/plugin-widget"}), - ("conclusion", "failure"), ("conclusion", "cancelled"), - ("status", "in_progress"), ("id", 99), ("run_attempt", 99)): - with self.subTest(field=field, value=value): - with patch.dict(self.api.attempts[10, 1], {field: value}): - self.assert_no_writes(self.verify_again, "CI must|Recorded CI attempt") - del self.api.attempts[10, 1] - self.assert_no_writes(self.verify_again, "Cannot validate recorded CI attempt.*404") - - def test_recovery_refuses_an_artifact_outside_the_recorded_attempt(self): - self.frozen_draft() - self.api.artifacts[20]["created_at"] = "2026-01-02T00:05:00Z" - self.assert_no_writes(self.verify_again, "not created within the recorded CI attempt") - - def test_recovery_refuses_a_nonexistent_attempt_recorded_in_the_receipt(self): - self.frozen_draft() - asset = next(item for item in self.api.assets.values() if item["name"] == release.RECEIPT) - receipt = json.loads(self.api.asset_bytes[asset["id"]]) - receipt["ci"]["run_attempt"] = 99 - data = release.canonical(receipt) - self.api.asset_bytes[asset["id"]] = data - asset.update(size=len(data), digest="sha256:" + release.sha256(data)) - self.assert_no_writes(self.verify_again, "Cannot validate recorded CI attempt.*404") - self.assertIn(("GET", API_ROOT + "/actions/runs/10/attempts/99"), self.api.calls) - - def test_starter_assets_at_each_upload_position_are_deleted_and_replaced(self): - self.verify() - files = self.files() - for name in (release.RECEIPT, "widget.zip", release.SUMS): - with self.subTest(name=name): - self.api = FakeGitHub(self.archives) - self.api.add_draft() - if name != release.RECEIPT: - self.api.add_asset(release.RECEIPT, files[release.RECEIPT]) - self.api.add_asset(name, b"partial", state="starter") - self.publish() - self.assertIn("delete", [step for step, _ in self.api.writes]) - self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] - for item in self.api.assets.values()}, files) - - def test_starter_deletion_failure_is_recoverable(self): - self.frozen_draft() - self.api.add_asset("widget.zip", state="starter") - for when in ("before", "after"): - with self.subTest(when=when): - self.api.fail = ("delete", when) - with self.assertRaises(release.APIError): - self.publish() - if not any(item["name"] == "widget.zip" for item in self.api.assets.values()): - self.api.add_asset("widget.zip", state="starter") - self.publish() - - def test_completed_digest_or_size_conflicts_are_never_overwritten(self): - self.verify() - for field, value in (("digest", "sha256:" + "0" * 64), ("size", 999), ("state", "unknown")): - with self.subTest(field=field): - self.api = FakeGitHub(self.archives) - self.api.add_draft() - asset = self.api.add_asset("widget.zip", self.archives["widget.zip"]) - asset[field] = value - self.assert_no_writes(self.publish, "conflicts") - - def test_conflicting_frozen_receipt_is_refused_before_any_upload(self): - self.verify() - self.api.add_draft() - self.api.add_asset(release.RECEIPT, b"another receipt") - self.assert_no_writes(self.publish, "conflicts") - - def test_unexpected_and_duplicate_assets_are_refused(self): - self.verify() - for duplicate in (False, True): - with self.subTest(duplicate=duplicate): - self.api = FakeGitHub(self.archives) - self.api.add_draft() - name = "widget.zip" if duplicate else "unowned.txt" - self.api.add_asset(name) - if duplicate: - self.api.add_asset(name) - self.assert_no_writes(self.publish, "Duplicate|unexpected") - - def test_recorded_artifact_expired_missing_or_gone_has_explicit_recovery(self): - self.frozen_draft() - for mode in ("expired", "missing", "gone"): - with self.subTest(mode=mode): - saved = copy.deepcopy(self.api.artifacts) - if mode == "expired": - self.api.artifacts[20]["expired"] = True - elif mode == "missing": - del self.api.artifacts[20] - original = self.api.download - - def download(path): - if mode == "gone" and "/actions/artifacts/" in path: - raise release.APIError(410, path) - return original(path) - - with patch.object(self.api, "download", side_effect=download): - self.assert_no_writes(self.verify_again, "delete the unpublished draft release.*rerun release.yml") - self.api.artifacts = saved - - def test_recovery_rejects_replaced_artifact_metadata_or_bytes(self): - self.frozen_draft() - for change in ("digest", "bytes", "run"): - with self.subTest(change=change): - with patch.dict(self.api.artifacts[20], {"digest": "sha256:" + "0" * 64} if change == "digest" - else {"workflow_run": {"id": 99}} if change == "run" else {}): - original = self.api.artifact_bytes[20] - if change == "bytes": - self.api.artifact_bytes[20] += b"changed" - self.assert_no_writes(self.verify_again, "digest|another run") - self.api.artifact_bytes[20] = original - - def test_public_release_repeat_is_read_only_even_after_ci_expiry(self): - self.verify() - self.publish() - writes = copy.deepcopy(self.api.writes) - del self.api.artifacts[20] - self.api.calls.clear() - self.verify_again() - self.assertFalse(any("/actions/runs/" in path or "/actions/artifacts/20" in path for _, path in self.api.calls)) - self.output = self.root / "recovered" - self.assertEqual(self.publish()["published"], "true") - self.assertEqual(self.api.writes, writes) - - def test_release_published_before_immutability_was_enabled_requires_owner_handling(self): - self.verify() - self.api.add_draft() - for name, data in self.files().items(): - self.api.add_asset(name, data) - self.api.immutability["enabled"] = False - self.api.publish_release() - self.api.immutability["enabled"] = True - self.assertIs(self.api.get(API_ROOT + "/releases/50")["immutable"], False) - for operation in (self.verify_again, self.publish): - with self.subTest(operation=operation.__name__): - self.assert_no_writes(operation, "public but mutable.*owner") - - def test_public_release_requires_explicit_immutable_true(self): - self.verify() - self.publish() - item = self.api.releases[50] - for immutable in (None, "true", 1, "missing"): - with self.subTest(immutable=immutable): - if immutable == "missing": - item.pop("immutable") - else: - item["immutable"] = immutable - for operation in (self.verify_again, self.publish): - self.assert_no_writes(operation, "public but mutable.*owner") - - def test_publication_requires_the_published_release_to_report_immutable_true(self): - self.verify() - for immutable in (False, None, "true", 1, "missing"): - with self.subTest(immutable=immutable): - self.api = FakeGitHub(self.archives) - original = self.api.send - - def send(method, path, *arguments): - if method == "PATCH": - self.api.immutability["enabled"] = False - result = original(method, path, *arguments) - if method == "PATCH": - if immutable == "missing": - result.pop("immutable") - else: - result["immutable"] = immutable - return result - - with patch.object(self.api, "send", side_effect=send): - with self.assertRaisesRegex(release.ReleaseError, "public but mutable.*owner"): - self.publish() - self.assertIs(self.api.releases[50]["draft"], False) - self.assertIs(self.api.releases[50]["immutable"], False) - self.assertEqual(self.api.writes[-1], ("publish", {"draft": False})) - - def test_public_release_incomplete_extra_starter_or_conflicting_assets_are_immutable(self): - self.verify() - self.publish() - assets = copy.deepcopy(self.api.assets) - for mode in ("missing", "extra", "starter", "digest"): - with self.subTest(mode=mode): - self.api.assets = copy.deepcopy(assets) - asset_id = next(item["id"] for item in self.api.assets.values() if item["name"] == "widget.zip") - if mode == "missing": - del self.api.assets[asset_id] - elif mode == "extra": - self.api.add_asset("extra.txt") - elif mode == "starter": - self.api.assets[asset_id]["state"] = "starter" - else: - self.api.assets[asset_id]["digest"] = "sha256:" + "0" * 64 - self.assert_no_writes(self.publish, "incomplete|unexpected|conflicts") - - def test_release_becoming_public_before_mutation_cannot_be_changed(self): - self.frozen_draft() - self.api.add_asset("widget.zip", state="starter") - original = self.api.get - count = 0 - - def get(path): - nonlocal count - if path.endswith("/releases/50"): - count += 1 - if count == 2: - self.api.publish_release() - return original(path) - - with patch.object(self.api, "get", side_effect=get): - self.assert_no_writes(self.publish, "became public") - - def test_publication_requires_server_enforced_release_immutability_before_any_write(self): - self.verify() - for enabled in (False, None, "true", 1): - with self.subTest(enabled=enabled): - self.api.immutability["enabled"] = enabled - self.assert_no_writes(self.publish, "Enable release immutability.*before publishing") - self.assertEqual(self.api.releases, {}) - - def test_immutability_api_failure_is_closed_and_explained(self): - self.verify() - original = self.api.get - - def get(path): - if path.endswith("/immutable-releases"): - raise release.APIError(403, path) - return original(path) - - with patch.object(self.api, "get", side_effect=get): - self.assert_no_writes(self.publish, "Cannot verify repository release immutability.*403") - - def test_server_refuses_mutation_after_the_draft_precheck_and_cli_stops_writing(self): - self.verify() - for mutation in ("DELETE", "POST", "PATCH"): - with self.subTest(mutation=mutation): - self.api = FakeGitHub(self.archives) - self.api.add_draft() - self.api.add_asset(release.RECEIPT, self.files()[release.RECEIPT]) - if mutation == "DELETE": - self.api.add_asset("widget.zip", state="starter") - elif mutation == "PATCH": - for name in ("widget.zip", release.SUMS): - self.api.add_asset(name, self.files()[name]) - assets = copy.deepcopy(self.api.assets) - original = self.api.send - - def send(method, path, *arguments): - self.assertEqual(method, mutation) - self.api.publish_release() - return original(method, path, *arguments) - - stderr = io.StringIO() - arguments = [str(SCRIPT), "publish", "--repository", REPOSITORY, "--directory", str(self.output), - "--asset-digests", self.asset_digests] - with patch.object(self.api, "send", side_effect=send), \ - patch.object(release, "GitHub", return_value=self.api), patch.object(sys, "argv", arguments), \ - patch.dict(os.environ, {"GH_TOKEN": "fake"}), redirect_stderr(stderr): - self.assertEqual(release.main(), 1) - self.assertIn("GitHub API returned 422", stderr.getvalue()) - self.assertEqual(self.api.writes, []) - self.assertEqual(self.api.assets, assets) - self.assertEqual(self.api.calls[-1][0], mutation) - - def test_upload_api_digest_mismatch_leaves_draft_unpublished(self): - self.verify() - original = self.api.send - - def send(*args): - result = original(*args) - if "/assets?" in args[1]: - result["digest"] = "sha256:" + "0" * 64 - return result - - with patch.object(self.api, "send", side_effect=send): - with self.assertRaisesRegex(release.ReleaseError, "conflicts"): - self.publish() - self.assertTrue(self.api.releases[50]["draft"]) - self.assertEqual([step for step, _ in self.api.writes], ["draft", release.RECEIPT]) - - def test_draft_target_and_tag_movement_are_refused(self): - self.verify() - self.api.add_draft()["target_commitish"] = "d" * 40 - self.assert_no_writes(self.publish, "target differs") - self.api.tag_commit = "d" * 40 - self.assert_no_writes(self.publish, "tag moved") - - def test_tag_movement_during_upload_prevents_publication(self): - self.verify() - original = self.api.send - - def send(*args): - result = original(*args) - if args[1].endswith("?name=SHA256SUMS"): - self.api.tag_commit = "d" * 40 - return result - - with patch.object(self.api, "send", side_effect=send): - with self.assertRaisesRegex(release.ReleaseError, "tag moved during"): - self.publish() - self.assertTrue(self.api.releases[50]["draft"]) - self.assertNotIn("publish", [step for step, _ in self.api.writes]) - - def test_asset_change_before_final_check_prevents_publication(self): - self.verify() - original = self.api.send - - def send(*args): - result = original(*args) - if args[1].endswith("?name=SHA256SUMS"): - asset = next(item for item in self.api.assets.values() if item["name"] == "widget.zip") - asset["digest"] = "sha256:" + "0" * 64 - return result - - with patch.object(self.api, "send", side_effect=send): - with self.assertRaisesRegex(release.ReleaseError, "conflicts"): - self.publish() - self.assertTrue(self.api.releases[50]["draft"]) - - def test_cli_dry_run_reports_publication_conditions_and_outputs(self): - output = self.root / "github-output" - arguments = [str(SCRIPT), "verify", "--repository", REPOSITORY, "--ref", COMMIT, - "--publish", "false", "--commit", COMMIT, "--build-artifact-id", "30", "--run-id", "12", - "--ci-artifact", "widget-${commit}-3.13", - "--workflow-ref", WORKFLOW_REF, "--workflow-sha", WORKFLOW_SHA, - "--output", str(self.output)] - stdout = io.StringIO() - with patch.object(release, "GitHub", return_value=self.api), patch.object(sys, "argv", arguments), \ - patch.dict(os.environ, {"GH_TOKEN": "fake", "GITHUB_OUTPUT": str(output)}), redirect_stdout(stdout): - self.assertEqual(release.main(), 0) - self.assertIn("signed annotated v0.2.0", stdout.getvalue()) - self.assertIn("manual acceptance", stdout.getvalue()) - self.assertIn("commit=" + COMMIT + "\ntag=\nasset_digests=", output.read_text()) - self.assertEqual(self.api.writes, []) - - def test_dry_run_tag_uses_identical_assembly_without_writes(self): - self.verify() - expected = self.files() - self.verify_again(publish=False) - self.assertEqual({path.name: path.read_bytes() for path in (self.root / "recovered").iterdir()}, expected) - self.assertEqual(self.api.writes, []) - - def test_commit_dry_run_checks_ci_and_rebuild_without_tag_access(self): - self.verify(ref=COMMIT, publish=False) - receipt = json.loads((self.output / release.RECEIPT).read_bytes()) - self.assertIsNone(receipt["tag"]) - self.assertFalse(any("/git/" in path for _, path in self.api.calls)) - self.assert_no_writes(self.publish, "untagged") - self.api.runs[10]["conclusion"] = "failure" - self.assert_no_writes(lambda: self.verify_again(ref=COMMIT, publish=False), "successful authoritative CI") - - def test_tag_format_annotation_signature_and_manifest_version_checks(self): - for change, pattern in (({"tag_type": "commit"}, "annotated"), - ({"verified": False}, "signature"), - ({"reason": "unknown_key"}, "signature")): - with self.subTest(change=change), patch.multiple(self.api, **change): - self.assert_no_writes(self.verify, pattern) - for invalid in (COMMIT, "main", "v00.2.0", "v0.2.0-preview.1", "v0.2.0-rc.0"): - with self.subTest(ref=invalid): - self.assert_no_writes(lambda: self.verify(ref=invalid), "signed annotated") - self.set_archives(MANIFEST.replace(b'0.2.0', b'0.2.1'), self.archives) - self.assert_no_writes(self.verify, "version differs") - - def test_prerelease_tag_preserves_prerelease_status(self): - manifest = MANIFEST.replace(b'0.2.0', b'0.2.0-rc.1') - self.set_archives(manifest, {"widget.zip": zip_bytes({"computer-mcp-plugin.toml": manifest})}) - self.api = FakeGitHub(self.archives) - original = self.api.get - - def get(path): - result = original(path) - if "/git/tags/" in path: - result["tag"] = "v0.2.0-rc.1" - return result - - with patch.object(self.api, "get", side_effect=get): - self.verify(ref="v0.2.0-rc.1") - self.publish() - self.assertTrue(self.api.releases[50]["prerelease"]) - - def test_invalid_plugin_api_is_refused(self): - for replacement in (b"", b"plugin_api = 0\n", b"plugin_api = -1\n", b"plugin_api = true\n", - b'plugin_api = "1"\n', b"plugin_api = 1.0\n"): - with self.subTest(replacement=replacement): - (self.checkout / "computer-mcp-plugin.toml").write_bytes(MANIFEST.replace(b"plugin_api = 1\n", replacement)) - self.assert_no_writes(self.verify, "plugin_api") - - def test_verification_reads_source_at_the_resolved_commit_without_running_caller_code(self): - with patch.object(subprocess, "run", side_effect=AssertionError("Verification executed a command")): - self.verify() - self.assertIn(("GET", API_ROOT + "/contents/computer-mcp-plugin.toml?ref=" + COMMIT), self.api.calls) - - def test_verification_refuses_a_ref_that_moved_after_source_resolution(self): - self.api.tag_commit = "d" * 40 - self.assert_no_writes(self.verify, "ref moved after source resolution") - - def test_malicious_build_cannot_forge_the_verification_bundle_or_its_output_digests(self): - forged_archive = b"forged archive, not a ZIP" - info, _ = release.manifest_info(release.tomllib.loads(MANIFEST.decode()), TAG) - receipt = dict(info, schema=1, tag=TAG, commit=COMMIT, reproduction="rebuild", - archives=[{"name": "widget.zip", "size": len(forged_archive), - "sha256": release.sha256(forged_archive)}], - ci={"run_id": 10, "run_attempt": 1, - "artifacts": [{"id": 20, "digest": self.api.artifacts[20]["digest"]}]}, - workflow={"ref": WORKFLOW_REF, "sha": WORKFLOW_SHA}) - forged = release.assemble(receipt, {"widget.zip": forged_archive}) - forged_digests = json.dumps({name: release.sha256(data) for name, data in forged.items()}, sort_keys=True) - with (self.checkout / "build.py").open("a") as script: - script.write("\nroot = Path(os.environ['RUNNER_TEMP']) / 'plugin-release'\n" - "(root / 'assets').mkdir(parents=True)\n") - for name, data in forged.items(): - script.write(f"(root / 'assets' / {name!r}).write_bytes(base64.b64decode({base64.b64encode(data)!r}))\n") - script.write("(root / 'plugin_release.py').write_text('raise SystemExit(99)')\n" + - f"Path(os.environ['GITHUB_OUTPUT']).write_text({'asset_digests=' + forged_digests + chr(10)!r})\n" + - f"Path(os.environ['GITHUB_ENV']).write_text({'RELEASE_ASSET_DIGESTS=' + forged_digests + chr(10)!r})\n") - build_runner = self.run_build() - self.assertEqual((build_runner / "plugin-release/assets/widget.zip").read_bytes(), forged_archive) - self.assertIn(forged_digests, (build_runner / "output").read_text()) - verify_runner = self.root / "verify-runner" - root = verify_runner / "plugin-release" - root.mkdir(parents=True) - (root / "plugin_release.py").write_bytes(SCRIPT.read_bytes()) - self.output = root / "assets" - outputs = self.run_helper_step("verify", verify_runner, { - "RELEASE_REF": TAG, "RELEASE_PUBLISH": "true", "RELEASE_COMMIT": COMMIT, - "RELEASE_BUILD_ARTIFACT_ID": "30", "GH_RUN_ID": "12", "RELEASE_CI_ARTIFACT": "widget-${commit}-3.13", - "RELEASE_WORKFLOW_REF": WORKFLOW_REF, "RELEASE_WORKFLOW_SHA": WORKFLOW_SHA}) - self.asset_digests = dict(line.split("=", 1) for line in outputs.splitlines())["asset_digests"] - self.assertNotEqual(json.loads(self.asset_digests), json.loads(forged_digests)) - expected = self.files() - self.assertEqual(expected["widget.zip"], self.archives["widget.zip"]) - self.publish() - self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] - for item in self.api.assets.values()}, expected) - - def test_build_failure_cannot_reach_verification_or_publication(self): - with self.assertRaises(subprocess.CalledProcessError): - self.run_build("false") - self.assertEqual(self.api.writes, []) - self.assertFalse(self.output.exists()) - - def test_build_artifact_from_another_run_or_expired_is_refused(self): - for changes in ({"workflow_run": {"id": 99}}, {"expired": True}, {"id": 99}): - with self.subTest(changes=changes), patch.dict(self.api.artifacts[30], changes): - self.assert_no_writes(self.verify, "available artifact from this release run") - - def test_build_artifact_is_only_archive_data(self): - for extra in ("release-receipt.json", "SHA256SUMS", "plugin_release.py", "nested/widget.zip"): - with self.subTest(extra=extra): - self.api.install_build_artifact({**self.archives, extra: b"caller data"}) - self.assert_no_writes(self.verify, "ZIP basenames") - self.api.install_build_artifact({"widget.zip": b"forged archive, not a ZIP"}) - self.assert_no_writes(self.verify, "byte for byte") - - def test_authoritative_ci_ignores_other_event_branch_commit_workflow_or_repository(self): - for field, value in (("event", "pull_request"), ("event", "workflow_dispatch"), ("head_branch", "other"), - ("head_sha", "d" * 40), ("path", ".github/workflows/other.yml"), - ("head_repository", {"full_name": "fork/plugin-widget"})): - with self.subTest(field=field, value=value): - self.api.runs = {10: self.api.run(**{field: value})} - self.assert_no_writes(self.verify, "No successful authoritative CI") - - def test_ci_requires_successful_latest_attempt(self): - for state in ("failure", "cancelled", None): - with self.subTest(state=state): - self.api.runs = {10: self.api.run(run_attempt=2, conclusion=state)} - self.assert_no_writes(self.verify, "successful authoritative CI") - self.api.runs = {10: self.api.run(status="in_progress")} - self.assert_no_writes(self.verify, "successful authoritative CI") - - def test_latest_successful_authoritative_run_is_selected(self): - self.api.runs[9] = self.api.run(id=9) - self.api.runs[11] = self.api.run(id=11, conclusion="failure") - self.verify() - receipt = json.loads((self.output / release.RECEIPT).read_bytes()) - self.assertEqual(receipt["ci"]["run_id"], 10) - - def test_ci_rerun_after_listing_cannot_reuse_previous_attempt_success(self): - original = self.api.get - - def get(path): - result = original(path) - if path.endswith("/actions/runs/10"): - result.update(run_attempt=2, conclusion="failure") - return result - - with patch.object(self.api, "get", side_effect=get): - self.assert_no_writes(self.verify, "Latest CI attempt") - - def test_latest_attempt_excludes_artifacts_left_by_earlier_attempts(self): - self.api.rerun_ci(self.archives) - result = self.verify() - self.assertTrue(result["asset_digests"]) - receipt = json.loads((self.output / release.RECEIPT).read_bytes()) - self.assertEqual(receipt["ci"]["run_attempt"], 2) - self.assertEqual(receipt["ci"]["artifacts"][0]["id"], 21) - - def test_attempt_specific_metadata_defines_artifact_creation_window(self): - self.api.rerun_ci(self.archives) - attempt = copy.deepcopy(self.api.runs[10]) - self.api.runs[10]["run_started_at"] = "2026-01-01T00:00:00Z" - original = self.api.get - - def get(path): - return copy.deepcopy(attempt) if path.endswith("/attempts/2") else original(path) - - with patch.object(self.api, "get", side_effect=get): - self.verify() - receipt = json.loads((self.output / release.RECEIPT).read_bytes()) - self.assertEqual(receipt["ci"]["artifacts"][0]["id"], 21) - - def test_ci_artifact_requires_one_exact_live_artifact_with_digest(self): - for mode, pattern in (("missing", "exactly one"), ("duplicate", "exactly one"), - ("expired", "expired"), ("digest", "SHA-256"), ("old", "exactly one")): - with self.subTest(mode=mode): - self.api = FakeGitHub(self.archives) - if mode == "missing": - self.api.artifacts[20]["name"] += "-other" - elif mode == "duplicate": - self.api.install_artifact(21, self.archives) - elif mode == "old": - self.api.artifacts[20]["created_at"] = "2025-01-01T00:00:00Z" - elif mode == "digest": - self.api.artifacts[20]["digest"] = None - else: - self.api.artifacts[20]["expired"] = True - self.assert_no_writes(self.verify, pattern) - - def test_downloaded_ci_and_build_artifact_digest_mismatches_are_read_only(self): - self.api.artifact_bytes[20] += b"corrupt" - self.assert_no_writes(self.verify, "digest mismatch") - self.api = FakeGitHub(self.archives) - self.api.artifact_bytes[30] += b"corrupt" - self.assert_no_writes(self.verify, "build artifact digest mismatch") - self.assertEqual(self.api.writes, []) - self.assertFalse(self.output.exists()) - - def test_rebuild_mismatch_is_read_only_for_publication_and_dry_run(self): - self.api.install_artifact(20, {"widget.zip": self.archives["widget.zip"] + b"different"}) - for publish in (True, False): - with self.subTest(publish=publish): - self.assert_no_writes(lambda: self.verify(publish=publish), "byte for byte") - - def test_multiple_declared_archives_are_sorted_rebuilt_and_published(self): - manifest = MANIFEST + b'[[compatibility.artifacts]]\nname = "z-arm64.zip"\n[[compatibility.artifacts]]\nname = "a-x86.zip"\n' - self.set_archives(manifest, {name: zip_bytes({"computer-mcp-plugin.toml": manifest, "platform": name.encode()}) - for name in ("z-arm64.zip", "a-x86.zip")}) - self.api = FakeGitHub(self.archives) - self.verify() - self.publish() - self.assertEqual([step for step, _ in self.api.writes], - ["draft", release.RECEIPT, "a-x86.zip", "z-arm64.zip", release.SUMS, "publish"]) - - def test_invalid_artifact_declarations_are_refused(self): - for declaration in (b'artifacts = []\n', b'artifacts = ["widget.zip"]\n', - b'artifacts = [{name = "../widget.zip"}]\n', - b'artifacts = [{name = "widget.zip"}, {name = "widget.zip"}]\n'): - with self.subTest(declaration=declaration): - (self.checkout / "computer-mcp-plugin.toml").write_bytes(MANIFEST + declaration) - self.assert_no_writes(self.verify, "artifacts|artifact names") - - def test_archive_set_must_match_manifest_or_be_single(self): - for manifest in (MANIFEST, MANIFEST + b'[[compatibility.artifacts]]\nname = "missing.zip"\n'): - with self.subTest(manifest=manifest): - self.set_archives(manifest, self.archives) - self.api = FakeGitHub(dict(self.archives, **{"extra.zip": b"extra"})) - self.assert_no_writes(self.verify, "Archives differ") - - def test_archive_manifest_must_equal_source(self): - self.set_archives(MANIFEST, {"widget.zip": zip_bytes({"computer-mcp-plugin.toml": MANIFEST + b'platforms = ["macos"]\n'})}) - self.api = FakeGitHub(self.archives) - self.assert_no_writes(self.verify, "manifest differs") - - def test_bundle_tampering_is_refused_before_release_writes(self): - self.verify() - expected = self.files() - for name in (release.RECEIPT, "widget.zip", release.SUMS, "extra.txt"): - with self.subTest(name=name): - (self.output / name).write_bytes(expected.get(name, b"") + b"tampered") - self.assert_no_writes(self.publish, "differ|file set") - if name in expected: - (self.output / name).write_bytes(expected[name]) - else: - (self.output / name).unlink() - - def test_a_self_consistent_forged_bundle_cannot_replace_verify_job_digests(self): - self.verify() - receipt = json.loads(self.files()[release.RECEIPT]) - archive = b"forged archive, not a ZIP" - receipt["archives"][0].update(size=len(archive), sha256=release.sha256(archive)) - forged = release.assemble(receipt, {"widget.zip": archive}) - for name, data in forged.items(): - (self.output / name).write_bytes(data) - self.assert_no_writes(self.publish, "verify job's digests") - - def test_symlink_in_bundle_is_refused_before_reading_its_target(self): - self.verify() - (self.output / release.RECEIPT).unlink() - (self.output / release.RECEIPT).symlink_to(self.root / "missing-target") - self.assert_no_writes(self.publish, "file set") - - def test_invalid_frozen_receipt_or_source_is_rejected(self): - self.frozen_draft() - receipt_asset = next(item for item in self.api.assets.values() if item["name"] == release.RECEIPT) - original = (self.output / release.RECEIPT).read_bytes() - for mode in ("schema", "incomplete", "noncanonical", "commit", "plugin_api", "ci", "digest"): - with self.subTest(mode=mode): - receipt = json.loads(original) - if mode == "schema": - receipt["schema"] = 2 - elif mode == "incomplete": - del receipt["ci"] - elif mode == "commit": - receipt["commit"] = "d" * 40 - elif mode == "plugin_api": - receipt["plugin_api"] = 2 - elif mode == "ci": - receipt["ci"]["run_attempt"] = 0 - data = json.dumps(receipt).encode() if mode == "noncanonical" else release.canonical(receipt) - self.api.asset_bytes[receipt_asset["id"]] = data - receipt_asset["size"] = len(data) - receipt_asset["digest"] = "sha256:" + ("0" * 64 if mode == "digest" else release.sha256(data)) - self.assert_no_writes(self.verify_again, "schema|fields|canonical|source|provenance|download differs") - - -class FormatAndTransportTests(unittest.TestCase): - def test_unsafe_zip_paths_duplicate_names_and_symlinks_are_refused(self): - for name in ("../widget.zip", "/widget.zip", "dir\\widget.zip"): - with self.subTest(name=name), self.assertRaisesRegex(release.ReleaseError, "Unsafe"): - release.zip_entries(zip_bytes({name: b"data"})) - output = io.BytesIO() - with zipfile.ZipFile(output, "w") as archive: - entry = zipfile.ZipInfo("widget.zip") - entry.external_attr = (stat.S_IFLNK | 0o777) << 16 - archive.writestr(entry, b"target") - with self.assertRaisesRegex(release.ReleaseError, "special file"): - release.zip_entries(output.getvalue()) - with self.assertRaisesRegex(release.ReleaseError, "Duplicate CI"): - release.ci_archives(zip_bytes({"a/widget.zip": b"one", "b/widget.zip": b"two"}), None) - - def test_pagination_consumes_all_pages(self): - api = release.GitHub("fake") - with patch.object(api, "get", side_effect=[{"items": list(range(100))}, {"items": [100]}]) as get: - self.assertEqual(api.list(API_ROOT + "/example?filter=one", "items"), list(range(101))) - self.assertIn("&per_page=100&page=2", get.call_args[0][0]) - - def test_download_redirect_strips_token_and_requires_https(self): - request = Request("https://api.github.com/repos/example/plugin/actions/artifacts/20/zip", - headers={"Authorization": "Bearer fake"}) - redirect = release.SafeRedirect() - moved = redirect.redirect_request(request, None, 302, "Found", {}, "https://blob.example/archive.zip") - self.assertFalse(moved.has_header("Authorization")) - with self.assertRaisesRegex(release.ReleaseError, "non-HTTPS"): - redirect.redirect_request(request, None, 302, "Found", {}, "http://blob.example/archive.zip") - - -class WorkflowBoundaryTests(unittest.TestCase): - def test_workflow_helper_digest_matches_source_and_actions_are_pinned(self): - workflow = WORKFLOW.read_text() - self.assertEqual(re.findall(r"PLUGIN_RELEASE_SHA256: '([0-9a-f]{64})'", workflow), - [release.sha256(SCRIPT.read_bytes())]) - uses = re.findall(r"uses: (\S+)", workflow) - self.assertTrue(uses) - self.assertTrue(all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", use) for use in uses)) - source, build, verify, publish = (workflow_job(name) for name in ("source", "build", "verify", "publish")) - self.assertIn(" contents: read\n actions: read", source) - self.assertIn(" permissions: {}", build) - self.assertNotIn("GH_TOKEN", build) - self.assertNotIn("github.token", build) - self.assertNotIn("plugin-release.py", build) - self.assertIn("persist-credentials: false", build) - self.assertIn("needs: source", build) - self.assertIn("ref: ${{ needs.source.outputs.commit }}", build) - self.assertIn("RELEASE_BUILD: ${{ inputs.build }}", build) - self.assertEqual(workflow.count("inputs.build"), 1) - self.assertIn(" contents: read\n actions: read", verify) - self.assertIn("needs: [source, build]", verify) - self.assertIn("runs-on: ubuntu-24.04", verify) - self.assertNotIn("actions/checkout", verify) - self.assertNotIn("--build'", verify) - self.assertIn(" contents: write", publish) - self.assertIn("needs: verify", publish) - self.assertNotIn("actions: read", publish) - self.assertNotIn("actions/checkout", publish) - self.assertNotIn("secrets", workflow) - self.assertNotIn("RELEASE_BUILD", publish) - - def test_verify_and_publish_bootstraps_reject_modified_helper_without_executing_it(self): - for job in ("verify", "publish"): - with self.subTest(job=job), tempfile.TemporaryDirectory() as directory: - root = Path(directory) / "plugin-release" - root.mkdir() - marker = root / "executed" - (root / "plugin_release.py").write_text(f"from pathlib import Path\nPath({str(marker)!r}).touch()\n") - with patch.dict(os.environ, {"RUNNER_TEMP": directory, "PLUGIN_RELEASE_SHA256": release.sha256(SCRIPT.read_bytes())}): - with self.assertRaisesRegex(SystemExit, "differs from this workflow pin"): - exec(compile(python_step(job), str(WORKFLOW), "exec"), {}) - self.assertFalse(marker.exists()) - - def test_bootstrap_loads_pinned_reusable_workflow_source_from_run_metadata(self): - script = python_step("source") - run = {"referenced_workflows": [{"path": WORKFLOW_REF, "sha": WORKFLOW_SHA}, - {"path": "example/caller/.github/workflows/other.yml@main", "sha": "c" * 40}]} - responses = [io.BytesIO(json.dumps(run).encode()), - io.BytesIO(json.dumps({"content": base64.b64encode(SCRIPT.read_bytes()).decode()}).encode())] - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "output" - with patch.dict(os.environ, {"RUNNER_TEMP": directory, "PLUGIN_RELEASE_SHA256": release.sha256(SCRIPT.read_bytes()), - "GH_REPOSITORY": REPOSITORY, "GH_RUN_ID": "12", "GH_TOKEN": "fake", - "GITHUB_OUTPUT": str(output)}): - with patch("urllib.request.urlopen", side_effect=responses) as urlopen: - exec(compile(textwrap.dedent(script), str(WORKFLOW), "exec"), {}) - self.assertEqual((Path(directory) / "plugin-release/plugin_release.py").read_bytes(), SCRIPT.read_bytes()) - self.assertEqual(output.read_text(), f"workflow_ref={WORKFLOW_REF}\nworkflow_sha={WORKFLOW_SHA}\n") - self.assertEqual(urlopen.call_args[0][0].full_url, - "https://api.github.com/repos/computer-mcp/.github/contents/Scripts/plugin_release.py?ref=" + WORKFLOW_SHA) - - -if __name__ == "__main__": - unittest.main() diff --git a/Tests/test_plugin_runtime.py b/Tests/test_plugin_runtime.py deleted file mode 100644 index abc5c6d..0000000 --- a/Tests/test_plugin_runtime.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Version assertions are resolved before the owned native probe starts.""" - -from pathlib import Path -import sys -import unittest -from unittest.mock import Mock, patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "PluginRuntime")) -import plugin_runtime as runtime - - -class VersionTests(unittest.TestCase): - def test_tree_assertion_preserves_exact_stdout(self): - tree = {"executable_checks": [ - {"args": ["--help"], "stdout": "help\n"}, - {"args": ["--version"], "stdout": "native 1.0\n"}, - {"args": ["--version"], "stdout": "later\n"}, - ]} - self.assertEqual(runtime.expected_version(tree), "native 1.0\n") - - def test_empty_stdout_is_an_assertion(self): - self.assertEqual(runtime.expected_version({ - "executable_checks": [{"args": ["--version"], "stdout": ""}], - }), "") - - def test_missing_assertion_fails_before_any_process_starts(self): - for tree in ( - {}, - {"executable_checks": []}, - {"executable_checks": [{"args": ["--help"], "stdout": "help\n"}]}, - {"executable_checks": [{"args": ["--version", "extra"], "stdout": "1\n"}]}, - {"executable_checks": [{"args": ["--version"]}]}, - {"executable_checks": [{"args": ["--version"], "stdout": None}]}, - ): - with self.subTest(tree=tree), patch.object(runtime, "Process") as process: - with self.assertRaises(runtime.Failure) as raised: - runtime.verify_version("/native", runtime.expected_version(tree), runtime.Job(), "/work") - self.assertEqual(raised.exception.code, "invalid_configuration") - self.assertEqual(str(raised.exception), "Package lacks its native version assertion") - process.assert_not_called() - - def test_tree_and_direct_version_strings_use_the_same_probe(self): - expected = "native α\n" - tree = {"executable_checks": [{"args": ["--version"], "stdout": expected}]} - for value in (runtime.expected_version(tree), expected): - with self.subTest(value=value): - process = Mock() - process.read.side_effect = [b"native ", "α\n".encode(), None] - process.finish.return_value = 0 - retained = Mock() - job = runtime.Job() - with patch.object(runtime, "Process", return_value=process) as factory: - runtime.verify_version("/native", value, job, "/work", retained) - factory.assert_called_once_with(["/native", "--version"], "/work") - retained.assert_called_once_with(process) - process.end_input.assert_called_once_with() - deadline = process.read.call_args_list[0].args[0] - for call in process.read.call_args_list: - self.assertEqual(call.args, (deadline, job)) - process.finish.assert_called_once_with(deadline, job) - process.close.assert_called_once_with() - - def test_mismatch_and_nonzero_exit_keep_the_existing_failure(self): - for output, status in ((b"other\n", 0), (b"native", 0), (b"native\n", 1)): - with self.subTest(output=output, status=status): - process = Mock() - process.read.side_effect = [output, None] - process.finish.return_value = status - with patch.object(runtime, "Process", return_value=process): - with self.assertRaises(runtime.Failure) as raised: - runtime.verify_version("/native", "native\n", runtime.Job(), "/work") - self.assertEqual(raised.exception.code, "incompatible_vendor") - self.assertEqual(str(raised.exception), "Vendor version differs from the verified plugin baseline") - process.close.assert_called_once_with() - - def test_output_bound_closes_the_probe(self): - process = Mock() - process.read.side_effect = [b"x" * 65536, b"x"] - with patch.object(runtime, "Process", return_value=process): - with self.assertRaises(runtime.Failure) as raised: - runtime.verify_version("/native", "native\n", runtime.Job(), "/work") - self.assertEqual(raised.exception.code, "incompatible_vendor") - self.assertEqual(str(raised.exception), "Native version output exceeded its bound") - process.finish.assert_not_called() - process.close.assert_called_once_with() - - def test_cancellation_and_timeout_close_the_probe(self): - for code in ("cancelled", "timeout"): - with self.subTest(code=code): - failure = runtime.Failure(code, "probe stopped") - process = Mock() - process.read.side_effect = failure - with patch.object(runtime, "Process", return_value=process): - with self.assertRaises(runtime.Failure) as raised: - runtime.verify_version("/native", "native\n", runtime.Job(), "/work") - self.assertIs(raised.exception, failure) - process.close.assert_called_once_with() - - def test_retention_failure_still_closes_the_probe(self): - process = Mock() - failure = RuntimeError("adapter shutdown") - with patch.object(runtime, "Process", return_value=process): - with self.assertRaises(RuntimeError) as raised: - runtime.verify_version("/native", "native\n", runtime.Job(), "/work", Mock(side_effect=failure)) - self.assertIs(raised.exception, failure) - process.end_input.assert_not_called() - process.close.assert_called_once_with() - - -if __name__ == "__main__": - unittest.main() diff --git a/Tests/test_runtime_delivery.py b/Tests/test_runtime_delivery.py deleted file mode 100644 index 1a7222e..0000000 --- a/Tests/test_runtime_delivery.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Offline delivery and execution of the reusable workflow's actual verifier.""" - -import hashlib -import os -from pathlib import Path -import re -import shlex -import shutil -import subprocess -import sys -import tempfile -import textwrap -import unittest - -ROOT = Path(__file__).resolve().parents[1] -CANONICAL = ROOT / "PluginRuntime/plugin_runtime.py" -WORKFLOW = ROOT / ".github/workflows/plugin-runtime.yml" - - -class DeliveryTests(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - self.authority = self.root / "standards" - self.script = self.authority / "Scripts/plugin-runtime.py" - self.canonical = self.authority / "PluginRuntime/plugin_runtime.py" - self.workflow = self.authority / ".github/workflows/plugin-runtime.yml" - for source, target in ( - (ROOT / "Scripts/plugin-runtime.py", self.script), - (CANONICAL, self.canonical), - (WORKFLOW, self.workflow), - ): - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, target) - self.checkout = self.root / "plugin checkout" - self.checkout.mkdir() - self.target = self.checkout / "bin/plugin_runtime.py" - - def run_command(self, *args, expected=0): - result = subprocess.run( - [sys.executable, "-I", str(self.script), *map(str, args)], - cwd=self.checkout, capture_output=True, text=True, timeout=10, - ) - self.assertEqual(result.returncode, expected, result.stdout + result.stderr) - self.assertNotIn("Traceback", result.stderr) - return result - - def test_sync_and_check_from_another_directory_with_spaces(self): - self.run_command("sync", self.checkout) - self.assertEqual(self.target.read_bytes(), self.canonical.read_bytes()) - before = self.target.stat().st_mtime_ns - self.run_command("check", self.checkout) - self.assertEqual(self.target.stat().st_mtime_ns, before) - - def test_sync_replaces_drift_and_can_be_repeated(self): - self.target.parent.mkdir() - self.target.write_bytes(b"local copy\n") - self.run_command("sync", self.checkout) - self.run_command("sync", self.checkout) - self.assertEqual(self.target.read_bytes(), self.canonical.read_bytes()) - - def test_check_rejects_missing_file_without_writing(self): - result = self.run_command("check", self.checkout, expected=1) - self.assertIn("Missing plugin runtime", result.stderr) - self.assertFalse(self.target.parent.exists()) - - def test_check_rejects_byte_drift_without_writing(self): - self.run_command("sync", self.checkout) - for content in (self.canonical.read_bytes() + b"\n", b"\xff\x00"): - with self.subTest(content=content[-8:]): - self.target.write_bytes(content) - result = self.run_command("check", self.checkout, expected=1) - self.assertIn("differs from the canonical", result.stderr) - self.assertEqual(self.target.read_bytes(), content) - - def test_commands_require_an_existing_checkout_directory(self): - invalid = self.root / "missing" - for command in ("sync", "check"): - with self.subTest(command=command): - self.run_command(command, invalid, expected=1) - self.assertFalse(invalid.exists()) - self.run_command(command, self.script, expected=1) - - def test_delivery_cannot_follow_symlinks_outside_checkout(self): - outside = self.root / "outside" - outside.mkdir() - protected = outside / "plugin_runtime.py" - protected.write_bytes(b"outside content\n") - for parent_link in (True, False): - with self.subTest(parent_link=parent_link): - if parent_link: - self.target.parent.symlink_to(outside, target_is_directory=True) - else: - self.target.parent.mkdir() - self.target.symlink_to(protected) - for command in ("sync", "check"): - result = self.run_command(command, self.checkout, expected=1) - self.assertIn("escapes the checkout", result.stderr) - self.assertEqual(protected.read_bytes(), b"outside content\n") - if parent_link: - self.target.parent.unlink() - else: - self.target.unlink() - self.target.parent.rmdir() - - def test_check_workflow_matches_canonical(self): - self.run_command("check-workflow") - - def test_check_workflow_rejects_a_stale_digest(self): - self.canonical.write_bytes(self.canonical.read_bytes() + b"\n") - result = self.run_command("check-workflow", expected=1) - self.assertIn("differs from the canonical runtime", result.stderr) - - def test_check_workflow_rejects_missing_malformed_and_duplicate_digest(self): - original = self.workflow.read_text() - line = next(line for line in original.splitlines() if "PLUGIN_RUNTIME_SHA256:" in line) - for replacement in ("", " PLUGIN_RUNTIME_SHA256: 'bad'", line + "\n" + line): - with self.subTest(replacement=replacement): - self.workflow.write_text(original.replace(line, replacement)) - result = self.run_command("check-workflow", expected=1) - self.assertIn("exactly one quoted", result.stderr) - - -class WorkflowVerifierTests(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - self.checkout = self.root / "consumer" - self.checkout.mkdir() - self.target = self.checkout / "bin/plugin_runtime.py" - self.target.parent.mkdir() - workflow = WORKFLOW.read_text() - command, script = workflow.split("<<'PY'\n", 1) - self.command = [sys.executable, *shlex.split(command.splitlines()[-1])[1:]] - self.script = textwrap.dedent(script.split("\n PY\n", 1)[0]) - self.digest = re.search(r"PLUGIN_RUNTIME_SHA256: '([0-9a-f]{64})'", workflow).group(1) - - def verify(self, expected): - result = subprocess.run( - self.command, input=self.script, text=True, - cwd=self.checkout, capture_output=True, timeout=10, - env=dict(os.environ, PLUGIN_RUNTIME_SHA256=self.digest), - ) - self.assertEqual(result.returncode, expected, result.stdout + result.stderr) - self.assertNotIn("Traceback", result.stderr) - return result - - def test_exact_runtime_passes(self): - self.target.write_bytes(CANONICAL.read_bytes()) - self.verify(0) - - def test_missing_runtime_fails(self): - result = self.verify(1) - self.assertIn("Missing plugin runtime", result.stderr) - - def test_changed_runtime_fails_without_executing_consumer_code(self): - marker = self.checkout / "executed" - self.target.write_text("from pathlib import Path\nPath('executed').touch()\n") - result = self.verify(1) - self.assertIn("differs from the pinned", result.stderr) - self.assertFalse(marker.exists()) - - def test_changed_runtime_fails_with_consumer_hashlib(self): - self.target.write_bytes(CANONICAL.read_bytes() + b"\n") - (self.checkout / "hashlib.py").write_text(textwrap.dedent("""\ - import os - from pathlib import Path - - Path("hashlib-imported").touch() - - class Digest: - def hexdigest(self): - return os.environ["PLUGIN_RUNTIME_SHA256"] - - def sha256(data): - return Digest() - """)) - result = self.verify(1) - self.assertIn("differs from the pinned", result.stderr) - self.assertFalse((self.checkout / "hashlib-imported").exists()) - - def test_runtime_outside_checkout_fails_even_when_digest_matches(self): - outside = self.root / "outside.py" - outside.write_bytes(CANONICAL.read_bytes()) - self.assertEqual(hashlib.sha256(outside.read_bytes()).hexdigest(), self.digest) - self.target.symlink_to(outside) - self.verify(1) - - -if __name__ == "__main__": - unittest.main() diff --git a/VERSIONING.md b/VERSIONING.md index c48b8f0..9d47978 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -86,7 +86,7 @@ filename. It covers every archive and the receipt; it does not hash itself. Human acceptance results and their limits belong in the Release notes. The reusable -[plugin release workflow](.github/workflows/plugin-release.yml) uses the +[plugin release workflow](https://github.com/computer-mcp/github-workflows/blob/master/docs/plugin-release.md) uses the repository's default-branch `push` run of `.github/workflows/ci.yml` for the exact source commit. It selects the latest successful matching run and rechecks its latest attempt. The exact `ci_artifact` name selects one artifact diff --git a/profile/README.md b/profile/README.md index 08c1a22..4e83ec9 100644 --- a/profile/README.md +++ b/profile/README.md @@ -1,35 +1,37 @@ - - - Computer MCP — Wherever you chat, your computer is there. - - -**Wherever you chat, your computer is there.** - -**聊天在哪,你的电脑就在哪。** - -ChatGPT can read and write code and run commands right on your computer, and use your -command-line tools, MCP, Skills and Computer Use; when needed, it hands the work to -Codex, Claude Code or Cursor. You set the permissions for each computer. - -[Get started](https://computer-mcp.github.io/) · -[Product and downloads](https://github.com/computer-mcp/computer-mcp) · -[Documentation](https://github.com/computer-mcp/computer-mcp/tree/master/Documentation) - -| Repository | Role | -| --- | --- | -| [Computer MCP](https://github.com/computer-mcp/computer-mcp) | macOS App that connects ChatGPT to your computer | -| [Website](https://github.com/computer-mcp/computer-mcp.github.io) | Product guide, verified downloads and plugin catalog | -| [Codex](https://github.com/computer-mcp/plugin-codex) | Codex execution and protocol inspection | -| [Computer Use](https://github.com/computer-mcp/plugin-computer-use) | Native desktop observation and actions | -| [Claude Code](https://github.com/computer-mcp/plugin-claude) | Claude Code CLI and streaming MCP adapter | -| [Cursor](https://github.com/computer-mcp/plugin-cursor) | Cursor Agent CLI and ACP integration | -| [Swift Format](https://github.com/computer-mcp/plugin-swift-format) | Verified Swift formatting and linting | -| [TRAE](https://github.com/computer-mcp/plugin-trae) | TRAE sessions through ACP; repository access required | -| [Apple CLI](https://github.com/computer-mcp/apple-cli) | A local macOS CLI and MCP server for Apple apps | -| [Homebrew Tap](https://github.com/computer-mcp/homebrew-tap) | Install Computer MCP and Apple CLI with Homebrew | - -The App requires macOS 14 or later. Vendor runtimes and accounts have their own -requirements. Integrations retain vendor ownership and do not imply endorsement. - -The [MCP Swift SDK fork](https://github.com/computer-mcp/swift-sdk) retains its -upstream SDK identity and independent technical scope. +

Computer MCP mark

+ +

Wherever you chat, your computer is there.

+ +

ChatGPT and other MCP clients can read and write code, run commands and use your tools on your Mac, with permissions you set on each computer.

+ +

Documentation · Contributing · Security · Support

+ +

Use Computer MCP

+ + + + + +
computer-mcp iconcomputer-mcp
Connect ChatGPT and other MCP clients to your Mac's tools, with permissions set on each computer.
homebrew-tap iconhomebrew-tap
Install the Computer MCP App and Apple CLI with Homebrew.
computer-mcp.github.io iconcomputer-mcp.github.io
Read the product guide, get verified downloads and browse the plugin catalog.
+ +

Hand work to coding agents

+ + + + + +
plugin-claude iconplugin-claude
Run Claude Code from your chat and stream its results back.
plugin-codex iconplugin-codex
Run Codex from your chat and inspect its protocol traffic.
plugin-cursor iconplugin-cursor
Run Cursor's agent from your chat over ACP.
+ +

Add tools for your Mac

+ + + + + +
plugin-computer-use iconplugin-computer-use
Let your chat see and act on the apps on your Mac.
plugin-swift-format iconplugin-swift-format
Format and lint Swift code with a verified swift-format.
apple-cli iconapple-cli
Use Notes, Reminders, Calendar and other Apple apps from the terminal or an MCP client.
+ +

Maintain the family

+ + + +
github-workflows icongithub-workflows
Run the same checks and releases in every Computer MCP repository.
diff --git a/Brand/Exports/web/avatar.png b/profile/assets/avatar.png similarity index 100% rename from Brand/Exports/web/avatar.png rename to profile/assets/avatar.png diff --git a/profile/assets/mark-dark.png b/profile/assets/mark-dark.png new file mode 100644 index 0000000..ac167c1 Binary files /dev/null and b/profile/assets/mark-dark.png differ diff --git a/Brand/Exports/app/app-icon.png b/profile/assets/mark-light.png similarity index 100% rename from Brand/Exports/app/app-icon.png rename to profile/assets/mark-light.png diff --git a/profile/brand/avatar.png b/profile/brand/avatar.png deleted file mode 100644 index e66e84b..0000000 Binary files a/profile/brand/avatar.png and /dev/null differ diff --git a/profile/brand/header-dark.png b/profile/brand/header-dark.png deleted file mode 100644 index 1679ee8..0000000 Binary files a/profile/brand/header-dark.png and /dev/null differ diff --git a/profile/brand/header-light.png b/profile/brand/header-light.png deleted file mode 100644 index 0ac66a7..0000000 Binary files a/profile/brand/header-light.png and /dev/null differ diff --git a/profile/catalog.json b/profile/catalog.json new file mode 100644 index 0000000..ad82827 --- /dev/null +++ b/profile/catalog.json @@ -0,0 +1,165 @@ +{ + "organization": { + "login": "computer-mcp", + "name": "Computer MCP", + "about": "Computer MCP organization profile, design system and shared community files." + }, + "promise": "Wherever you chat, your computer is there.", + "secondary": "ChatGPT and other MCP clients can read and write code, run commands and use your tools on your Mac, with permissions you set on each computer.", + "mark": { + "light": "assets/mark-light.png", + "dark": "assets/mark-dark.png" + }, + "navigation": { + "documentation": { + "label": "Documentation", + "url": "https://github.com/computer-mcp/computer-mcp/tree/master/Documentation" + }, + "contributing": { + "label": "Contributing", + "url": "https://github.com/computer-mcp/.github/blob/master/CONTRIBUTING.md" + }, + "security": { + "label": "Security", + "url": "https://github.com/computer-mcp/.github/blob/master/SECURITY.md" + }, + "support": { + "label": "Support", + "url": "https://github.com/computer-mcp/.github/blob/master/SUPPORT.md" + } + }, + "website": "https://computer-mcp.github.io/", + "description": "Wherever you chat, your computer is there. Connect ChatGPT and other MCP clients to your computer's tools, with permissions set on each computer.", + "groups": [ + { + "title": "Use Computer MCP", + "repositories": [ + { + "name": "computer-mcp", + "outcome": "Connect ChatGPT and other MCP clients to your Mac's tools, with permissions set on each computer.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/computer-mcp/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/computer-mcp/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "app", + "title": "Computer MCP", + "about": "Wherever you chat, your computer is there. A macOS App that connects ChatGPT and other MCP clients to your computer's tools, with permissions set on each computer." + }, + { + "name": "homebrew-tap", + "outcome": "Install the Computer MCP App and Apple CLI with Homebrew.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/computer-mcp/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/computer-mcp/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "distribution", + "title": "Homebrew Tap", + "about": "Homebrew formulae and casks for Computer MCP and Apple CLI." + }, + { + "name": "computer-mcp.github.io", + "outcome": "Read the product guide, get verified downloads and browse the plugin catalog.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/computer-mcp/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/computer-mcp/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "website", + "title": "Website", + "about": "Computer MCP website: product overview, setup guide, verified downloads and plugin catalog." + } + ] + }, + { + "title": "Hand work to coding agents", + "repositories": [ + { + "name": "plugin-claude", + "outcome": "Run Claude Code from your chat and stream its results back.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/plugin-claude/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/plugin-claude/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "library", + "title": "Claude Code", + "about": "Computer MCP family plugin for Claude Code CLI and streaming MCP integration." + }, + { + "name": "plugin-codex", + "outcome": "Run Codex from your chat and inspect its protocol traffic.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/plugin-codex/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/plugin-codex/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "library", + "title": "Codex", + "about": "Computer MCP family plugin for optional Codex execution and protocol inspection." + }, + { + "name": "plugin-cursor", + "outcome": "Run Cursor's agent from your chat over ACP.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/plugin-cursor/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/plugin-cursor/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "library", + "title": "Cursor", + "about": "Computer MCP family plugin for Cursor Agent CLI and ACP integration." + } + ] + }, + { + "title": "Add tools for your Mac", + "repositories": [ + { + "name": "plugin-computer-use", + "outcome": "Let your chat see and act on the apps on your Mac.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/plugin-computer-use/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/plugin-computer-use/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "library", + "title": "Computer Use", + "about": "Computer MCP family plugin for native desktop observation and actions." + }, + { + "name": "plugin-swift-format", + "outcome": "Format and lint Swift code with a verified swift-format.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/plugin-swift-format/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/plugin-swift-format/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "library", + "title": "Swift Format", + "about": "Computer MCP family plugin for verified Swift formatting and linting." + }, + { + "name": "apple-cli", + "outcome": "Use Notes, Reminders, Calendar and other Apple apps from the terminal or an MCP client.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/apple-cli/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/apple-cli/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "tool", + "title": "Apple CLI", + "about": "A local macOS CLI and MCP server for Apple apps." + } + ] + }, + { + "title": "Maintain the family", + "repositories": [ + { + "name": "github-workflows", + "outcome": "Run the same checks and releases in every Computer MCP repository.", + "icon": { + "light": "https://raw.githubusercontent.com/computer-mcp/github-workflows/HEAD/.github/assets/logo/logo-light.png", + "dark": "https://raw.githubusercontent.com/computer-mcp/github-workflows/HEAD/.github/assets/logo/logo-dark.png" + }, + "kind": "library", + "title": "Shared Workflows", + "about": "Reusable workflows and checks for Computer MCP repositories: source policy, the organization check, the shared plugin runtime and plugin releases." + } + ] + } + ] +}