From b42f9261a580cf2fe71e502b8511c9a15a9985bb Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 27 Oct 2026 08:33:21 +0100 Subject: [PATCH] initial commit --- .gitignore | 54 + BETRIEB.md | 95 + COMPATIBILITY.md | 37 + KONZEPT.md | 1909 ++++++++++ README.md | 165 + STUFE0.md | 50 + TESTEN.md | 354 ++ cli/cmd/hugin/author.go | 313 ++ cli/cmd/hugin/import.go | 68 + cli/cmd/hugin/init.go | 45 + cli/cmd/hugin/main.go | 42 + cli/cmd/hugin/mcp.go | 127 + cli/cmd/hugin/status.go | 106 + cli/cmd/hugin/sync.go | 338 ++ cli/cmd/hugin/sync_test.go | 31 + cli/go.mod | 18 + cli/go.sum | 33 + cli/internal/api/api.go | 279 ++ cli/internal/api/secure_test.go | 46 + cli/internal/bundle/bundle.go | 313 ++ cli/internal/bundle/bundle_test.go | 92 + cli/internal/config/config.go | 92 + cli/internal/config/config_test.go | 81 + cli/internal/importer/importer.go | 943 +++++ cli/internal/importer/importer_test.go | 332 ++ cli/internal/keystore/keystore.go | 54 + cli/internal/keystore/keystore_test.go | 18 + cli/internal/localmcp/exec_unix.go | 11 + cli/internal/localmcp/exec_windows.go | 26 + cli/internal/localmcp/localmcp.go | 340 ++ cli/internal/localmcp/localmcp_test.go | 186 + cli/internal/lock/lock.go | 134 + cli/internal/lock/lock_test.go | 83 + cli/internal/paths/paths.go | 70 + cli/internal/paths/paths_test.go | 34 + cli/internal/pkgdir/pkgdir.go | 168 + cli/internal/pkgdir/pkgdir_test.go | 71 + cli/internal/reconcile/block.go | 93 + cli/internal/reconcile/json.go | 326 ++ cli/internal/reconcile/json_test.go | 83 + cli/internal/reconcile/reconcile.go | 523 +++ cli/internal/reconcile/reconcile_test.go | 328 ++ cli/internal/repo/repo.go | 58 + cli/internal/repo/repo_test.go | 17 + cli/internal/tools/opencode_test.go | 38 + cli/internal/tools/tools.go | 115 + cli/internal/tools/tools_test.go | 44 + contract/bundle-request.schema.json | 73 + .../.claude/skills/pub-munin-nutzen/SKILL.md | 17 + .../pub-munin-nutzen/referenz/beispiele.md | 4 + .../files/.hugin/rules/pub-hausregeln.md | 6 + .../bundles/claude-only/hugin-bundle.json | 57 + .../files/.claude/agents/pub-code-review.md | 12 + .../.claude/commands/pub-release-notiz.md | 13 + .../files/.opencode/agents/pub-code-review.md | 16 + .../.opencode/commands/pub-release-notiz.md | 9 + .../commands-and-agents/hugin-bundle.json | 59 + .../.claude/skills/pub-munin-nutzen/SKILL.md | 17 + .../pub-munin-nutzen/referenz/beispiele.md | 4 + .../files/.hugin/rules/pub-hausregeln.md | 6 + .../bundles/first-install/hugin-bundle.json | 115 + .../bundles/local-mcp/hugin-bundle.json | 86 + .../local/pub-zeit-lokal/1.0.0/zeit_server.py | 52 + .../.opencode/plugins/pub-specht-plugin.ts | 2 + .../plugins/pub-specht-plugin/index.ts | 17 + .../plugins/pub-specht-plugin/lib/marke.ts | 1 + .../bundles/opencode-plugin/hugin-bundle.json | 43 + .../.claude/skills/pub-munin-nutzen/SKILL.md | 18 + .../examples/bundles/update/hugin-bundle.json | 102 + .../packages/pub-code-review/1.0.0/AGENT.md | 3 + .../packages/pub-code-review/1.0.0/hugin.yaml | 15 + .../packages/pub-hausregeln/1.0.0/RULES.md | 4 + .../packages/pub-hausregeln/1.0.0/hugin.yaml | 6 + .../pub-munin-anbindung/1.0.0/hugin.yaml | 11 + .../packages/pub-munin-nutzen/1.4.0/SKILL.md | 10 + .../pub-munin-nutzen/1.4.0/hugin.yaml | 11 + .../1.4.0/referenz/beispiele.md | 4 + .../packages/pub-munin-nutzen/1.5.0/SKILL.md | 11 + .../pub-munin-nutzen/1.5.0/hugin.yaml | 11 + .../pub-release-notiz/1.0.0/COMMAND.md | 4 + .../pub-release-notiz/1.0.0/hugin.yaml | 7 + .../pub-specht-plugin/1.0.0/hugin.yaml | 9 + .../packages/pub-specht-plugin/1.0.0/index.ts | 17 + .../pub-specht-plugin/1.0.0/lib/marke.ts | 1 + .../packages/pub-zeit-lokal/1.0.0/hugin.yaml | 14 + .../pub-zeit-lokal/1.0.0/zeit_server.py | 52 + contract/examples/scenarios.yaml | 29 + contract/hugin-bundle.schema.json | 473 +++ docker-compose.yml | 35 + scripts/build-cli.sh | 31 + scripts/csp-test.sh | 27 + scripts/csp/actions.js | 22 + scripts/csp/server.py | 28 + scripts/csp/web_actions.py | 203 + scripts/csp/web_browser.py | 200 + scripts/demo.sh | 107 + scripts/integration-test.ps1 | 125 + scripts/integration-test.sh | 137 + scripts/postgres-test.sh | 42 + scripts/smoke-test-local.sh | 124 + scripts/stage0.sh | 91 + scripts/sync-framework.sh | 11 + scripts/tool-smoke-test.sh | 356 ++ .../packages/pub-smoke-agent/1.0.0/AGENT.md | 1 + .../packages/pub-smoke-agent/1.0.0/hugin.yaml | 7 + .../pub-smoke-command/1.0.0/COMMAND.md | 3 + .../pub-smoke-command/1.0.0/hugin.yaml | 7 + .../packages/pub-smoke-manual/1.0.0/SKILL.md | 3 + .../pub-smoke-manual/1.0.0/hugin.yaml | 13 + .../packages/pub-smoke-rules/1.0.0/RULES.md | 3 + .../packages/pub-smoke-rules/1.0.0/hugin.yaml | 6 + .../packages/pub-smoke-skill/1.0.0/SKILL.md | 3 + .../packages/pub-smoke-skill/1.0.0/hugin.yaml | 8 + server/.dockerignore | 9 + server/Dockerfile | 45 + server/pyproject.toml | 38 + server/src/hugin_app/__init__.py | 5 + server/src/hugin_app/api/__init__.py | 1 + server/src/hugin_app/api/admin.py | 168 + server/src/hugin_app/api/delivery.py | 180 + server/src/hugin_app/api/v1.py | 342 ++ server/src/hugin_app/app.py | 98 + server/src/hugin_app/audit.py | 22 + server/src/hugin_app/auth/__init__.py | 6 + server/src/hugin_app/auth/access.py | 48 + server/src/hugin_app/auth/keys.py | 20 + server/src/hugin_app/command.py | 49 + server/src/hugin_app/docs/__init__.py | 104 + server/src/hugin_app/docs/admins.md | 51 + server/src/hugin_app/docs/agents.md | 61 + server/src/hugin_app/docs/api.md | 18 + server/src/hugin_app/docs/authors.md | 59 + server/src/hugin_app/docs/cli.md | 62 + server/src/hugin_app/docs/glossary.md | 42 + server/src/hugin_app/docs/import.md | 63 + server/src/hugin_app/docs/integration.md | 63 + server/src/hugin_app/docs/kinds.md | 184 + server/src/hugin_app/docs/linter.md | 17 + server/src/hugin_app/docs/package-format.md | 196 + server/src/hugin_app/docs/reviewers.md | 46 + server/src/hugin_app/docs/roles.md | 60 + server/src/hugin_app/docs/start.md | 35 + server/src/hugin_app/docs/teams.md | 102 + server/src/hugin_app/docs/troubleshooting.md | 111 + server/src/hugin_app/download.py | 53 + server/src/hugin_app/export.py | 150 + server/src/hugin_app/hugin.py | 191 + server/src/hugin_app/management.py | 378 ++ server/src/hugin_app/mcp.py | 163 + server/src/hugin_app/migration.py | 33 + server/src/hugin_app/migrations/env.py | 28 + .../src/hugin_app/migrations/script.py.mako | 25 + .../versions/0001_initial_tables.py | 289 ++ server/src/hugin_app/platform.py | 69 + server/src/hugin_app/rate_limit.py | 172 + server/src/hugin_app/services.py | 704 ++++ server/src/hugin_app/settings.py | 37 + server/src/hugin_app/static/css/hugin.css | 220 ++ server/src/hugin_app/static/css/main.min.css | 1 + server/src/hugin_app/static/hugin-install.ps1 | 487 +++ .../src/hugin_app/static/js/framework.min.js | 133 + server/src/hugin_app/static/js/htmx.min.js | 1 + server/src/hugin_app/static/js/hugin.js | 112 + server/src/hugin_app/static/js/theme.min.js | 19 + server/src/hugin_app/store/__init__.py | 1 + server/src/hugin_app/store/db.py | 33 + server/src/hugin_app/store/models.py | 254 ++ server/src/hugin_app/store/packages.py | 111 + .../src/hugin_app/templates/_audit_list.html | 25 + .../hugin_app/templates/_catalog_list.html | 39 + .../src/hugin_app/templates/_pagination.html | 10 + server/src/hugin_app/templates/audit.html | 21 + server/src/hugin_app/templates/base.html | 82 + server/src/hugin_app/templates/catalog.html | 19 + server/src/hugin_app/templates/download.html | 34 + server/src/hugin_app/templates/editor.html | 50 + server/src/hugin_app/templates/error.html | 15 + server/src/hugin_app/templates/help.html | 45 + server/src/hugin_app/templates/login.html | 20 + server/src/hugin_app/templates/manage.html | 236 ++ server/src/hugin_app/templates/new.html | 24 + server/src/hugin_app/templates/overview.html | 55 + server/src/hugin_app/templates/package.html | 136 + server/src/hugin_app/templates/project.html | 39 + server/src/hugin_app/templates/reviews.html | 32 + server/src/hugin_app/templates/system.html | 70 + server/src/hugin_app/templates/upload.html | 31 + server/src/hugin_app/templates/version.html | 189 + server/src/hugin_app/web/__init__.py | 665 ++++ server/src/hugin_core/__init__.py | 5 + server/src/hugin_core/bundle/__init__.py | 191 + server/src/hugin_core/linter/__init__.py | 6 + .../src/hugin_core/linter/classification.py | 38 + server/src/hugin_core/linter/rules.py | 495 +++ server/src/hugin_core/min_version.py | 54 + server/src/hugin_core/package/__init__.py | 18 + server/src/hugin_core/package/folder.py | 172 + server/src/hugin_core/package/manifest.py | 83 + server/src/hugin_core/resolution/__init__.py | 107 + server/src/hugin_core/scaffolds.py | 79 + server/src/hugin_core/translators/__init__.py | 52 + server/src/hugin_core/translators/claude.py | 103 + server/src/hugin_core/translators/common.py | 83 + server/src/hugin_core/translators/opencode.py | 111 + server/src/hugin_core/translators/result.py | 84 + server/tests/conftest.py | 49 + server/tests/test_app.py | 527 +++ server/tests/test_bundle.py | 93 + server/tests/test_docs.py | 82 + server/tests/test_download.py | 46 + server/tests/test_export.py | 59 + server/tests/test_linter.py | 248 ++ server/tests/test_mcp.py | 116 + server/tests/test_migration.py | 46 + server/tests/test_package.py | 46 + server/tests/test_rate_limit.py | 57 + server/tests/test_resolution.py | 51 + server/tests/test_security.py | 143 + server/tests/test_stage4.py | 210 + server/tests/test_translators.py | 149 + server/tests/test_web.py | 500 +++ server/uv.lock | 1231 ++++++ static/css-framework/.gitignore | 3 + .../.opencode/commands/add-token.md | 45 + .../.opencode/commands/create-component.md | 65 + .../.opencode/commands/sync-docs.md | 57 + .../css-framework/.opencode/skills/SKILL.md | 406 ++ .../.opencode/skills/references/components.md | 565 +++ .../.opencode/skills/references/jinja2.md | 417 ++ .../.opencode/skills/references/tokens.md | 234 ++ static/css-framework/AGENTS.md | 46 + static/css-framework/CHANGELOG.md | 71 + static/css-framework/README.md | 1134 ++++++ static/css-framework/REPO_MAP.md | 124 + static/css-framework/base.css | 545 +++ static/css-framework/build.py | 79 + static/css-framework/components.css | 3391 +++++++++++++++++ static/css-framework/demo.css | 518 +++ static/css-framework/demo.js | 36 + static/css-framework/dist/demo.css | 518 +++ static/css-framework/dist/demo.js | 36 + static/css-framework/dist/framework.min.js | 133 + static/css-framework/dist/index.html | 2215 +++++++++++ static/css-framework/dist/main.min.css | 1 + static/css-framework/dist/theme.min.js | 19 + static/css-framework/docs/VERGLEICH.md | 168 + static/css-framework/fonts.css | 83 + static/css-framework/framework.js | 192 + static/css-framework/index.html | 2218 +++++++++++ static/css-framework/main.css | 21 + static/css-framework/theme.js | 39 + static/css-framework/tokens.css | 237 ++ static/css-framework/utilities.css | 523 +++ static/images/hugin_logo.svg | 1 + static/images/hugin_logo_dark.svg | 1 + static/images/hugin_logo_lean.svg | 1 + static/images/hugin_logo_transparent.svg | 1 + 257 files changed, 39080 insertions(+) create mode 100644 .gitignore create mode 100644 BETRIEB.md create mode 100644 COMPATIBILITY.md create mode 100644 KONZEPT.md create mode 100644 README.md create mode 100644 STUFE0.md create mode 100644 TESTEN.md create mode 100644 cli/cmd/hugin/author.go create mode 100644 cli/cmd/hugin/import.go create mode 100644 cli/cmd/hugin/init.go create mode 100644 cli/cmd/hugin/main.go create mode 100644 cli/cmd/hugin/mcp.go create mode 100644 cli/cmd/hugin/status.go create mode 100644 cli/cmd/hugin/sync.go create mode 100644 cli/cmd/hugin/sync_test.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/internal/api/api.go create mode 100644 cli/internal/api/secure_test.go create mode 100644 cli/internal/bundle/bundle.go create mode 100644 cli/internal/bundle/bundle_test.go create mode 100644 cli/internal/config/config.go create mode 100644 cli/internal/config/config_test.go create mode 100644 cli/internal/importer/importer.go create mode 100644 cli/internal/importer/importer_test.go create mode 100644 cli/internal/keystore/keystore.go create mode 100644 cli/internal/keystore/keystore_test.go create mode 100644 cli/internal/localmcp/exec_unix.go create mode 100644 cli/internal/localmcp/exec_windows.go create mode 100644 cli/internal/localmcp/localmcp.go create mode 100644 cli/internal/localmcp/localmcp_test.go create mode 100644 cli/internal/lock/lock.go create mode 100644 cli/internal/lock/lock_test.go create mode 100644 cli/internal/paths/paths.go create mode 100644 cli/internal/paths/paths_test.go create mode 100644 cli/internal/pkgdir/pkgdir.go create mode 100644 cli/internal/pkgdir/pkgdir_test.go create mode 100644 cli/internal/reconcile/block.go create mode 100644 cli/internal/reconcile/json.go create mode 100644 cli/internal/reconcile/json_test.go create mode 100644 cli/internal/reconcile/reconcile.go create mode 100644 cli/internal/reconcile/reconcile_test.go create mode 100644 cli/internal/repo/repo.go create mode 100644 cli/internal/repo/repo_test.go create mode 100644 cli/internal/tools/opencode_test.go create mode 100644 cli/internal/tools/tools.go create mode 100644 cli/internal/tools/tools_test.go create mode 100644 contract/bundle-request.schema.json create mode 100644 contract/examples/bundles/claude-only/files/.claude/skills/pub-munin-nutzen/SKILL.md create mode 100644 contract/examples/bundles/claude-only/files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md create mode 100644 contract/examples/bundles/claude-only/files/.hugin/rules/pub-hausregeln.md create mode 100644 contract/examples/bundles/claude-only/hugin-bundle.json create mode 100644 contract/examples/bundles/commands-and-agents/files/.claude/agents/pub-code-review.md create mode 100644 contract/examples/bundles/commands-and-agents/files/.claude/commands/pub-release-notiz.md create mode 100644 contract/examples/bundles/commands-and-agents/files/.opencode/agents/pub-code-review.md create mode 100644 contract/examples/bundles/commands-and-agents/files/.opencode/commands/pub-release-notiz.md create mode 100644 contract/examples/bundles/commands-and-agents/hugin-bundle.json create mode 100644 contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/SKILL.md create mode 100644 contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md create mode 100644 contract/examples/bundles/first-install/files/.hugin/rules/pub-hausregeln.md create mode 100644 contract/examples/bundles/first-install/hugin-bundle.json create mode 100644 contract/examples/bundles/local-mcp/hugin-bundle.json create mode 100644 contract/examples/bundles/local-mcp/local/pub-zeit-lokal/1.0.0/zeit_server.py create mode 100644 contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin.ts create mode 100644 contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/index.ts create mode 100644 contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/lib/marke.ts create mode 100644 contract/examples/bundles/opencode-plugin/hugin-bundle.json create mode 100644 contract/examples/bundles/update/files/.claude/skills/pub-munin-nutzen/SKILL.md create mode 100644 contract/examples/bundles/update/hugin-bundle.json create mode 100644 contract/examples/packages/pub-code-review/1.0.0/AGENT.md create mode 100644 contract/examples/packages/pub-code-review/1.0.0/hugin.yaml create mode 100644 contract/examples/packages/pub-hausregeln/1.0.0/RULES.md create mode 100644 contract/examples/packages/pub-hausregeln/1.0.0/hugin.yaml create mode 100644 contract/examples/packages/pub-munin-anbindung/1.0.0/hugin.yaml create mode 100644 contract/examples/packages/pub-munin-nutzen/1.4.0/SKILL.md create mode 100644 contract/examples/packages/pub-munin-nutzen/1.4.0/hugin.yaml create mode 100644 contract/examples/packages/pub-munin-nutzen/1.4.0/referenz/beispiele.md create mode 100644 contract/examples/packages/pub-munin-nutzen/1.5.0/SKILL.md create mode 100644 contract/examples/packages/pub-munin-nutzen/1.5.0/hugin.yaml create mode 100644 contract/examples/packages/pub-release-notiz/1.0.0/COMMAND.md create mode 100644 contract/examples/packages/pub-release-notiz/1.0.0/hugin.yaml create mode 100644 contract/examples/packages/pub-specht-plugin/1.0.0/hugin.yaml create mode 100644 contract/examples/packages/pub-specht-plugin/1.0.0/index.ts create mode 100644 contract/examples/packages/pub-specht-plugin/1.0.0/lib/marke.ts create mode 100644 contract/examples/packages/pub-zeit-lokal/1.0.0/hugin.yaml create mode 100644 contract/examples/packages/pub-zeit-lokal/1.0.0/zeit_server.py create mode 100644 contract/examples/scenarios.yaml create mode 100644 contract/hugin-bundle.schema.json create mode 100644 docker-compose.yml create mode 100755 scripts/build-cli.sh create mode 100755 scripts/csp-test.sh create mode 100644 scripts/csp/actions.js create mode 100644 scripts/csp/server.py create mode 100644 scripts/csp/web_actions.py create mode 100644 scripts/csp/web_browser.py create mode 100755 scripts/demo.sh create mode 100644 scripts/integration-test.ps1 create mode 100755 scripts/integration-test.sh create mode 100755 scripts/postgres-test.sh create mode 100755 scripts/smoke-test-local.sh create mode 100755 scripts/stage0.sh create mode 100755 scripts/sync-framework.sh create mode 100755 scripts/tool-smoke-test.sh create mode 100644 scripts/tool-smoke/packages/pub-smoke-agent/1.0.0/AGENT.md create mode 100644 scripts/tool-smoke/packages/pub-smoke-agent/1.0.0/hugin.yaml create mode 100644 scripts/tool-smoke/packages/pub-smoke-command/1.0.0/COMMAND.md create mode 100644 scripts/tool-smoke/packages/pub-smoke-command/1.0.0/hugin.yaml create mode 100644 scripts/tool-smoke/packages/pub-smoke-manual/1.0.0/SKILL.md create mode 100644 scripts/tool-smoke/packages/pub-smoke-manual/1.0.0/hugin.yaml create mode 100644 scripts/tool-smoke/packages/pub-smoke-rules/1.0.0/RULES.md create mode 100644 scripts/tool-smoke/packages/pub-smoke-rules/1.0.0/hugin.yaml create mode 100644 scripts/tool-smoke/packages/pub-smoke-skill/1.0.0/SKILL.md create mode 100644 scripts/tool-smoke/packages/pub-smoke-skill/1.0.0/hugin.yaml create mode 100644 server/.dockerignore create mode 100644 server/Dockerfile create mode 100644 server/pyproject.toml create mode 100644 server/src/hugin_app/__init__.py create mode 100644 server/src/hugin_app/api/__init__.py create mode 100644 server/src/hugin_app/api/admin.py create mode 100644 server/src/hugin_app/api/delivery.py create mode 100644 server/src/hugin_app/api/v1.py create mode 100644 server/src/hugin_app/app.py create mode 100644 server/src/hugin_app/audit.py create mode 100644 server/src/hugin_app/auth/__init__.py create mode 100644 server/src/hugin_app/auth/access.py create mode 100644 server/src/hugin_app/auth/keys.py create mode 100644 server/src/hugin_app/command.py create mode 100644 server/src/hugin_app/docs/__init__.py create mode 100644 server/src/hugin_app/docs/admins.md create mode 100644 server/src/hugin_app/docs/agents.md create mode 100644 server/src/hugin_app/docs/api.md create mode 100644 server/src/hugin_app/docs/authors.md create mode 100644 server/src/hugin_app/docs/cli.md create mode 100644 server/src/hugin_app/docs/glossary.md create mode 100644 server/src/hugin_app/docs/import.md create mode 100644 server/src/hugin_app/docs/integration.md create mode 100644 server/src/hugin_app/docs/kinds.md create mode 100644 server/src/hugin_app/docs/linter.md create mode 100644 server/src/hugin_app/docs/package-format.md create mode 100644 server/src/hugin_app/docs/reviewers.md create mode 100644 server/src/hugin_app/docs/roles.md create mode 100644 server/src/hugin_app/docs/start.md create mode 100644 server/src/hugin_app/docs/teams.md create mode 100644 server/src/hugin_app/docs/troubleshooting.md create mode 100644 server/src/hugin_app/download.py create mode 100644 server/src/hugin_app/export.py create mode 100644 server/src/hugin_app/hugin.py create mode 100644 server/src/hugin_app/management.py create mode 100644 server/src/hugin_app/mcp.py create mode 100644 server/src/hugin_app/migration.py create mode 100644 server/src/hugin_app/migrations/env.py create mode 100644 server/src/hugin_app/migrations/script.py.mako create mode 100644 server/src/hugin_app/migrations/versions/0001_initial_tables.py create mode 100644 server/src/hugin_app/platform.py create mode 100644 server/src/hugin_app/rate_limit.py create mode 100644 server/src/hugin_app/services.py create mode 100644 server/src/hugin_app/settings.py create mode 100644 server/src/hugin_app/static/css/hugin.css create mode 100644 server/src/hugin_app/static/css/main.min.css create mode 100644 server/src/hugin_app/static/hugin-install.ps1 create mode 100644 server/src/hugin_app/static/js/framework.min.js create mode 100644 server/src/hugin_app/static/js/htmx.min.js create mode 100644 server/src/hugin_app/static/js/hugin.js create mode 100644 server/src/hugin_app/static/js/theme.min.js create mode 100644 server/src/hugin_app/store/__init__.py create mode 100644 server/src/hugin_app/store/db.py create mode 100644 server/src/hugin_app/store/models.py create mode 100644 server/src/hugin_app/store/packages.py create mode 100644 server/src/hugin_app/templates/_audit_list.html create mode 100644 server/src/hugin_app/templates/_catalog_list.html create mode 100644 server/src/hugin_app/templates/_pagination.html create mode 100644 server/src/hugin_app/templates/audit.html create mode 100644 server/src/hugin_app/templates/base.html create mode 100644 server/src/hugin_app/templates/catalog.html create mode 100644 server/src/hugin_app/templates/download.html create mode 100644 server/src/hugin_app/templates/editor.html create mode 100644 server/src/hugin_app/templates/error.html create mode 100644 server/src/hugin_app/templates/help.html create mode 100644 server/src/hugin_app/templates/login.html create mode 100644 server/src/hugin_app/templates/manage.html create mode 100644 server/src/hugin_app/templates/new.html create mode 100644 server/src/hugin_app/templates/overview.html create mode 100644 server/src/hugin_app/templates/package.html create mode 100644 server/src/hugin_app/templates/project.html create mode 100644 server/src/hugin_app/templates/reviews.html create mode 100644 server/src/hugin_app/templates/system.html create mode 100644 server/src/hugin_app/templates/upload.html create mode 100644 server/src/hugin_app/templates/version.html create mode 100644 server/src/hugin_app/web/__init__.py create mode 100644 server/src/hugin_core/__init__.py create mode 100644 server/src/hugin_core/bundle/__init__.py create mode 100644 server/src/hugin_core/linter/__init__.py create mode 100644 server/src/hugin_core/linter/classification.py create mode 100644 server/src/hugin_core/linter/rules.py create mode 100644 server/src/hugin_core/min_version.py create mode 100644 server/src/hugin_core/package/__init__.py create mode 100644 server/src/hugin_core/package/folder.py create mode 100644 server/src/hugin_core/package/manifest.py create mode 100644 server/src/hugin_core/resolution/__init__.py create mode 100644 server/src/hugin_core/scaffolds.py create mode 100644 server/src/hugin_core/translators/__init__.py create mode 100644 server/src/hugin_core/translators/claude.py create mode 100644 server/src/hugin_core/translators/common.py create mode 100644 server/src/hugin_core/translators/opencode.py create mode 100644 server/src/hugin_core/translators/result.py create mode 100644 server/tests/conftest.py create mode 100644 server/tests/test_app.py create mode 100644 server/tests/test_bundle.py create mode 100644 server/tests/test_docs.py create mode 100644 server/tests/test_download.py create mode 100644 server/tests/test_export.py create mode 100644 server/tests/test_linter.py create mode 100644 server/tests/test_mcp.py create mode 100644 server/tests/test_migration.py create mode 100644 server/tests/test_package.py create mode 100644 server/tests/test_rate_limit.py create mode 100644 server/tests/test_resolution.py create mode 100644 server/tests/test_security.py create mode 100644 server/tests/test_stage4.py create mode 100644 server/tests/test_translators.py create mode 100644 server/tests/test_web.py create mode 100644 server/uv.lock create mode 100644 static/css-framework/.gitignore create mode 100644 static/css-framework/.opencode/commands/add-token.md create mode 100644 static/css-framework/.opencode/commands/create-component.md create mode 100644 static/css-framework/.opencode/commands/sync-docs.md create mode 100644 static/css-framework/.opencode/skills/SKILL.md create mode 100644 static/css-framework/.opencode/skills/references/components.md create mode 100644 static/css-framework/.opencode/skills/references/jinja2.md create mode 100644 static/css-framework/.opencode/skills/references/tokens.md create mode 100644 static/css-framework/AGENTS.md create mode 100644 static/css-framework/CHANGELOG.md create mode 100644 static/css-framework/README.md create mode 100644 static/css-framework/REPO_MAP.md create mode 100644 static/css-framework/base.css create mode 100644 static/css-framework/build.py create mode 100644 static/css-framework/components.css create mode 100644 static/css-framework/demo.css create mode 100644 static/css-framework/demo.js create mode 100644 static/css-framework/dist/demo.css create mode 100644 static/css-framework/dist/demo.js create mode 100644 static/css-framework/dist/framework.min.js create mode 100644 static/css-framework/dist/index.html create mode 100644 static/css-framework/dist/main.min.css create mode 100644 static/css-framework/dist/theme.min.js create mode 100644 static/css-framework/docs/VERGLEICH.md create mode 100644 static/css-framework/fonts.css create mode 100644 static/css-framework/framework.js create mode 100644 static/css-framework/index.html create mode 100644 static/css-framework/main.css create mode 100644 static/css-framework/theme.js create mode 100644 static/css-framework/tokens.css create mode 100644 static/css-framework/utilities.css create mode 100644 static/images/hugin_logo.svg create mode 100644 static/images/hugin_logo_dark.svg create mode 100644 static/images/hugin_logo_lean.svg create mode 100644 static/images/hugin_logo_transparent.svg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1a27ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Hugin +# +# Nicht ignorieren: .claude/, .opencode/ und .hugin/ in contract/examples/bundles/*/files/ +# (goldene Bundles) und in static/css-framework/ gehören ins Repo. COMPATIBILITY.md +# ebenfalls (Ergebnis von scripts/tool-smoke-test.sh). + +# Betriebssystem und Editoren +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*~ + +# Lokale Einstellungen und Geheimnisse +.env +.env.* +.claude/settings.local.json +/.claude/ +/.opencode/ +/CLAUDE.local.md + +# Python (server/) +__pycache__/ +*.py[cod] +.venv/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ + +# Node; OpenCode legt .opencode/node_modules/ selbst an +node_modules/ + +# Go (cli/); dist/ kommt von scripts/build-cli.sh und go build -o dist/… +/cli/dist/ +*.test +*.out + +# Lokale Datenbanken und Logs (z. B. HUGIN_DATABASE_URL=sqlite:///hugin.db) +*.db +*.db-shm +*.db-wal +*.db-journal +*.log + +# Demo-Daten mit Keys (scripts/demo.sh) +.demo/ + +# Bildschirmfotos der CSP-Prüfung (scripts/csp/web_browser.py ) +/screenshots/ diff --git a/BETRIEB.md b/BETRIEB.md new file mode 100644 index 0000000..b0dba3c --- /dev/null +++ b/BETRIEB.md @@ -0,0 +1,95 @@ +# Hugin betreiben + +Für den Betrieb: Container-Image, Datenbank, Sicherung. Deployment und Ingress liegen im +Template des Betriebs. Hintergrund in [KONZEPT.md](KONZEPT.md) Abschnitt 18. + +## Konfiguration + +Nur über Umgebungsvariablen: + +| Umgebungsvariable | Bedeutung | +|---|---| +| `HUGIN_DATABASE_URL` | SQLite oder PostgreSQL | +| `HUGIN_ADMIN_TOKEN` | Plattform-Admin | +| `HUGIN_BASE_URL` | steht im Kopf verwalteter Dateien | +| `HUGIN_DOWNLOAD_DIR` | gebaute CLI-Programme für die Seite „Programm hugin“ | +| `HUGIN_CLI_MIN_VERSION` | ältere CLIs meldet `hugin check` | +| `HUGIN_RATE_LIMIT` | Anfragen je Key und Minute (Standard 600) | +| `HUGIN_FAILED_ATTEMPTS` | falsche Keys je IP in 5 Minuten, danach 429 (Standard 20) | +| `HUGIN_PROXY_IPS` | Router, deren `X-Forwarded-For` gilt (Standard `127.0.0.1`) | +| `HUGIN_MAX_UPLOAD_MB` | größter Request-Body (Standard 40) | + +**Hinter dem OpenShift-Router** `HUGIN_PROXY_IPS` auf das Netz des Routers +setzen (z. B. `10.128.0.0/14`). Sonst sehen Sperre und Audit nur die Adresse des +Routers, und wenige falsche Keys sperren alle Nutzer für fünf Minuten. + +## Container-Image + +Im Hugin-Repo liegt `server/Dockerfile`: Red Hat UBI 9 minimal mit Python 3.12, die +Abhängigkeiten fest aus `uv.lock`. Das Image läuft als beliebiger Benutzer der Gruppe 0, +wie OpenShift es vorgibt, und auch mit schreibgeschütztem Dateisystem. + +```sh +docker build -t hugin server/ +docker build --build-arg UBI=/ubi9/ubi-minimal:9.7 -t hugin server/ # eigener Spiegel +``` + +| Was | Wie | +|---|---| +| Server starten | ohne Argument (Standard `run`), Port 8080 | +| Migrationen | dasselbe Image mit Argument `migrate`, als Job vor jedem Rollout | +| Liveness-Probe | `GET /healthz` | +| Readiness-Probe | `GET /readyz`: Datenbank erreichbar und Migrationen aktuell; ohne `migrate` wird der Pod nicht bereit | +| Konfiguration | Umgebungsvariablen (oben); `HUGIN_ADMIN_TOKEN` als Secret | +| Logs | stdout | + +## Datenbank: SQLite oder PostgreSQL + +Beides funktioniert gleich; der Wechsel ist nur `HUGIN_DATABASE_URL`. + +| | SQLite | PostgreSQL | +|---|---|---| +| `HUGIN_DATABASE_URL` | nicht setzen: das Image nutzt `/data/hugin.db` | `postgresql+psycopg://user:pw@host:5432/hugin` | +| Volume | PVC auf `/data`, auch im Migrations-Job | keins | +| Pods | genau einer, Strategie `Recreate` | beliebig viele | +| Beim Ausrollen | kurz nicht erreichbar | ohne Unterbrechung | +| Sicherung | selbst: z. B. CronJob mit `sqlite3 /data/hugin.db ".backup /sicherung/hugin.db"`; die Datei nicht einfach kopieren, solange Hugin schreibt | übernimmt der Datenbankbetrieb | +| Gut für | Pilot, wenige Teams | breiter Einsatz, Hochverfügbarkeit | + +Fällt Hugin aus, laufen alle Agenten weiter, denn die Dateien liegen in den Repos; nur +`hugin sync` geht dann nicht. Kurze Unterbrechungen beim Ausrollen sind deshalb +unkritisch. + +Von SQLite nach PostgreSQL umziehen: Dafür gibt es noch kein Werkzeug. Der Export +enthält nur freigegebene Pakete, keine Keys, Personen oder Audit-Einträge. + +Mehrere Pods: Das Rate-Limit zählt je Pod; mit drei Pods gelten also bis zu dreimal so +viele Anfragen und Fehlversuche. + +## Export und CLI-Downloads + +- **Export:** `hugin-server export --commit` schreibt alle freigegebenen Pakete + im Quellformat in einen Git-Ordner, z. B. als täglicher CronJob. Ergibt eine lesbare + Historie und eine Notfall-Verteilung. Dasselbe als ZIP auf der Systemseite. + `--commit` braucht `git`, das im Image fehlt; für den Betrieb ist es nicht nötig, die + Datenbank-Sicherung enthält alles. +- **CLI-Downloads:** `scripts/build-cli.sh ` baut das Programm `hugin` + für macOS, Linux und Windows mit `SHA256SUMS`. Den Ordner einhängen und + `HUGIN_DOWNLOAD_DIR` darauf setzen; ohne ihn zeigt die Seite „Programm hugin“ + „Noch keine Programme hinterlegt“. + +## Clients mit lokalen MCP-Servern + +Pakete mit lokalem MCP-Server (Python) installiert `hugin sync` auf jedem Rechner. Der +Server braucht dafür nichts Zusätzliches (Upload bis `HUGIN_MAX_UPLOAD_MB`, Pakete bis +32 MB). Auf den Rechnern (Windows, Linux) muss bereitstehen: + +| Was | Wozu | +|---|---| +| `hugin` im `PATH` | Werkzeuge starten den Server über `hugin mcp run`; ein Alias reicht nicht | +| Python 3 (Windows: mit dem Starter `py`) | venv je Server; abweichend mit `HUGIN_PYTHON` | +| `pip.conf` (Linux `~/.config/pip/pip.conf` oder `/etc/pip.conf`, Windows `%APPDATA%\pip\pip.ini`) mit dem Artifactory als `index-url` | pip lädt die Abhängigkeiten daraus, ohne Anmeldung | +| Schreibrecht auf `~/.local/share/mcp` (Windows `%USERPROFILE%\.local\share\mcp`) | Ablage; abweichend mit `HUGIN_MCP_HOME` | + +Speicherplatz: je Server und Version eine venv (mit dem MCP-SDK etwa 50 MB). Alte +Versionen löscht `hugin mcp prune`. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 0000000..d234e58 --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,37 @@ +# Kompatibilität + +Erzeugt von `scripts/tool-smoke-test.sh` am 25.10.2026 (→ KONZEPT.md 17.2). +Modelle: Claude Code `haiku`, OpenCode `Standard`. Installiert mit `hugin sync`. + +## Rauchtests + +| Paket | Prüfung | Claude Code 2.1.307 | OpenCode 1.19.4 | +|---|---|---|---| +| `pub-smoke-skill` | Skill wird gefunden | ✓ | ✓ | +| `pub-smoke-skill` | Skill wird geladen | ✓ | ✓ | +| `pub-smoke-rules` | Regeln wirken | ✓ | ✓ | +| `pub-smoke-command` | Befehl mit zwei Argumenten | ✓ | ✓ | +| `pub-smoke-agent` | Subagent vorhanden | ✓ | ✓ | +| `pub-smoke-agent` | Subagent wirkt | ✓ | ✓ | +| `pub-smoke-mcp` | MCP eingetragen | ✓ | ✓ | +| `pub-smoke-mcp` | MCP verbunden | ✓ | ✓ | +| `pub-zeit-lokal` | Lokaler Server eingetragen | ✓ | – | +| `pub-zeit-lokal` | Lokaler Server verbunden | – | ✓ | +| `pub-zeit-lokal` | Lokaler Server antwortet | ✓ | ✓ | +| `pub-specht-plugin` | Plugin geladen | – | ✓ | +| `pub-specht-plugin` | Plugin wirkt | – | ✓ | +| `pub-smoke-command` | Installieren auf Bitte | ✓ | ✓ | +| `pub-smoke-command` | Ohne hugin nichts schreiben | ✓ | ✓ | + +## Stufe 0 + +| Frage | Aufbau | erkannte Wörter | Folgerung | +|---|---|---|---| +| 4 | OpenCode, nur `CLAUDE.md` mit `@`-Verweis, keine `AGENTS.md`, keine `instructions` | DACHS-2207 | liest `CLAUDE.md`, löst `@` nicht auf: keine Doppel | +| 5 | Claude Code, `CLAUDE.md` und `AGENTS.md` | LUCHS-3318 | liest nur `CLAUDE.md` | +| autoinvoke | Claude Code 2.1.307, Skill mit `disable-model-invocation: true` | selbst: –, per `/`: IGEL-8140 | wirkt: Skill nur per `/name` | +| autoinvoke | OpenCode 1.19.4, Skill mit `metadata: opencode/autoinvoke: "false"` | selbst: IGEL-8140, per `/`: IGEL-8140 | wirkt **nicht**: das Modell lädt den Skill selbst | +| 8 | Claude Code 2.1.307, Befehl mit `arguments` und `$name` | – | benannte Argumente wirken | + +✓ bestanden · ✗ fehlgeschlagen · – nicht geprüft (kein Modellzugang). +Modellantworten schwanken; ein einzelnes ✗ mit `KEEP=1` wiederholen und die Logs ansehen. diff --git a/KONZEPT.md b/KONZEPT.md new file mode 100644 index 0000000..c980a50 --- /dev/null +++ b/KONZEPT.md @@ -0,0 +1,1909 @@ +# Hugin — Konzept für die Entwicklung + +> Hugin („Gedanke“) und Munin („Gedächtnis“) sind die beiden Raben Odins. +> Munin speichert, **was** Agenten wissen. Hugin legt fest, **wie** sie arbeiten. + +Stand: 19.10.2026 · Status: Stufen 0–4 umgesetzt, Stufe 5 offen (→ 20) + +--- + +## Inhalt + +1. [Kurzfassung](#1-kurzfassung) +2. [Ausgangslage und Problem](#2-ausgangslage-und-problem) +3. [Ziele und Nicht-Ziele](#3-ziele-und-nicht-ziele) +4. [Begriffe](#4-begriffe) · [Namen im Code](#41-namen-im-code) +5. [Zielwerkzeuge: Claude Code und OpenCode](#5-zielwerkzeuge-claude-code-und-opencode) +6. [Das Quellformat](#6-das-quellformat) +7. [Die Übersetzer](#7-die-übersetzer) +8. [Prüfung (Linter)](#8-prüfung-linter) +9. [Lebenszyklus und Freigabe](#9-lebenszyklus-und-freigabe) +10. [Geltung, Abos, Pflichtpakete, Namen](#10-geltung-abos-pflichtpakete-namen) +11. [Verteilung: das Programm `hugin`](#11-verteilung-das-programm-hugin) +12. [MCP-Server](#12-mcp-server) +13. [Weboberfläche](#13-weboberfläche) +14. [Architektur](#14-architektur) +15. [Datenmodell](#15-datenmodell) +16. [Sicherheit](#16-sicherheit) +17. [Kompatibilitätstests](#17-kompatibilitätstests) +18. [Betrieb](#18-betrieb) +19. [Zusammenspiel mit Munin](#19-zusammenspiel-mit-munin) +20. [Ausbaustufen](#20-ausbaustufen) +21. [Entscheidungen und offene Fragen](#21-entscheidungen-und-offene-fragen) +22. [Quellen und was neu zu prüfen ist](#22-quellen-und-was-neu-zu-prüfen-ist) + +--- + +## 1. Kurzfassung + +Hugin ist ein zentrales Register für alles, was das Verhalten von KI-Agenten +steuert: **Skills, Hausregeln, Befehle, Subagenten und MCP-Anbindungen**. + +- Autoren legen **Pakete** an. Jede Änderung ist eine neue, unveränderliche **Version**. +- Eine zweite Person **prüft und gibt frei**, bevor eine Version bei den Teams ankommt. +- Ein Paket wird **einmal in einem neutralen Format** geschrieben. Hugin + **übersetzt** es für **Claude Code** und für **OpenCode**. Beide Werkzeuge werden + voll unterstützt, andere nicht. +- Die Teams holen die Pakete mit dem Kommandozeilenprogramm **`hugin sync`** + in ihre Repositories. Welche Pakete in welcher Version, steht in einer Datei im Repo. +- Server in Python (FastAPI, Jinja2, HTMX, hauseigenes CSS-Framework), CLI in Go. + Konzepte wie Munin: SQLite oder PostgreSQL, Tenants, Projekte und Keys, + Audit, Hilfe-System, Markdown-Export, MCP-Endpunkt. Später in ein anderes + FastAPI-Projekt einbettbar (→ 14.3). + +--- + +## 2. Ausgangslage und Problem + +Teams arbeiten mit Claude Code und OpenCode. Jedes Team schreibt eigene +Anweisungen (`CLAUDE.md`, `AGENTS.md`), eigene Skills und eigene Subagenten und +kopiert sie von Repository zu Repository. Daraus folgt: + +| Problem | Folge | +|---|---| +| **Wildwuchs** | Fünf Fassungen derselben Anweisung „So nutzt du Munin“; niemand weiß, welche aktuell ist. | +| **Keine Prüfung** | Ein Skill kann Agenten anweisen, Befehle auszuführen oder Daten zu verschicken. Er ist faktisch Code, wird aber nie reviewt. | +| **Keine Verbesserung von oben** | Findet ein Team eine bessere Anweisung, erreicht sie die anderen nicht. | +| **Kein Überblick** | Niemand weiß, welche Skills es gibt, wer sie nutzt und in welcher Fassung. | +| **Zwei Werkzeuge** | Was für Claude Code geschrieben ist, funktioniert in OpenCode oft nur halb und umgekehrt. Teams pflegen doppelt oder gar nicht. | + +--- + +## 3. Ziele und Nicht-Ziele + +### Ziele + +1. **Ein Ort** für Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen, mit Suche. +2. **Geprüft:** Nichts erreicht die Teams ohne Freigabe durch eine zweite Person. +3. **Versioniert und nachvollziehbar:** Jede Version ist unveränderlich, mit Prüfsumme, + Diff zur Vorgängerversion und Audit, wer was wann freigegeben hat. +4. **Claude Code und OpenCode gleichwertig:** Ein Paket, beide Werkzeuge. + Die Weboberfläche zeigt für jede Version, was in welchem Werkzeug ankommt. +5. **Einfach für die Teams:** Eine kleine Datei im Repo, ein Befehl `hugin sync`. + Eigene Dateien der Teams werden nie angefasst. +6. **Rückkanal:** Hugin weiß, welches Projekt welche Version nutzt, und Agenten + wie Menschen können Rückmeldung zu einem Paket geben. +7. **Betrieb wie Munin:** Wer Munin betreibt, kann Hugin betreiben. + +### Nicht-Ziele + +- **Andere Werkzeuge** (Codex, Cursor, Copilot …) werden nicht unterstützt. + Die Übersetzer-Architektur ließe es zu, es ist aber ausdrücklich nicht geplant. +- **Keine Ausführung:** Hugin führt keine Agenten aus und startet keine Skripte. +- **Kein Marktplatz für draußen:** Hugin ist nur intern. +- **Keine persönlichen Pakete:** Hugin schreibt nicht in Nutzerordner + (`~/.claude/`, `~/.config/opencode/`). Pakete gibt es nur öffentlich oder je + Tenant bzw. Projekt. +- **Kein Ersatz für Munin:** Wissen („Service X spricht nur IPv4“) gehört nach + Munin. Anweisungen („Prüfe vor jedem Deploy die Changelogs“) gehören nach Hugin. +- **Keine Bearbeitung von Hand in den Repos:** Was Hugin verwaltet, wird über Hugin + geändert. `hugin sync` überschreibt Änderungen von Hand an verwalteten Dateien, + nachdem es gewarnt hat. + +--- + +## 4. Begriffe + +| Begriff | Bedeutung | +|---|---| +| **Paket** | Eine verwaltete Einheit, z. B. der Skill `pub-munin-nutzen`. Hat einen Besitzer und eine Art. | +| **Art** | Was das Paket ist: `skill`, `rules`, `command`, `subagent`, `mcp`, `hook`. | +| **Version** | Ein unveränderlicher Stand eines Pakets mit Semver-Nummer (`1.4.0`) und Prüfsumme. | +| **Ziel** | Ein unterstütztes Werkzeug: `claude` (Claude Code) oder `opencode` (OpenCode). | +| **Übersetzer** | Erzeugt aus dem Quellformat die Dateien für ein Ziel. | +| **Kompatibilitätsstufe** | `portable`, `translated`, `claude-only`, `opencode-only` (→ Abschnitt 5.2). | +| **Risiko** | `text`, `tools`, `scripts`, `permissions` (→ Abschnitt 16). Bestimmt, wie streng die Prüfung ist. | +| **Tenant** | Abgeschlossener Bereich, z. B. eine Organisationseinheit. Sieht nur seine eigenen Pakete und die öffentlichen. Entspricht einem Workspace in Munin. | +| **Geltung** | Wer das Paket sieht und abonnieren darf: `public`, `tenant`, `project`. | +| **Kanal** | `stable` (Standard) oder `test` (für Teams, die Neues früh wollen). | +| **Abo** | Eintrag in `.hugin.yaml` eines Repos: dieses Paket, dieser Kanal oder Versionsbereich. | +| **Pflichtpaket** | Ein Paket, das jedes Projekt einer Geltung bekommt und nicht abbestellen kann. | +| **Verwaltete Datei** | Eine Datei, die `hugin sync` erzeugt hat und die es pflegt. Erkennbar am Kopfkommentar und an `hugin.lock`. | + +### 4.1 Namen im Code + +Das Konzept, die Hilfe und die Weboberfläche sind deutsch. **Code, Felder, Werte, +Befehle, API-Pfade und Datenbank sind englisch.** Die wichtigsten Zuordnungen: + +| Begriff | im Code | Begriff | im Code | +|---|---|---|---| +| Paket | `package` | Freigabe / freigeben | `approval` / `approve` | +| Art | `kind` | Prüfer, Prüfung | `reviewer`, `review` | +| Geltung (öffentlich, Tenant, Projekt) | `scope` (`public`, `tenant`, `project`) | Entwurf / einreichen | `draft` / `submit` | +| Besitzer | `owner` | zurückgeben | `return` | +| Beschreibung | `description` | veraltet / reaktivieren | `deprecated` / `reactivate` | +| Änderung | `changes` | Kanal stabil / test | `channel` `stable` / `test` | +| Ziele | `targets` | Risiko (Werkzeuge, Skripte, Rechte) | `risk` (`tools`, `scripts`, `permissions`) | +| Zusatz | `extra` | Kompatibilität (portabel, übersetzt) | `compatibility` (`portable`, `translated`) | +| Argumente | `arguments` | Pflichtpaket | `mandatory` | +| Fähigkeit (lesen, schreiben) | `capability` (`read`, `write`) | Abo / abbestellt | `subscription` / `unsubscribed` | +| Modell (schnell, gründlich, erben) | `model` (`fast`, `thorough`, `inherit`) | Auslieferung | `delivery` | +| Kopfzeilen | `headers` | Rückmeldung | `feedback` | +| Regeln, Befehl | `rules`, `command` | Untergrenze | `floor` | +| Werkzeug | `tool` | Befund | `finding` | +| Zugriff | `access` | Abgleich | `reconcile` | + +CLI: `hugin init`, `sync`, `check`, `status`, `login`, `new`, `validate`, `try`, +`upload`, `search`, `import`. + +--- + +## 5. Zielwerkzeuge: Claude Code und OpenCode + +### 5.1 Grundentscheidung: Wer ist der Standard? + +Die naheliegende Idee wäre: „OpenCode ist Standard, Claude Code liest das mit.“ +**Es ist aber umgekehrt.** **OpenCode** liest viele Claude-Formate mit +(`.claude/skills/`, `CLAUDE.md` als Ersatz für `AGENTS.md`, `~/.claude/skills/`). +**Claude Code** liest keine OpenCode-Ordner (`.opencode/…`). Würde Hugin nur +OpenCode-Dateien schreiben, sähe Claude Code die meisten davon nicht. + +Deshalb gilt in Hugin: + +| Frage | Entscheidung | +|---|---| +| **Woran richtet sich die Strenge?** | An der jeweils **strengeren** Regel; das ist meist OpenCode, z. B. bei Namen und Feldern. Was diese Regeln erfüllt, funktioniert in beiden. | +| **Wo liegen Skills?** | In `.claude/skills/`, weil **beide** Werkzeuge diesen Ort lesen. Eine Datei, kein Doppel. Nutzt ein Repo nur OpenCode, in `.opencode/skills/` (→ 7.1). | +| **Wo liegt der Rest?** | Je Werkzeug am eigenen Ort, erzeugt von den Übersetzern. | +| **Was ist mit Zusatzfunktionen von Claude Code?** | Erlaubt, aber nur als ausdrücklicher Zusatz für das Ziel `claude` (→ Abschnitt 6.3). Der Kern eines Pakets bleibt portabel. | + +Das schränkt kaum ein. Man verzichtet im Kern nur auf die Komfortfelder von Claude Code +(`allowed-tools`, `context: fork`, `model` im Skill …). Wer sie braucht, setzt sie im +Block `targets.claude`. Das Paket bleibt dann für OpenCode nutzbar, nur ohne diesen Komfort. + +### 5.2 Kompatibilitätsstufen + +| Stufe | Bedeutung | Beispiel | +|---|---|---| +| `portable` | Eine erzeugte Datei, beide Werkzeuge lesen sie. | Skill nur mit `name` und `description` | +| `translated` | Hugin erzeugt je Werkzeug eine eigene Fassung. | Subagent, Befehl mit Argumenten, MCP-Eintrag | +| `claude-only` | Nutzt etwas, das es in OpenCode nicht gibt. | Claude-Hook in `settings.json` | +| `opencode-only` | Nutzt etwas, das es in Claude Code nicht gibt. | OpenCode-Plugin in TypeScript | + +Hugin berechnet die Stufe automatisch (→ Abschnitt 8) und zeigt sie im Katalog. +Ziel: fast alles `portable` oder `translated`; `…-only` ist die begründete Ausnahme. + +### 5.3 Abbildung je Art + +#### Skills + +| | Claude Code | OpenCode | +|---|---|---| +| Ort im Projekt | `.claude/skills//SKILL.md` | `.opencode/skills/`, **liest auch** `.claude/skills/` und `.agents/skills/` | +| Ort für den Nutzer | `~/.claude/skills/` | `~/.config/opencode/skills/`, **liest auch** `~/.claude/skills/` | +| Suche im Projekt | auch verschachtelt `/.claude/skills/` | wandert vom Arbeitsordner nach oben bis zur Git-Wurzel | +| Pflichtfelder | nur `description` empfohlen, `name` wird sonst der Ordnername | `name` und `description` | +| Name | großzügig | `^[a-z0-9]+(-[a-z0-9]+)*$`, 1–64 Zeichen, gleich dem Ordnernamen | +| Beschreibung | bis 1.536 Zeichen (mit `when_to_use`) | 1–1.024 Zeichen | +| Zusatzfelder | viele (`allowed-tools`, `disable-model-invocation`, `user-invocable`, `context`, `agent`, `model`, `effort`, `paths`, `hooks`, `arguments`, `argument-hint`, `shell` …) | nur `license`, `compatibility`, `metadata`; Eigenes unter `metadata` (`opencode/slash`, `opencode/autoinvoke` wirken in OpenCode 1 nicht, → STUFE0.md) | +| Unbekannte Felder | werden still ignoriert | werden still ignoriert (Stufe 0) | +| Laden | automatisch nach Beschreibung oder per `/name`; nur von Hand mit `disable-model-invocation` | über das Werkzeug `skill`, nach Beschreibung, oder per `/name`; immer beides (Stufe 0) | +| Rechte | `allowed-tools` im Skill | `permission.skill` in `opencode.json` | + +**Für Hugin:** Skills sind die einfachste Art. Mit dem gemeinsamen Kern +(`name`, `description`, `license`, `compatibility`, `metadata`) sind sie `portable`. + +#### Regeln (Hausanweisungen) + +| | Claude Code | OpenCode | +|---|---|---| +| Hauptdatei | `CLAUDE.md` | `AGENTS.md`, sonst `CLAUDE.md` als Ersatz | +| Weitere Dateien einbinden | `@path/to/file.md` in der `CLAUDE.md` | `instructions` in `opencode.json`: Pfade, Globs, auch URLs | +| `AGENTS.md` | ab 2.1.277, aber nur wenn es keine `CLAUDE.md` gibt | nativ; OpenCode 2 liest **nur** noch `AGENTS.md`, nicht mehr `CLAUDE.md` | + +**Für Hugin:** Hugin schreibt Regeln in eigene Dateien unter `.hugin/rules/` +und bindet sie ein, ohne die Inhalte der Teams anzufassen: + +- in der `CLAUDE.md` über einen **verwalteten Block** mit `@`-Verweisen, +- in `opencode.json` über einen Eintrag `.hugin/rules/*.md` in `instructions`. + +Einzelheiten → Abschnitt 7.2. + +#### Befehle (Slash-Commands) + +| | Claude Code | OpenCode | +|---|---|---| +| Ort | `.claude/commands/.md` (weiter unterstützt) oder als Skill | `.opencode/commands/.md` | +| Felder | wie bei Skills, außer `name` und `paths` | `description`, `agent`, `subtask`, `model` | +| Alle Argumente | `$ARGUMENTS` | `$ARGUMENTS` | +| Einzelne Argumente | `$0`, `$1` … **ab 0**, oder benannt über `arguments` + `$name` | `$1`, `$2` … **ab 1** | +| Shell-Ausgabe einfügen | `` !`befehl` `` | `` !`befehl` `` | +| Datei einfügen | `@path` | `@path` | + +**Stolperfalle:** Derselbe Text mit `$1` meint in Claude Code das **zweite**, +in OpenCode das **erste** Argument. Hugin erlaubt im Quellformat deshalb nur +`$ARGUMENTS` oder **benannte Argumente**, die der Übersetzer umschreibt. + +#### Subagenten + +| | Claude Code | OpenCode | +|---|---|---| +| Ort | `.claude/agents/*.md` | `.opencode/agents/*.md` | +| Name | Feld `name` | Dateiname | +| Pflicht | `name`, `description` | `description` | +| Werkzeuge | `tools` (Erlaubtliste), `disallowedTools` | `permission`: `edit`, `bash`, `webfetch` … je `allow`, `ask`, `deny` (`tools` ist veraltet) | +| Modell | `sonnet`, `opus`, `haiku`, `fable`, volle ID oder `inherit` | `provider/model-id` | +| Art | immer Subagent | `primary`, `subagent`, `all` | +| Eigenes | `permissionMode`, `skills`, `mcpServers`, `memory`, `isolation`, `effort` … | `temperature`, `steps`, `hidden`, `color`, `top_p` … | + +**Für Hugin:** Subagenten sind immer `translated`. Das Quellformat beschreibt sie +neutral über **Fähigkeitsklassen** und **Modellklassen** (→ Abschnitt 7.4). + +#### MCP-Anbindungen + +| | Claude Code | OpenCode | +|---|---|---| +| Datei | `.mcp.json` | `opencode.json`, Schlüssel `mcp` | +| Entfernter Server | `"type": "http", "url": …, "headers": {…}` | `"type": "remote", "url": …, "headers": {…}` | +| Umgebungsvariablen | `${VAR}` | `{env:VAR}` | +| MCP-Werkzeuge | ja | ja | +| MCP-Prompts und Ressourcen | ja | in der Doku **nicht erwähnt** | + +**Für Hugin:** MCP-Pakete sind `translated`. Keys stehen nie im Paket, nur der Name +der Umgebungsvariablen. Hugin verlässt sich nur auf MCP-**Werkzeuge**. + +#### Hooks, Plugins, Rechte + +| | Claude Code | OpenCode | +|---|---|---| +| Mechanismus | Hooks als Shell-Befehle in `.claude/settings.json` | Plugins in JS/TS (`.opencode/plugins/` oder npm), Ereignisse wie `tool.execute.before` | +| Rechte | `permissions` in `.claude/settings.json` | `permission` in `opencode.json` | + +**Für Hugin:** grundverschieden, also nicht übersetzbar. Hooks und Plugins gibt es +nur als `claude-only`- bzw. `opencode-only`-Pakete. Zwei Pakete, die dasselbe tun, +lassen sich als **Paar** verknüpfen (→ Abschnitt 6.5). Rechte verwaltet Hugin in der +ersten Fassung gar nicht; sie bleiben Sache der Teams. + +### 5.4 Werkzeugversionen als Untergrenze + +Beide Werkzeuge entwickeln sich schnell. Viele Funktionen, auf die Hugin baut, gibt +es erst ab einer bestimmten Version. Beispiele: + +- dass OpenCode `.claude/skills/` mitliest, +- die `metadata`-Schlüssel `opencode/slash` und `opencode/autoinvoke`, +- benannte Argumente (`arguments`) in Claude Code. + +Hat ein Entwickler eine ältere Version, kommt der Skill zwar an, wird aber nicht +gefunden oder verhält sich anders. Das ist ein stiller Fehler, der schwer zu +finden ist. + +**Untergrenze** heißt: Hugin legt fest, welche Version **mindestens** installiert sein +muss, z. B. „Claude Code ≥ 2.x, OpenCode ≥ 1.y“. + +- Die Untergrenze steht in der Hugin-Konfiguration; der Plattform-Admin pflegt sie. +- `hugin sync` und `hugin check` lesen die installierten Versionen + (`claude --version`, `opencode --version`) und **warnen**, wenn eine zu alt ist. + Mit `tool_check: strict` in `.hugin.yaml` (oder `--strict`) bricht `sync` + stattdessen ab. +- Ein Paket kann eine **höhere** Untergrenze verlangen, wenn es eine neuere + Funktion nutzt (Feld `min_version` im Manifest). +- Der Linter kennt eine Tabelle „Feld oder Funktion → ab Version“ und setzt die + Untergrenze eines Pakets automatisch. +- Die Untergrenze wird nur angehoben, wenn Hugin eine neue Funktion wirklich braucht. + +Die konkreten Versionsnummern stehen in 5.5. + +### 5.5 Untergrenzen (Stand 18.10.2026) + +Ermittelt aus dem Changelog von Claude Code und den Release-Notes von OpenCode. +Aktuell: Claude Code 2.1.302, OpenCode 1.19.3 und OpenCode 2. + +**Claude Code** + +| Funktion, die Hugin nutzt | ab | +|---|---| +| Skills (`.claude/skills/`) | 2.0.20 | +| `disallowedTools` in Subagenten | 2.0.30 | +| Skills in Unterordnern (`/.claude/skills/`) | 2.1.6 | +| `effort` in Skills und Befehlen | 2.1.80 | +| `paths` als YAML-Liste | 2.1.84 | +| Beschreibung bis 1.536 Zeichen | 2.1.105 | +| volle Modell-IDs in Subagenten (`model: claude-…`) | 2.1.74 | +| benannte Argumente (`arguments`, `$name`) | **nicht im Changelog**; wirken in 2.1.303 (Stufe 0), ältere Versionen ungeprüft | +| `AGENTS.md` wird gelesen, wenn es keine `CLAUDE.md` gibt | 2.1.277 (für Hugin nicht nötig) | + +**Untergrenze Claude Code: 2.1.105**, vorläufig. Sie steigt, falls die benannten +Argumente später kamen. Prüfen: ältere Version installieren und +`TOOLS=claude scripts/tool-smoke-test.sh`. + +**OpenCode 1** + +| Funktion, die Hugin nutzt | ab | +|---|---| +| Kopfzeilen für MCP-Server | 0.3.18 (zuverlässig ab 1.0.138) | +| Agent Skills | 1.0.186 | +| liest `.claude/skills/` | 1.0.208 | +| `permission` statt `tools` in Agenten | 1.1.1 | +| Claude-Kompatibilität abschaltbar | 1.1.7 | +| liest `.agents/skills/` | 1.1.50 | +| Frontmatter mit CRLF (Windows) | 1.2.11 | +| `metadata`-Schlüssel `opencode/slash`, `opencode/autoinvoke` | wirken nicht (1.19.3, Stufe 0); Skills sind immer per `/name` aufrufbar | + +**Untergrenze OpenCode 1: 1.2.11**, vorläufig. Die Windows-Korrektur ist der Grund. + +**OpenCode 2** (seit 11.10.2026, Wechsel mit `opencode upgrade --major`; beide +Fassungen heißen `opencode`) + +| Thema | Auswirkung auf Hugin | +|---|---| +| Skills | liest weiter `.claude/skills/` und `.agents/skills/`; `name` ist nur noch Anzeige, die ID kommt aus dem Pfad; `opencode/autoinvoke` und `disable-model-invocation` werden verstanden, `opencode/slash` ist nicht dokumentiert | +| `CLAUDE.md` | wird **nicht mehr** gelesen, nur `AGENTS.md`. Für Hugin unkritisch, weil Regeln über `instructions` eingebunden werden (→ 7.2) | +| `instructions` in `opencode.json` | unverändert | +| Agenten und Befehle | neue Felder (`permissions` als geordnete Liste, `bash` → `shell`, `subtask` → `subagent`); alte Frontmatter wird automatisch übersetzt | +| MCP | Server stehen unter `mcp.servers`, `enabled` wird zu `disabled`. Ob die alte Form noch gelesen wird, ist offen → Stufe 0 | +| Plugins | V1-Plugins laufen nicht. Betrifft `plugin`-Pakete (Stufe 5d) und später `hook`-Pakete; ungeprüft | + +**Folge:** Hugin unterstützt vorerst **OpenCode 1 und 2**. Die Übersetzer schreiben +Agenten und Befehle im Format von OpenCode 1, das beide verstehen. Für MCP (und +später Plugins) braucht es vielleicht eine eigene Fassung für OpenCode 2; das ist +ungeprüft, solange OpenCode 2 nicht getestet ist (STUFE0.md, Frage 10). Das CLI meldet dafür die erkannte Hauptversion (`opencode@1` oder +`opencode@2`) an `/bundle`. + +--- + +## 6. Das Quellformat + +Ein Paket ist ein Ordner. Darin liegen das **Manifest** `hugin.yaml` und der Inhalt. +So kann ein Autor ein Paket lokal mit seinem Werkzeug entwickeln und dann hochladen. + +### 6.1 Ordnerstruktur + +``` +pub-munin-nutzen/ +├── hugin.yaml # Manifest: wer, was, welche Version, welche Ziele +├── SKILL.md # Inhalt (bei skill); Frontmatter erzeugt Hugin +├── referenz/ +│ └── beispiele.md # Hilfsdateien, werden mitverteilt +└── skripte/ + └── pruefen.sh # Skripte: Risiko „skripte“, strengere Prüfung +``` + +Je Art heißt die Inhaltsdatei anders: + +| Art | Inhaltsdatei | Bemerkung | +|---|---|---| +| `skill` | `SKILL.md` | plus beliebige Hilfsdateien | +| `rules` | `RULES.md` | reiner Text | +| `command` | `COMMAND.md` | Prompt-Vorlage mit Platzhaltern | +| `subagent` | `AGENT.md` | Systemprompt des Subagenten | +| `mcp` | keine | alles steht im Manifest | +| `hook` | `claude/…` oder `opencode/…` | Inhalt je Werkzeug, nicht übersetzt | + +In der Inhaltsdatei steht **keine Frontmatter**. Alle Angaben stehen im Manifest, +die Übersetzer erzeugen daraus die Frontmatter je Werkzeug. So gibt es genau +eine Stelle für Metadaten. + +### 6.2 Das Manifest + +```yaml +# hugin.yaml +id: pub-munin-nutzen # = Name in beiden Werkzeugen, Regeln → 8 +kind: skill +version: 1.4.0 +owner: team-plattform # Projekt, das das Paket pflegt +scope: public # public | tenant | project +description: > + Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen + remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest. +license: intern # optional +min_version: # optional, sonst die allgemeine Untergrenze + claude: "" + opencode: "" +changes: > # Pflicht ab Version 2: was ist neu? + Hinweis auf das neue Werkzeug „related“ ergänzt. +targets: [claude, opencode] # Standard: beide +``` + +Hugin setzt selbst, der Autor setzt sie **nicht**: + +- `risk` (vom Linter ermittelt, → 16) +- `compatibility` (vom Linter ermittelt, → 5.2) +- `checksum` +- Status, Freigabe, Kanal + +### 6.3 Zusätze je Werkzeug + +Was nur ein Werkzeug kann, steht in einem eigenen Block. Der Kern bleibt portabel. + +```yaml +extra: + claude: + allowed-tools: [mcp__munin__recall, mcp__munin__remember] + paths: ["**/*.go"] +``` + +In OpenCode ist jeder Skill auch per `/name` aufrufbar; `opencode/slash` ist nicht nötig. +Einen Skill nur von Hand aufrufbar zu machen, geht in OpenCode 1 nicht: `opencode/autoinvoke` +und `disable-model-invocation` werden übergangen (geprüft mit 1.19.3, → STUFE0.md). +In Claude Code wirkt `disable-model-invocation: true`. + +- Der Linter kennt für jedes Werkzeug die erlaubten Felder. Unbekannte lehnt er ab. +- Zusätze können die Kompatibilitätsstufe ändern. Beispiel: `context: fork` bei + Claude Code verlangt eine eigene Fassung je Werkzeug → `translated`. + +### 6.4 Beispiele je Art + +#### Befehl mit benannten Argumenten + +```yaml +id: pub-release-notiz +kind: command +version: 1.0.0 +owner: team-plattform +scope: public +description: Schreibt eine Release-Notiz für eine Version. +arguments: [version, zielgruppe] # benannt, Reihenfolge = Position +``` + +```markdown + +Schreibe eine Release-Notiz für Version {{version}}, gedacht für {{zielgruppe}}. +Lies dazu die Commits seit dem letzten Tag: +!`git log --oneline $(git describe --tags --abbrev=0)..HEAD` +``` + +`{{name}}` ist der neutrale Platzhalter von Hugin. Daneben ist nur `$ARGUMENTS` erlaubt. + +#### Subagent + +```yaml +id: pub-sicherheits-review +kind: subagent +version: 2.1.0 +owner: team-sicherheit +scope: public +description: > + Prüft Änderungen auf Sicherheitsprobleme. Nach Änderungen an + Authentifizierung, Eingabeverarbeitung oder Kryptografie einsetzen. +capability: read # read | write | shell | web; kombinierbar als Liste +model: thorough # fast | standard | thorough | inherit +extra: + claude: + effort: high + opencode: + mode: subagent + temperature: 0.1 +``` + +#### MCP-Anbindung + +```yaml +id: pub-munin-anbindung +kind: mcp +version: 1.0.0 +owner: team-plattform +scope: public +description: Bindet Munin als MCP-Server an. +server: + name: munin + url: https://munin.intern/mcp + headers: + Authorization: "Bearer {{env:MUNIN_KEY}}" # neutral, nie ein echter Key +``` + +### 6.5 Paare + +Zwei Pakete, die dasselbe für verschiedene Werkzeuge tun, typischerweise Hooks: + +```yaml +id: pub-prod-schutz-claude +kind: hook +targets: [claude] +pair: pub-prod-schutz-opencode +``` + +Hugin zeigt Paare zusammen an, warnt, wenn ein Projekt nur eine Hälfte abonniert hat +und beide Werkzeuge nutzt, und verlangt bei einer neuen Version der einen Hälfte +eine Bestätigung, dass die andere noch passt. + +--- + +## 7. Die Übersetzer + +Ein Übersetzer je Ziel und Art. Ein Übersetzer ist eine reine Funktion: +**Version + Hugin-Konfiguration → Liste von (Pfad, Inhalt)**. Er schreibt nichts +selbst. Das hat zwei Vorteile: + +- Es gibt sie nur einmal, im Server (→ 14.2). Das CLI wendet nur ihr Ergebnis an, + die Weboberfläche zeigt damit vorher, was im Repo ankommt. +- Übersetzer sind einfach mit Tabellentests zu prüfen: Eingabe → erwartete Dateien. + +### 7.1 Skills + +- **`portable`:** **eine** Datei `.claude/skills//SKILL.md` mit + Frontmatter aus `name`, `description`, `license`, `metadata`, dazu die Hilfsdateien. + Beide Werkzeuge lesen sie. +- **Mit Zusätzen für Claude:** OpenCode übergeht unbekannte Felder wie `allowed-tools` + still (Stufe 0, Frage 1). Die Felder kommen mit in dieselbe Datei, die Stufe bleibt + `portable`. Folge: Felder, die das Verhalten ändern, wirken nur in Claude Code, etwa + `disable-model-invocation` (Linter-Hinweis H023). +- **Ausnahme `OPENCODE_DISABLE_CLAUDE_CODE` oder `…_SKILLS`:** Hat ein Team den + Claude-Support in OpenCode abgeschaltet, liest OpenCode `.claude/skills/` nicht. + Dafür gibt es in `.hugin.yaml` die Einstellung `opencode_reads_claude: false`. + Hugin schreibt die Skills dann zusätzlich nach `.opencode/skills/`. +- **Nur OpenCode** (`tools` ohne `claude`): Skills nur nach `.opencode/skills/`, nichts + unter `.claude/`. Der Server leitet das aus den Werkzeugen der Anfrage ab. Grund: + `.opencode/skills/` ist der eigene Ort von OpenCode und hängt nicht davon ab, ob der + Claude-Support an ist; ein `.claude/`-Ordner in einem Repo ohne Claude Code verwirrt + nur. Unter OpenCode 1.19.3 verdeckte zudem ein globaler Skill + (`~/.config/opencode/skills/`) einen gleichnamigen aus `.claude/skills/` im Repo; ab + 1.19.4 gewinnt an beiden Orten der Skill im Repo (geprüft mit `opencode debug skill`). + +### 7.2 Regeln + +Erzeugte Dateien: + +``` +.hugin/rules/pub-hausregeln.md +.hugin/rules/team-alpha-go-stil.md +``` + +Einbindung für **Claude Code:** Hugin pflegt in der `CLAUDE.md` einen verwalteten Block +und legt die Datei an, falls es sie nicht gibt: + +```markdown + +@.hugin/rules/pub-hausregeln.md +@.hugin/rules/team-alpha-go-stil.md + +``` + +Einbindung für **OpenCode:** Hugin ergänzt in `opencode.json` den Eintrag +`".hugin/rules/*.md"` in `instructions`, falls er fehlt. Andere Einträge bleiben unberührt. + +Ergebnisse aus Stufe 0 (STUFE0.md): + +- OpenCode liest bei fehlender `AGENTS.md` zwar die `CLAUDE.md`, löst deren + `@`-Verweise aber nicht auf. Die Regeln kommen also nicht doppelt an; eine eigene + `AGENTS.md` ist nicht nötig. +- `opencode.json` darf Kommentare (JSONC) enthalten. Das CLI erhält sie beim Ändern. + +### 7.3 Befehle + +| | Claude Code | OpenCode | +|---|---|---| +| Datei | `.claude/commands/.md` | `.opencode/commands/.md` | +| `{{name}}` wird zu | `$name`, dazu `arguments: [...]` in der Frontmatter | `$` | +| `$ARGUMENTS` | bleibt | bleibt | + +Beispiel `{{version}}` (erstes Argument): Claude `$version`, OpenCode `$1`. + +`.claude/commands/` statt eines Skills, weil OpenCode Skills aus `.claude/skills/` +mitliest. Ein Befehl als Skill käme dort als zusätzlicher, vom Modell automatisch +aufrufbarer Skill an. **Geprüft** (STUFE0.md): OpenCode 1 liest `.claude/commands/` nicht. + +### 7.4 Subagenten + +**Fähigkeitsklassen:** + +| Klasse | Claude Code (`tools`) | OpenCode (`permission`) | +|---|---|---| +| `read` | `Read, Grep, Glob` | `edit: deny`, `bash: deny`, `webfetch: deny`, `websearch: deny` | +| `write` | + `Edit, Write` | `edit: allow` | +| `shell` | + `Bash` | `bash: ask` (bewusst nicht `allow`) | +| `web` | + `WebFetch, WebSearch` | `webfetch: allow`, `websearch: allow` | + +Die Werkzeug- und Rechtenamen sind in Stufe 0 geprüft und stehen als Tabelle im Code +(`TOOLS` und `PERMISSIONS` in den Übersetzern). Ändern sie sich, ändert man nur +diese Tabelle. + +**Modellklassen:** Der Plattform-Admin legt die Zuordnung in der Hugin-Konfiguration fest: + +```yaml +models: + claude: + fast: haiku + standard: sonnet + thorough: opus + opencode: + fast: anthropic/claude-haiku-5-5 + standard: anthropic/claude-sonnet-5-5 + thorough: anthropic/claude-opus-5-5 +``` + +`inherit` wird in Claude Code zu `model: inherit`, in OpenCode fehlt `model` einfach. +Eine neue Modellgeneration bedeutet: Zuordnung ändern, `hugin sync` aufrufen, fertig. +An den Paketen ändert sich nichts. + +**Erzeugte Dateien:** `.claude/agents/.md` mit `name`, `description`, `tools`, `model` +und den Zusätzen; `.opencode/agents/.md` mit `description`, `mode` +(Standard `subagent`), `permission`, `model` und den Zusätzen. +**Geprüft** (STUFE0.md): OpenCode 1 liest `.claude/agents/` nicht mit; es gibt keine Doppel. + +### 7.5 MCP-Anbindungen + +- **Claude Code:** Hugin ergänzt in `.mcp.json` unter `mcpServers.` den Eintrag + `"type": "http"`, `url`, `headers`. Aus `{{env:VAR}}` wird `${VAR}`. +- **OpenCode:** Hugin ergänzt in `opencode.json` unter `mcp.` den Eintrag + `"type": "remote"`, `url`, `headers`, `"enabled": true`. Aus `{{env:VAR}}` wird `{env:VAR}`. +- Hugin ändert nur Schlüssel, die es selbst angelegt hat (vermerkt in `hugin.lock`). + Gibt es schon einen gleichnamigen Server, den Hugin nicht angelegt hat, bricht + `sync` mit einer klaren Meldung ab, statt ihn zu überschreiben. + +### 7.6 Hooks + +Keine Übersetzung. Der Inhalt aus `claude/` bzw. `opencode/` wird übernommen: + +- **Claude Code:** Hook-Einträge in `.claude/settings.json`, nur in Schlüsseln, die + Hugin verwaltet; dazu das Skript unter `.hugin/hooks//`. +- **OpenCode:** Plugin-Datei unter `.opencode/plugins/.ts`. + +Hooks haben immer mindestens das Risiko `scripts` (→ 16). + +--- + +## 8. Prüfung (Linter) + +Der Linter läuft beim Hochladen jeder Version auf dem Server und lokal mit +`hugin validate`. Er liefert **Fehler** (Version wird nicht angenommen) und **Hinweise**. +Jede Regel hat eine Kennung, damit Meldungen und Doku zusammenpassen. + +| Kennung | Prüft | Stufe | +|---|---|---| +| `H001` | `id` passt auf `^[a-z0-9]+(-[a-z0-9]+)*$`, 1–64 Zeichen | Fehler | +| `H002` | `id` beginnt mit dem Präfix der Geltung (→ 10.4) | Fehler | +| `H003` | `description` 1–1.024 Zeichen, nicht mehr der Platzhalter aus Vorlage oder Import | Fehler | +| `H004` | `version` ist Semver und größer als die letzte | Fehler | +| `H005` | `changes` ist ab der zweiten Version gesetzt | Fehler | +| `H006` | die Inhaltsdatei der Art ist vorhanden (`SKILL.md`, `RULES.md` …) | Fehler | +| `H007` | die Inhaltsdatei hat keine Frontmatter | Fehler | +| `H008` | Art `hook` erst ab Stufe 5 | Fehler | +| `H010` | keine nummerierten Platzhalter (`$0`, `$1` …) im Inhalt | Fehler | +| `H011` | alle `{{name}}` sind in `arguments` deklariert und umgekehrt | Fehler | +| `H012` | keine werkzeugspezifischen Variablen im Inhalt (`${CLAUDE_SKILL_DIR}` …) | Fehler | +| `H020` | Felder in `extra.claude` und `extra.opencode` kennt das Werkzeug für diese Art | Fehler | +| `H021` | Werkzeugnamen im Text (`Read`, `Bash`, `read`, `bash` …) | Hinweis: neutral formulieren, z. B. „lies die Datei“ statt „nutze Read“ | +| `H022` | Verweise auf Hilfsdateien sind relativ und existieren | Fehler | +| `H023` | Felder ohne Wirkung in OpenCode 1: „nur von Hand“ (`disable-model-invocation`, `opencode/autoinvoke`) und `opencode/slash` (→ STUFE0.md) | Hinweis | +| `H024` | auffällige Anweisungen an den Agenten, z. B. „ignoriere vorherige Anweisungen“ (Prompt-Injection, → 16.2) | Hinweis | +| `H025` | unsichtbare Unicode-Zeichen (Zero-Width, Bidi, Tag-Zeichen): Prüfer sehen sie nicht, das Modell schon | Fehler | +| `H030` | Skripte: Typ erkannt (sh, ps1, py …) | Hinweis bei nur einem Betriebssystem | +| `H031` | kein Geheimnis im Paket (Muster für Keys, Passwörter, Tokens) | Fehler | +| `H032` | `{{env:…}}` statt echter Werte in MCP-Kopfzeilen | Fehler | +| `H033` | ein `mcp`-Paket hat einen Block `server` | Fehler | +| `H034` | `capability` eines Subagenten: nur `read`, `write`, `shell`, `web` | Fehler | +| `H035` | `model` eines Subagenten: nur `fast`, `standard`, `thorough`, `inherit` | Fehler | +| `H040` | Risiko ermitteln (→ 16) | – | +| `H041` | Kompatibilitätsstufe ermitteln (→ 5.2) | – | +| `H042` | Mindestversion aus genutzten Feldern ermitteln (→ 5.4); als Hinweis gemeldet, in der Version gespeichert und im Bundle als Hinweis ausgeliefert | Hinweis | +| `H043` | Risiko `scripts` oder `permissions` nur mit persönlichem Key (`hp_…`, → 14.5) | Fehler | +| `H050` | Paar: die andere Hälfte existiert und hat die passende Gegenrichtung | Fehler | + +Die Kompatibilitätsstufe wird nicht geraten. Die Übersetzer laufen probehalber: +Kommt für beide Ziele dieselbe Datei am selben Ort heraus, ist das Paket `portable`. + +--- + +## 9. Lebenszyklus und Freigabe + +### 9.1 Status einer Version + +``` +Entwurf ──einreichen──▶ in Prüfung ──freigeben──▶ freigegeben ──▶ veraltet + ▲ │ + └───────zurückgeben──────┘ (mit Kommentar) +``` + +- **Entwurf:** Nur Autor und Besitzer sehen ihn. Er ist änderbar, bekommt aber + bei jeder Änderung eine neue Prüfsumme. +- **In Prüfung:** Ab hier unveränderlich. Prüfer sehen Diff, Vorschau je Werkzeug, + Linter-Ergebnis und Risiko. +- **Freigegeben:** Erreicht die Teams beim nächsten `hugin sync`. +- **Veraltet:** Bleibt abrufbar, damit `hugin.lock` immer funktioniert. `sync` meldet + Veraltetes und schlägt die Nachfolgeversion vor. +- Daneben kann ein **Paket** insgesamt veraltet sein, mit Hinweis auf einen Nachfolger. + +### 9.2 Wer darf freigeben + +| Risiko | Freigaben | Wer | +|---|---|---| +| `text` | 1 | ein Prüfer der Geltung, nicht der Autor | +| `tools` | 1 | wie oben | +| `scripts` | 2 | zwei Prüfer, keiner davon der Autor | +| `permissions` | 2 | wie oben, einer davon Admin der Geltung | + +Öffentliche Pakete gibt ein Prüfer frei, den der Plattform-Admin dafür benannt hat. + +**Voraussetzung:** Für das Vier-Augen-Prinzip muss Hugin **Personen** unterscheiden +können, nicht nur Projekte (→ 14.5). Bis Stufe 4 gilt eine vereinfachte Regel: +eine Freigabe mit einem Prüfer-Key, der vom Autor-Key getrennt vergeben ist. + +### 9.3 Versionen und Kanäle + +- **Semver:** + - **Major:** Argumente, Name oder Verhalten ändern sich so, dass Nutzer etwas anpassen müssen. + - **Minor:** Ergänzungen. + - **Patch:** Tippfehler und Klarstellungen. +- **Kanal `stable`:** neueste freigegebene Version. +- **Kanal `test`:** Eine freigegebene Version kann als `test` markiert werden. Sie + erreicht nur Abos auf `@test` und wird später auf `stable` hochgestuft. +- **Abos auf Versionsbereiche:** `@^1` (alles 1.x), `@1.4.0` (fest). + Bei `@stable` gilt: Major-Sprünge kommen **nicht** automatisch. `sync` meldet sie, + der Nutzer bestätigt mit `hugin sync --major`. + +--- + +## 10. Geltung, Abos, Pflichtpakete, Namen + +### 10.1 Geltung + +Zwei Ebenen: eine **öffentliche** für alle und darunter die **Tenants**, die +voneinander nichts sehen. Innerhalb eines Tenants kann ein Paket zusätzlich auf +ein Projekt beschränkt sein. + +| Geltung | Wer sieht und abonniert | Wer pflegt | +|---|---|---| +| `public` | alle Tenants | Plattform-Team und benannte Autoren | +| `tenant` | alle Projekte dieses Tenants, sonst niemand | Projekte des Tenants | +| `project` | nur dieses Projekt | das Projekt selbst | + +Ein Tenant sieht also genau: die öffentlichen Pakete und seine eigenen. Das gilt +für alle Zugänge gleich: Weboberfläche, API, `/bundle`, MCP-Suche und Export +(→ 15.1). Ein Paket kann nicht für ausgewählte Tenants freigegeben werden; wer +das braucht, legt es im jeweiligen Tenant an oder macht es öffentlich. + +### 10.2 Abos + +Abos stehen **im Repository** (`.hugin.yaml`, → 11.2), nicht auf dem Server. +Damit sind sie versioniert und im Code-Review sichtbar. Der Server erfährt sie über +die Nutzungsmeldung von `sync`. + +### 10.3 Pflichtpakete + +Plattform-Admin (für öffentliche Pakete, dann in allen Tenants) und Tenant-Admin (für seinen Tenant) können +Pakete als **Pflicht** markieren. `hugin sync` holt sie immer, auch wenn sie in +`.hugin.yaml` fehlen, und verweigert ein Abbestellen. Gedacht für wenige Dinge, +etwa Sicherheitsregeln und die Munin-Anbindung. + +### 10.4 Namensräume + +Weder Claude Code noch OpenCode kennen außerhalb von Plugins Namensräume für +Skills und Agenten. Feste Präfixe verhindern Kollisionen mit den eigenen Dateien +der Teams: + +| Geltung | Präfix | Beispiel | +|---|---|---| +| `public` | `pub-` | `pub-munin-nutzen` | +| `tenant` | `-` | `vertrieb-angebot-pruefen` | +| `project` | `-` | `shop-api-release` | + +Tenant-Slugs dürfen nicht `pub`, `hugin`, `admin` oder `api` heißen; das wird beim +Anlegen eines Tenants geprüft. + +Das Präfix gehört zur `id` und zählt zu den 64 Zeichen. Tenant- und +Projekt-Slugs sollten deshalb kurz sein. + +--- + +## 11. Verteilung: das Programm `hugin` + +Ein einzelnes Go-Programm ohne Abhängigkeiten für macOS, Linux und Windows. +Es ist für zwei Gruppen gedacht: **Teams** (abonnieren, abgleichen) und **Autoren** +(entwickeln, prüfen, hochladen). + +### 11.1 Befehle + +| Befehl | Für | Tut | +|---|---|---| +| `hugin init --server … --project …` | Teams | legt `.hugin.yaml` an, mit `tools: auto` (Werkzeuge werden bei jedem `sync` an den Dateien im Repo erkannt) | +| `hugin sync` | Teams | holt die abonnierten und die Pflichtpakete, übersetzt, schreibt, aktualisiert `hugin.lock` | +| `hugin sync --major` | Teams | erlaubt Major-Sprünge | +| `hugin check` | Teams | ändert nichts; meldet neue Versionen, veraltete Pakete, Handänderungen an verwalteten Dateien, zu alte Werkzeugversionen. Rückgabewert ≠ 0 bei Befund, gut für Skripte | +| `hugin status` | Teams | listet Abos, installierte Versionen und Kompatibilität | +| `hugin search [text] [--kind …]` | beide | sucht im Katalog in ID und Beschreibung | +| `hugin new ` | Autoren | legt einen Paketordner mit Manifest-Gerüst an | +| `hugin validate [ordner]` | Autoren | schickt den Ordner an den Server zum Prüfen (ohne Speichern); Linter-Ergebnis und Vorschau der erzeugten Dateien je Werkzeug | +| `hugin try [ordner]` | Autoren | installiert den Entwurf in das aktuelle Repo, ohne `hugin.lock` zu ändern, zum Ausprobieren mit Claude Code und OpenCode. Führt `.hugin/trial.lock`; `hugin try --remove` räumt wieder auf | +| `hugin upload [ordner]` | Autoren | lädt als Entwurf hoch | +| `hugin import --prefix

` | Autoren | wandelt vorhandene Dateien des Repos in Paketordner um, **OpenCode zuerst**: `.opencode/`, `opencode.json`, `AGENTS.md`, danach `.claude/`, `.mcp.json`, `CLAUDE.md`. Werkzeugeigene Felder (z. B. `permission`) bleiben samt Reihenfolge in `extra`; feste Werte in MCP-Kopfzeilen werden zu `{{env:…}}`; lokale MCP-Server, Plugins und Symlinks bleiben draußen. Ändert im Repo nichts; Übersicht in `IMPORT.md` | +| `hugin login` | beide | speichert den Key im Schlüsselbund des Betriebssystems, sonst Umgebungsvariable `HUGIN_KEY` | + +### 11.2 `.hugin.yaml` im Repository (wird eingecheckt) + +```yaml +server: https://hugin.intern +project: shop # Projekt-Slug in Hugin +tools: auto # auto | [claude] | [opencode] | [claude, opencode] +opencode_reads_claude: true # false, wenn OPENCODE_DISABLE_CLAUDE_CODE gesetzt ist +tool_check: warn # warn | strict (bricht bei zu alten Werkzeugen ab) +packages: + - pub-munin-nutzen@stable + - pub-sicherheits-review@^2 + - shop-api-release@test +unsubscribed: [] # hier stehen nur Nicht-Pflichtpakete +``` + +`tools: auto` erkennt die Werkzeuge an vorhandenen Dateien (`.claude/`, +`CLAUDE.md`, `.mcp.json` für Claude Code; `.opencode/`, `opencode.json`, `AGENTS.md` +für OpenCode). Gibt es keine Hinweise, erzeugt Hugin für beide. Liegt nur eine +`CLAUDE.md` im Repo, gilt nur Claude Code; wer beide nutzt, trägt sie dann fest ein. + +### 11.3 `hugin.lock` (wird eingecheckt) + +```yaml +# Von hugin sync erzeugt. Nicht von Hand ändern. +packages: + pub-munin-nutzen: + version: 1.4.0 + checksum: sha256:3f1c… + files: + .claude/skills/pub-munin-nutzen/SKILL.md: sha256:a91e… + .claude/skills/pub-munin-nutzen/referenz/beispiele.md: sha256:77b0… + pub-munin-anbindung: + version: 1.0.0 + checksum: sha256:c02d… + keys: + .mcp.json: [mcpServers.munin] + opencode.json: [mcp.munin] +``` + +Damit weiß `sync`: + +- welche Dateien und JSON-Schlüssel ihm gehören, +- ob jemand eine verwaltete Datei von Hand geändert hat (Prüfsumme weicht ab), +- was beim Abbestellen zu löschen ist. + +### 11.4 Verwaltete Dateien + +- **Markdown-Dateien** bekommen einen Kopfkommentar. Bei Dateien mit Frontmatter + steht er als YAML-Kommentar in der Frontmatter, damit die Datei gültig bleibt: + + ```markdown + --- + # Verwaltet von Hugin (pub-munin-nutzen 1.4.0). Änderungen über hugin.intern. + name: pub-munin-nutzen + description: … + --- + ``` + +- **`CLAUDE.md`:** nur der Block zwischen `hugin:begin` und `hugin:end`. +- **JSON-Dateien:** nur die Schlüssel aus `hugin.lock`. Reihenfolge und Formatierung + der übrigen Inhalte bleiben erhalten (→ prüfen bei JSONC, 7.2). +- **Handänderung entdeckt:** `sync` zeigt den Diff und bricht ab. Mit `--overwrite` + setzt es die Datei zurück. + +### 11.5 Ablauf von `hugin sync` + +1. `.hugin.yaml` lesen, Werkzeuge erkennen, Werkzeugversionen prüfen (Warnung). +2. Beim Server `POST /api/v1/bundle` aufrufen: abonnierte und Pflichtpakete gegen + Kanäle und Bereiche → konkrete Versionen, übersetzt für die erkannten Werkzeuge. +3. Für Versionen, deren Prüfsumme nicht in `hugin.lock` steht, die übersetzten + Operationen für die erkannten Werkzeuge laden (→ 14.2). Prüfsummen kontrollieren. +5. Handänderungen an verwalteten Dateien suchen → bei Befund abbrechen. +6. Schreiben, Abbestelltes entfernen, `hugin.lock` neu schreiben. +7. Nutzung an den Server melden: Projekt, Repo-Kennung, Werkzeuge mit Versionen, + installierte Paketversionen. Die Repo-Kennung ist die Git-Remote-URL + (`git remote get-url origin`), vereinheitlicht: ohne Zugangsdaten, ohne `.git`, + SSH- und HTTPS-Form angeglichen (`git.intern/shop/api`). Ohne Git-Remote: der + Pfad des Repos relativ zum Benutzerordner. +8. Zusammenfassung ausgeben: neu, aktualisiert, entfernt, Hinweise. + +Schlägt Schritt 2 oder 3 fehl (kein Netz), ändert `sync` nichts. Ein halber Zustand +darf nie entstehen. + +--- + +## 12. MCP-Server + +Hugin bietet einen MCP-Endpunkt, wie Munin. Agenten in beiden Werkzeugen +erreichen ihn, weil beide MCP-Werkzeuge unterstützen. Er dient der **Suche und dem +Rückkanal**, nicht der Verteilung. Verteilt wird über `hugin sync`, weil nur +installierte Skills die eingebaute Skill-Logik der Werkzeuge nutzen. + +| Werkzeug | Tut | +|---|---| +| `search_packages` | „Gibt es schon einen Skill für X?“: sucht in ID und Beschreibung freigegebener Pakete (→ 13, Suche) | +| `get_package` | liefert den Inhalt einer Version, z. B. damit der Agent einen Skill einmalig nutzt, ohne ihn zu installieren | +| `report_feedback` | Rückmeldung zu einem Paket: unklar, falsch, veraltet, Verbesserungsvorschlag; mit Version und Werkzeug | +| `list_subscriptions` | was dieses Projekt abonniert hat und ob es Neues gibt | + +Kein Werkzeug zum Schreiben oder Freigeben. Pakete entstehen nur über Autoren und Prüfer. + +--- + +## 13. Weboberfläche + +Vom Server gerendert mit **Jinja2** und **HTMX**, gestaltet mit dem hauseigenen +CSS-Framework (`static/css-framework/`). Kein JS-Framework, kein eigener Build-Schritt. + +- **Seiten** bauen auf dem Basis-Template des Frameworks auf (Sidebar-Layout, + Topnav, Theme-Umschalter). +- **HTMX** für Teilaktualisierungen: Filter und Suche im Katalog, Tabs für Diff und + Vorschau, Freigabe-Dialoge, Formularprüfung (422 → Fragment mit Fehlern). Ein + Endpunkt liefert bei `HX-Request` nur das Fragment, sonst die ganze Seite. +- **Komponenten** kommen nur aus dem Framework (`components.css`, Tokens aus + `tokens.css`). Fehlt etwas, wird es im Framework ergänzt, nicht in Hugin. +- **CSP** streng, ohne Inline-Skripte und Inline-Styles. Das Framework muss dafür + angepasst werden (→ 21.2). +- **Online-Hilfe** unter `/help`, auch ohne Anmeldung: Themen je Zielgruppe als + Markdown, Suche, Kontext-Links von den Arbeitsseiten. Die Tabelle der Linter-Regeln + entsteht aus dem Code (`hugin_core.linter.RULES`). + +| Seite | Inhalt | +|---|---| +| **Katalog** | alle sichtbaren Pakete; Filter nach Art, Geltung, Kompatibilität, Risiko; Suche | +| **Paket** | Beschreibung, Besitzer, Versionen, Kanäle, Nutzung (wie viele Projekte, welche Versionen), Rückmeldungen | +| **Version** | Inhalt, Diff zur Vorgängerversion, **Vorschau je Werkzeug** (die erzeugten Dateien nebeneinander), Linter-Ergebnis, Risiko, Freigaben | +| **Prüfungen** | Posteingang der Prüfer: was wartet auf mich? | +| **Mein Projekt** | Abos laut letzter Meldung, veraltete Versionen, Pakete mit neuen Versionen, Werkzeugversionen der Teams | +| **Überblick (Tenant)** | wie bei Munin: was zu tun ist, z. B. Prüfungen offen, Projekte auf veralteten Versionen, Pakete ohne Nutzung | +| **System (Plattform)** | wie bei Munin, plus Modellzuordnung, Werkzeug-Untergrenzen, Pflichtpakete | +| **Projekte, Keys, Tenants** | wie bei Munin | +| **Hilfe** | für Teams, Autoren, Prüfer, Admins | + +**Suche** (Katalog, `hugin search`, MCP): einfache Textsuche (`LIKE`, ohne Groß- und +Kleinschreibung) über ID und Beschreibung, dazu die Filter. Keine Volltextsuche: Der +Katalog bleibt klein, und die Beschreibung ist ohnehin so geschrieben, dass sie +sagt, wofür ein Paket da ist. Reicht das später nicht, lässt sich Volltext im +Store nachrüsten, ohne Schnittstellen zu ändern. + +Bearbeiten in der Weboberfläche ist bewusst schlicht: Manifest und Textdateien +direkt editieren; mehrere Dateien als ZIP hochladen. Der bevorzugte Weg für Autoren +ist lokal mit `hugin new`, `hugin validate`, `hugin try` und `hugin upload`. + +--- + +## 14. Architektur + +### 14.1 Überblick + +``` +┌──────────────── Entwickler-Rechner ────────────────┐ +│ Repo: .hugin.yaml, hugin.lock, erzeugte Dateien │ +│ hugin (CLI, Go) ──────┐ │ +│ Claude Code / OpenCode ──┐ │ +└────────────────────────┼──┼────────────────────────┘ + │ │ MCP (suchen, Rückmeldung) + HTTP-API │ │ +┌────────────────────────▼──▼────────────────────────┐ +│ Hugin-Server (Python, FastAPI) │ +│ Weboberfläche (Jinja2, HTMX) · HTTP-API · MCP · │ +│ Linter · Übersetzer · Auflösung · Audit · Export │ +│ │ │ +│ SQLite oder PostgreSQL │ +└────────────────────────────────────────────────────┘ +``` + +### 14.2 Technik und Aufteilung + +| Teil | Technik | +|---|---| +| Server | Python 3.12+, FastAPI, Uvicorn; Projekt und Abhängigkeiten mit `uv` | +| Modelle, Prüfung | Pydantic v2 (Manifest, API-Schemas) | +| Datenbank | SQLAlchemy 2 + Alembic; SQLite für kleinen Betrieb, PostgreSQL sonst | +| Weboberfläche | Jinja2, HTMX, CSS-Framework (→ 13) | +| MCP | offizielles MCP-Python-SDK, in FastAPI unter `/mcp` eingehängt | +| CLI | Go, eine Binärdatei ohne Abhängigkeiten für macOS, Linux und Windows | +| Tests | `pytest` (Server), `go test` (CLI) | + +#### Übersetzer und Linter nur im Server + +Server und CLI sind in verschiedenen Sprachen geschrieben. Linter und Übersetzer +gibt es deshalb **nur einmal, im Server**. Das CLI übersetzt nicht selbst. + +| Server | CLI | +|---|---| +| Manifest lesen, Linter, Risiko, Kompatibilität | Werkzeuge und ihre Versionen erkennen | +| Auflösung: Abos + Pflicht + Kanäle → Versionen | `.hugin.yaml` lesen, `hugin.lock` schreiben | +| Übersetzer → **Liste von Operationen** | Operationen anwenden, Handänderungen erkennen | +| Vorschau in der Weboberfläche | Schlüsselbund, Anmeldung | + +Ein Übersetzer liefert keine fertigen Repo-Dateien, sondern Operationen, die das +CLI anwendet. So bleiben Dateien der Teams unberührt, auch wenn der Server sie nie sieht: + +| Operation | Bedeutung | Beispiel | +|---|---|---| +| `file` | Datei mit diesem Inhalt anlegen oder ersetzen | `.claude/skills/pub-munin-nutzen/SKILL.md` | +| `block` | verwalteten Block in einer Datei setzen | `hugin`-Block in `CLAUDE.md` | +| `json` | Schlüssel in einer JSON-Datei setzen | `mcpServers.munin` in `.mcp.json` | + +Folge: `hugin validate` und `hugin try` brauchen den Server (`POST /api/v1/validate`, +→ 14.6). Für ein internes Werkzeug ist das vertretbar; `hugin sync` mit +unveränderter `hugin.lock` arbeitet wie bisher ohne Übersetzung. + +#### Verhältnis zu Munin + +Munin ist in Go geschrieben; Code lässt sich nicht übernehmen. Übernommen werden die +**Konzepte**: Tenants (in Munin: Workspaces), Projekte, Keys mit Rollen, Audit, Hilfe-System mit +Zielgruppen, Überblicks- und Systemseite, Markdown-Export. Sie werden in Python +neu gebaut. + +### 14.3 Paketstruktur und Einbettung + +``` +hugin/ +├── server/ # Python, ein uv-Projekt +│ ├── pyproject.toml +│ ├── src/ +│ │ ├── hugin_core/ # ohne FastAPI und DB, einzeln nutzbar +│ │ │ ├── package/ # Quellformat: Manifest (Pydantic), Ordner ↔ Version +│ │ │ ├── linter/ # Regeln H001 ff. +│ │ │ ├── translators/ # je Ziel und Art; reine Funktionen → Operationen +│ │ │ │ ├── claude/ +│ │ │ │ └── opencode/ +│ │ │ └── resolution/ # Abos + Pflicht + Kanäle → konkrete Versionen +│ │ └── hugin_app/ # FastAPI-Schicht +│ │ ├── store/ # SQLAlchemy-Modelle und Zugriffe +│ │ ├── auth/ # Keys, Personen, Rollen, Sitzungen, CSRF +│ │ ├── audit/ +│ │ ├── api/ # /api/v1 +│ │ ├── mcp/ # MCP-Werkzeuge +│ │ ├── web/ # Routen der Weboberfläche +│ │ ├── docs/ # Online-Hilfe +│ │ ├── export/ +│ │ ├── migrations/ # Alembic +│ │ ├── templates/ +│ │ └── static/ # CSS-Framework (aus dist/), htmx +│ └── tests/ +├── cli/ # Go +│ ├── cmd/hugin/ +│ └── internal/ +│ ├── api/ # Client für /api/v1 +│ ├── reconcile/ # Operationen anwenden, hugin.lock +│ ├── tools/ # Erkennung, Versionsprüfung +│ ├── keystore/ # Schlüsselbund des Betriebssystems +│ └── importer/ # hugin import +├── static/css-framework/ # Quelle des CSS-Frameworks +└── scripts/ +``` + +`hugin_core` darf nichts aus `hugin_app` importieren (geprüft mit `import-linter`). + +#### Einbettung in ein anderes Projekt + +Hugin soll später in ein anderes FastAPI-Projekt eingebaut werden können. Dafür: + +- **Kern einzeln nutzbar:** `hugin_core` hat keine Abhängigkeit zu FastAPI oder DB. +- **Router statt fester App:** `hugin_app` stellt Router bereit (`api_router()`, + `web_router()`, `mcp_app()`). Die eigenständige App setzt sie nur zusammen; ein + anderes Projekt hängt sie mit `include_router` unter einem Präfix ein. +- **Auth austauschbar:** Die Router fragen Identität und Rollen über eine + Schnittstelle ab (FastAPI-Dependency). Hugin bringt die Umsetzung mit Keys und + Personen mit (→ 14.5); ein Gastprojekt kann seine eigene einsetzen. +- **Datenbank teilbar:** Tabellen mit Präfix `hugin_` oder eigenes PostgreSQL-Schema, + eigene Alembic-Versionstabelle. Die Sitzung kommt als Dependency vom Gastprojekt. +- **Templates überschreibbar:** Jinja2 sucht zuerst in den Templates des + Gastprojekts, dann in denen von Hugin; das Layout (`base.html`) lässt sich so ersetzen. +- **Pfade nur über `url_for`**, nie fest im Template, damit ein Präfix funktioniert. + +### 14.4 Rollen + +| Rolle | Hängt an | Darf | +|---|---|---| +| `read` | Projekt-Key | Katalog, `sync`, Nutzung melden, Rückmeldung geben; typisch für das CLI und die Agenten | +| `author` | Person (ab Stufe 4), vorher Projekt-Key | Entwürfe für Pakete seines Projekts anlegen und einreichen | +| `reviewer` | Person (ab Stufe 4), vorher Projekt-Key | Versionen in seiner Geltung freigeben oder zurückgeben, nicht die eigenen | +| Tenant-Admin | bis Stufe 4 Tenant-Key (`ht_`), danach Person | Projekte und Keys im Tenant anlegen, Pflichtpakete im Tenant, ab Stufe 4 Personen anlegen und Prüfer benennen | +| Plattform-Admin | wie bei Munin | Tenants anlegen, Modellzuordnung, Untergrenzen, öffentliche Pflichtpakete, Prüfer für öffentliche Pakete | + +### 14.5 Identität: Maschinen und Personen + +#### Das Problem + +In Munin hängt der Zugang an **Keys**, und ein Key gehört zu einem **Projekt**, +nicht zu einem Menschen. Den Projekt-Key nutzen der Agent und oft mehrere Leute im +Team gemeinsam. Munin weiß also, *mit welchem Key* etwas geschah, aber nicht, +*welcher Mensch* es war. Für Wissenseinträge reicht das: Ist etwas falsch, korrigiert +man es. + +Bei Hugin reicht es nicht. Ein Paket ist Lieferkette (→ 16), und die Freigabe ist +der eigentliche Schutz. Sie muss zwei Dinge leisten: + +1. **Vier Augen:** Der Autor darf nicht selbst freigeben. Bei riskanten Paketen + stimmen zwei *verschiedene* Menschen zu. +2. **Verantwortung:** Im Audit steht „freigegeben von Anna Schmidt am 12.11.“, nicht + „freigegeben mit Key `shop-pruefer`“. Fragt die Revision, wer ein Skript in + 50 Repos verteilt hat, braucht es einen Namen. + +Mit Projekt-Keys geht beides nicht sicher: + +- Hat Anna den Autor- und den Prüfer-Key, reicht sie ein und gibt sich selbst frei. +- Teilen sich drei Leute einen Prüfer-Key, zählen zwei Freigaben als vier Augen, + obwohl es ein Mensch gewesen sein kann. + +#### Wer Person sein muss + +| Handlung | Wer | Identität | +|---|---|---| +| `hugin sync`, Suche, Nutzung melden | CLI, Agent | Projekt-Key genügt | +| Rückmeldung zu einem Paket | Agent oder Mensch | Projekt-Key genügt; Quelle wird vermerkt | +| Entwurf hochladen, einreichen | Autor | **Person** | +| Freigeben, zurückgeben | Prüfer | **Person** | +| Pflichtpakete, Prüfer benennen, Personen verwalten | Tenant-Admin | **Person** | +| Modellzuordnung, Untergrenzen, öffentliche Pflichtpakete | Plattform-Admin | `HUGIN_ADMIN_TOKEN` (→ 16.3), im Audit als „Plattform-Admin“ | + +**Grundsatz: Maschinen nutzen Projekt-Keys, Menschen in der Freigabekette eine +eigene Identität.** + +#### Möglichkeiten + +| | Persönliche Keys | SSO (OIDC: Keycloak, Entra ID) | +|---|---|---| +| Wie | Tenant-Admin legt Personen mit Name und Mail an; jede bekommt einen eigenen Key (`hp_…`) für Web und CLI | Anmeldung mit dem Firmenkonto; Gruppen aus dem Verzeichnis werden zu Rollen | +| Vorteile | keine neue Infrastruktur, Muster wie Munin, schnell gebaut | echte Identität, Austritte wirken automatisch, keine Keys für Menschen | +| Nachteile | Identität nur so gut wie die Vergabe; weitergegebene Keys hebeln sie aus; Austritte von Hand sperren | Abhängigkeit vom Identity Provider (IdP), Abstimmung mit der IT; für Munin bewusst zurückgestellt | + +#### Entscheidung + +1. **Stufe 1–3: ohne Personen.** Eine Freigabe; Autor- und Prüfer-Key werden + getrennt vergeben. Die Schwäche steht offen in der Doku. Das genügt für den + Start mit wenigen Paketen vom Risiko `text`, etwa `pub-munin-nutzen`. + Pakete mit Risiko `scripts` oder `permissions` nimmt Hugin bis Stufe 4 **nicht an**. +2. **Stufe 4: persönliche Keys.** + - Rollen `author` und `reviewer` hängen an der Person, je Projekt oder Tenant. + - Vier Augen werden erzwungen: Prüfer ≠ Autor; bei `scripts` und `permissions` zwei + verschiedene Personen. + - Im Audit stehen Namen. + - Persönliche Keys laufen ab (Standard 180 Tage) und tauchen wie bei Munin im + Überblick auf („läuft bald ab“, „seit 90 Tagen ungenutzt“). So fallen + vergessene Keys ausgeschiedener Personen auf. + - Wer eine Person sperrt, behält ihre bisherigen Freigaben im Audit; nur neue + Handlungen sind ausgeschlossen. +3. **Später: SSO** als zweiter Anmeldeweg für **dieselben** Personen, als + gemeinsames Thema mit Munin. Munin hätte auch etwas davon: Im Audit stünde + dann, wer in der Weboberfläche einen Eintrag geändert hat. + +Damit SSO später ohne Umbau andockt, gibt es die Tabelle `people` (→ 15) +**von Stufe 1 an**, mit einem Feld `external_id` für die spätere Kennung aus dem IdP. +Bis Stufe 4 bleibt sie leer. + +#### Anmeldung mit persönlichem Key + +- **Weboberfläche:** Anmeldeseite wie bei Munin. Die Sitzung trägt die Person; alle + Handlungen im Audit nennen sie. +- **CLI:** `hugin login` speichert den persönlichen Key im Schlüsselbund. + `hugin upload` nutzt ihn; `hugin sync` nutzt weiterhin den Projekt-Key aus + `HUGIN_KEY` bzw. dem Schlüsselbund-Eintrag des Projekts. +- **Agenten** bekommen nie einen persönlichen Key. Ein Agent kann über MCP keine + Entwürfe einreichen oder freigeben (→ 12). + +### 14.6 HTTP-API für das CLI + +Schlicht, JSON, versioniert unter `/api/v1/`: + +| Methode und Pfad | Zweck | +|---|---| +| `POST /api/v1/bundle` | Abos auflösen und ausliefern: ZIP mit `hugin-bundle.json` und Dateien, gleiches Format wie 14.7 | +| `GET /api/v1/packages/{id}/versions/{v}` | Version im Quellformat als ZIP (für Autoren und Export) | +| `POST /api/v1/validate` | Paketordner prüfen und übersetzen, ohne zu speichern (`hugin validate`) | +| `POST /api/v1/trial?tools=claude:2.1.302,opencode:1.19.3` | Bundle für einen ungespeicherten Paketordner (`hugin try`) | +| `GET /api/v1/config` | Modellzuordnung, Untergrenzen, Pflichtpakete | +| `POST /api/v1/usage` | Meldung von `sync` | +| `GET /api/v1/search?q=` | Katalogsuche | +| `POST /api/v1/drafts` | Upload eines Entwurfs (Rolle `author`) | + +Authentifizierung über `Authorization: Bearer ` (→ 16.3). Lesende +Endpunkte und `usage` nehmen Projekt-Keys; `drafts` verlangt ab Stufe 4 einen +persönlichen Key (→ 14.5). + + +### 14.7 Auslieferung für Integrationen + +Eine weitere Integration holt Pakete ohne das Go-CLI ab: ein **PowerShell-Skript +unter Windows**. **Windows PowerShell 5.1 ist vorausgesetzt**, PowerShell 7 ist +nicht verfügbar. Umsetzung nach dem CLI (→ 20, Stufe 2). + +#### Präfix und Key + +- Alle Endpunkte unter `/api/v1/delivery/`. +- Eigener **Integrations-Key** (`hi_…`, → 16.3), neben Projekt-Key und persönlichem Key: + gilt für genau einen Tenant, optional auf Projekte eingeschränkt; Rolle + `delivery` (nur lesen, ausliefern, Nutzung melden; kein Hochladen, keine + Freigabe, kein MCP); einzeln widerrufbar, läuft ab; im Audit mit dem Namen der + Integration; mit Rate-Limit. + +#### Endpunkte + +| Methode und Pfad | Zweck | +|---|---| +| `GET /status` | Dienst ok, Hugin-Version, Gültigkeit und Geltung des Keys, Werkzeug-Untergrenzen | +| `GET /packages` | Übersicht der freigegebenen Pakete: Art, aktuelle Version je Kanal, Kompatibilität, Risiko; Filter `kind`, `channel`, `changed_since` | +| `GET /packages/{id}` | Details und Versionen | +| `POST /bundle` | Auslieferung als ZIP (siehe unten) | +| `POST /usage` | Meldung nach der Installation, optional (→ 11.5, Schritt 7) | +| `GET /client.ps1` | Referenz-Skript für die Installation, von Hugin gepflegt | +| `POST /resolve` | Auflösen ohne Übersetzung, für Plattformen (siehe unten) | +| `GET /packages/{id}/versions/{version}/source` | Eine lieferbare Version im Quellformat als ZIP | +| `POST /feedback` | Rückmeldung zu einem Paket, wie `report_feedback` über MCP | + +#### Plattformen als Abnehmer + +Neben Claude Code, OpenCode und dem PowerShell-Skript nutzt eine **Plattform** Hugin, +die Pakete selbst abbildet, statt Dateien für ein Werkzeug zu schreiben: der AI Workspace +Bifröst (Regeln in den System-Prompt, Skills über eigene Tools, Befehle als +Slash-Befehle im Chat). Sie braucht keine übersetzten Bundles, sondern: + +- **`POST /resolve`:** gleiche Anfrage wie `/bundle` ohne `tools` und `options` + (`packages`, `unsubscribed`, `mandatory`, `major`, `installed`). Antwort je Paket: + `id`, `kind`, `version`, `checksum`, `risk`, `channel`, `mandatory`, `deprecated`, + `successor`, `description`, `arguments`; dazu `major_available`. Gleiche Auflösung + wie beim Bundle (→ 9.3), Pflichtpakete eingeschlossen. +- **`GET /packages/{id}/versions/{version}/source`:** `hugin.yaml` und Inhaltsdateien + als ZIP (wie der Download, → 14.6), deterministisch; Kopfzeile `X-Hugin-Checksum` mit + der Prüfsumme der Version. Die Plattform schreibt Version und Prüfsumme fest und + prüft beim Laden ihre gespeicherte Kopie. +- **`POST /feedback`:** `package`, `version`, `kind` (`unclear`, `wrong`, `deprecated`, + `suggestion`), `text`, `client` (z. B. `ai-workspace`). Quelle `agent`, im Tenant des + Keys; der Agent der Plattform hat keinen Projekt-Key und damit kein MCP. +- Nutzung meldet die Plattform wie andere Integrationen über `POST /usage`, mit dem + Client-Namen `ai-workspace/` als `repo`. + +Was die Plattform aus Risiko und Art macht (z. B. keine Skills mit Skripten), entscheidet +sie selbst; Hugin-Inhalte verleihen dort keine Rechte. + +Das Format ist verbindlich in **`contract/`** festgelegt; Server und Clients +testen gegen dieselben Dateien: + +| Datei | Inhalt | +|---|---| +| `contract/bundle-request.schema.json` | Anfrage an `/bundle` | +| `contract/hugin-bundle.schema.json` | `hugin-bundle.json` im ZIP | +| `contract/examples/packages/` | Beispielpakete im Quellformat | +| `contract/examples/bundles/` | goldene Bundles, vom Server erzeugt (`pytest --update`), vom CLI angewendet | + +Anfrage an `/bundle`: + +```json +{ "packages": ["pub-munin-nutzen@stable", "pub-sicherheits-review@^2"], + "tools": { "claude": "2.1.302", "opencode": "1.19.3" }, + "mandatory": true, + "installed": { "pub-munin-nutzen": "1.3.0" } } +``` + +Aus den Werkzeugversionen leitet der Server die Ziele ab (`claude`, `opencode@1`, +`opencode@2`). Aus `installed` berechnet er, was zu entfernen ist. `ETag` und +`If-None-Match` → `304`, wenn sich nichts geändert hat. + +#### Inhalt des ZIP + +``` +hugin-bundle.json # Installationsanleitung +files/.claude/skills/pub-munin-nutzen/SKILL.md +files/… +``` + +Ein Bundle beschreibt den **vollständigen Soll-Zustand** der angefragten Pakete. +Die Operationen werden der Reihe nach angewendet: + +| Operation | Bedeutung | +|---|---| +| `remove` | Datei entfernen, die eine frühere Version angelegt hat | +| `json_remove` | JSON-Schlüssel bzw. Listeneintrag entfernen | +| `file` | Datei aus `files/` schreiben, mit Prüfsumme | +| `json` | JSON-Schlüssel setzen (`mode: set`) oder Eintrag in einer Liste sicherstellen (`mode: list_item`); Schlüssel als Liste, z. B. `["mcpServers", "munin"]` | +| `block` | verwalteten Block setzen; leerer `content` entfernt ihn | + +```json +{ + "format": 1, + "generated": "2026-10-18T12:00:00Z", + "tools": ["claude", "opencode@1"], + "packages": [{ "id": "pub-munin-nutzen", "version": "1.4.0", "checksum": "sha256:…", + "kind": "skill", "risk": "text", "compatibility": "portable", "mandatory": false }], + "operations": [ + { "op": "file", "package": "pub-munin-nutzen", "path": ".claude/skills/pub-munin-nutzen/SKILL.md", + "source": "files/.claude/skills/pub-munin-nutzen/SKILL.md", "sha256": "sha256:…" }, + { "op": "json", "package": "pub-munin-anbindung", "file": ".mcp.json", + "key": ["mcpServers", "munin"], "value": { "…": "…" } }, + { "op": "json", "file": "opencode.json", "key": ["instructions"], + "mode": "list_item", "value": ".hugin/rules/*.md" }, + { "op": "block", "file": "CLAUDE.md", "block": "hugin", + "content": "@.hugin/rules/pub-hausregeln.md\n" } + ], + "hints": [ + { "kind": "env_variable", "name": "MUNIN_KEY", "package": "pub-munin-anbindung" } + ] +} +``` + +`package` fehlt bei gemeinsamen Einträgen, die Hugin für mehrere Pakete pflegt. +`create` (optional bei `json` und `block`) verweist auf eine fertige Datei für +den Fall, dass die Zieldatei noch nicht existiert. + +#### Vorgaben für PowerShell 5.1 + +| Punkt | Vorgabe | +|---|---| +| Encoding | UTF-8 **ohne BOM** schreiben: `[IO.File]::WriteAllText($pfad, $text, (New-Object Text.UTF8Encoding $false))`. Nie `Set-Content`/`Out-File` (schreiben in 5.1 nicht UTF-8 ohne BOM) | +| Zeilenenden | LF wie geliefert, nicht nach CRLF wandeln | +| JSON | `ConvertTo-Json` immer mit `-Depth 20` (sonst still abgeschnitten); formatiert die Datei um und verliert Kommentare. Deshalb `json` nur für Dateien ohne Kommentare, sonst Abbruch mit Hinweis | +| Prüfsummen | `Get-FileHash -Algorithm SHA256` gegen `sha256` im Bundle | +| ZIP | `Expand-Archive` in einen leeren Temp-Ordner, erst nach erfolgreicher Prüfung kopieren | +| TLS | vor dem ersten Aufruf `[Net.ServicePointManager]::SecurityProtocol = 'Tls12'` | +| HTTP | `Invoke-RestMethod`/`Invoke-WebRequest` mit `-UseBasicParsing` | +| Zustand | eigene Lock-Datei wie `hugin.lock` (→ 11.3), damit Handänderungen erkannt und Abbestelltes entfernt werden kann | + +Das Format `hugin-bundle.json` ist versioniert (`format`). Das Go-CLI nutzt +denselben Endpunkt `/bundle`, damit es nur einen Auslieferungsweg gibt. + +--- + +## 15. Datenmodell + +**Eine Datenbank** (SQLite oder PostgreSQL) für alle Tenants. Jede Tabelle mit +Tenant-Bezug hat eine Spalte `tenant_id`. Öffentliche Pakete haben `tenant_id = NULL`. +Bei Einbettung in ein anderes Projekt tragen alle Tabellen das Präfix `hugin_` +(→ 14.3); unten ist es weggelassen. + +### Plattform + +| Tabelle | Wichtige Spalten | +|---|---| +| `tenants` | `id`, `slug` (kurz, Präfix für Paket-IDs), `name`, `locked_since`, `created` | +| `projects` | `id`, `tenant_id`, `slug`, `name`, `created` | +| `keys` | `id`, `kind` (`project` / `integration` / `tenant` / `person`), `tenant_id`, `project_id` (bei `project`), `person_id` (bei `person`), `name`, `roles`, `projects` (Einschränkung bei `integration`, leer = alle), `key_hash`, `prefix` (erste Zeichen zur Anzeige), `expires_at`, `last_used`, `revoked_since`, `created`, `created_by` | +| `settings` | `key`, `value` (JSON): Modellzuordnung, Untergrenzen der Werkzeuge | +| `sessions` | `id` (SHA-256 des Cookie-Tokens), `key_id` (leer = Plattform-Admin), `csrf`, `created`, `expires_at`, `revoked_since`, `ip`, `admin_fingerprint` (Admin-Sitzung endet, wenn `HUGIN_ADMIN_TOKEN` wechselt) — Sitzungen der Weboberfläche, serverseitig widerrufbar (→ 16.3) | +| `people` | `id`, `tenant_id`, `name`, `mail`, `external_id` (leer bis SSO), `locked_since`, `created`, `created_by` | +| `people_roles` | `person_id`, `role` (`author`, `reviewer`, `tenant_admin`), `tenant_id`, `project_id` (leer = ganzer Tenant) | + +`people` und `people_roles` werden von Stufe 1 an angelegt, aber erst ab +Stufe 4 genutzt (→ 14.5). Persönliche Keys stehen in `keys` mit `kind = person`. + +### Pakete + +| Tabelle | Wichtige Spalten | +|---|---| +| `packages` | `id` (Text, z. B. `pub-munin-nutzen`), `tenant_id` (NULL = öffentlich), `kind`, `scope`, `owner_project`, `deprecated_since`, `successor`, `created` | +| `mandatory_packages` | `package_id`, `tenant_id` (NULL = in allen Tenants), `set_by`, `time` | +| `versions` | `package_id`, `version`, `status`, `channel`, `manifest` (JSON), `checksum`, `risk`, `compatibility`, `min_version_claude`, `min_version_opencode`, `linter` (JSON), `author_person`, `author_key`, `created`, `submitted`, `approved` | +| `contents` | `checksum` (Schlüssel), `content` (BLOB), `size` | +| `files` | `version_id`, `path`, `checksum` → `contents` | +| `approvals` | `version_id`, `reviewer_person`, `reviewer_key`, `decision` (`approve` / `return`), `comment`, `time` | + +`contents` speichert jeden Dateiinhalt nur einmal; unveränderte Hilfsdateien kosten +bei einer neuen Version keinen Platz. + +`…_person` ist bis Stufe 4 leer, dann steht dort nur der Key. Ab Stufe 4 ist +`…_person` Pflicht; die Regeln „Prüfer ≠ Autor“ und „zwei verschiedene Personen“ +werden über diese Spalten geprüft. + +Paket-IDs sind über alle Tenants eindeutig. Das ergibt sich aus dem Präfix (→ 10.4) +und wird zusätzlich per Unique-Index erzwungen. + +### Rückkanal und Audit + +| Tabelle | Wichtige Spalten | +|---|---| +| `usage` | `tenant_id`, `project_id`, `key_id`, `repo` (Kennung aus Git-Remote, Pfad oder Client-Name), `package_id`, `version`, `tools` (JSON: Werkzeug → Version), `last` | +| `feedbacks` | `tenant_id`, `package_id`, `version`, `source` (`agent` / `human`), `tool`, `kind`, `text`, `status`, `time` | +| `audit_events` | `id`, `time`, `tenant_id` (NULL = Plattform), `actor_kind` (`person` / `key` / `admin`), `actor_id`, `action` (z. B. `version.approved`), `target_kind`, `target_id`, `data` (JSON), `ip` | + +Rückmeldungen zu einem öffentlichen Paket tragen den Tenant der Meldenden. Der +Besitzer des Pakets sieht den Text, aber nicht, aus welchem Tenant er kommt; +der Plattform-Admin sieht beides. + +Versionen und Dateien werden nie geändert oder gelöscht, nur ihr Status wechselt. +Ausnahme: Entwürfe, die nie eingereicht wurden. + +### 15.1 Trennung der Tenants + +Mit einer gemeinsamen Datenbank hängt die Trennung allein am Code. Deshalb: + +- **Ein Zugriffsweg:** Routen greifen nie direkt auf Tabellen zu, sondern über die + Store-Schicht. Jede Funktion dort verlangt einen `Access` (Tenant, Projekt, + Rollen) und filtert selbst: `tenant_id = :tenant OR tenant_id IS NULL` für Pakete, + `tenant_id = :tenant` für alles andere. +- **Der Zugriff kommt aus dem Key oder der Sitzung**, nie aus einem Parameter der + Anfrage. Ein Tenant-Slug in der URL dient nur der Anzeige und wird gegen den + Zugriff geprüft. +- **Fremde Objekte gibt es nicht:** Eine ID aus einem anderen Tenant liefert `404`, + nicht `403`, damit sich nicht erraten lässt, was existiert. +- **Öffentliche Pakete** sind für Tenants nur lesbar. Ändern, Freigeben und + Pflicht setzen darf nur, wer öffentliche Pakete pflegt (→ 10.1, 14.4). +- **Optional in PostgreSQL:** Row-Level-Security als zweite Sicherung; die + Anwendung setzt dazu je Anfrage `SET LOCAL hugin.tenant_id`. +- **Test:** Eine Testsuite legt zwei Tenants mit gleich benannten Inhalten an und + ruft jeden Endpunkt (Web, API, `/bundle`, MCP, Export) mit dem Key des einen + auf. Nichts aus dem anderen darf auftauchen. Neue Endpunkte werden automatisch + erfasst (Routen aus der FastAPI-App gelesen); ein Endpunkt ohne Testfall lässt + den Test fehlschlagen. + +--- + +## 16. Sicherheit + +Ein Paket ist **Lieferkette**: Wer ein weit verbreitetes Paket ändern kann, steuert +viele Agenten in vielen Repositories. + +### 16.1 Risiko-Einstufung (automatisch) + +| Risiko | Wann | +|---|---| +| `text` | nur Anweisungen, keine Skripte, keine Rechte | +| `tools` | verweist auf MCP-Werkzeuge oder enthält `` !`befehl` `` in Befehlen | +| `scripts` | enthält ausführbare Dateien oder ist ein Hook | +| `permissions` | gewährt Rechte: `allowed-tools`, `permission` mit `allow`, Fähigkeitsklasse `shell` oder `write` | + +Das höchste zutreffende Risiko gilt. Es bestimmt die Zahl der Freigaben (→ 9.2). + +### 16.2 Maßnahmen + +- **Prüfsummen** auf Version und Datei; `sync` lehnt bei Abweichung ab. +- **Unveränderliche Versionen.** Was freigegeben ist, kann nicht still ersetzt werden. +- **Audit** aller Statuswechsel, Freigaben, Pflicht-Markierungen und Konfigurationsänderungen. +- **Keine Geheimnisse** in Paketen (Linter `H031`, `H032`). +- **Skripte** werden in der Weboberfläche vollständig angezeigt, nie nur verlinkt. +- **Nur verwaltete Dateien:** `sync` schreibt nur unter den bekannten Pfaden und + nur Schlüssel aus `hugin.lock`. Pfade im Paket werden gegen „aus dem Repo + ausbrechen“ geprüft (`..`, absolute Pfade, Symlinks und unter Windows Junctions, + nichts unter `.git/`). Dateinamen, die nicht auf allen Systemen gehen (Punkt oder + Leerzeichen am Ende, `<>:"|?*`, `CON`, `NUL` …), lehnt schon der Upload ab. +- **Uploads begrenzt:** Request-Bodies bis `HUGIN_MAX_UPLOAD_MB` (Standard 40), + geprüft vor der Anmeldung; Pakete bis 32 MB entpackt, 4 MB je Datei, 500 Dateien. +- **Key nur an den eigenen Server:** Ist `HUGIN_SERVER` gesetzt, sendet das CLI einen + Key aus `HUGIN_KEY` nur an diesen Server, auch wenn ein Pull Request `server:` in + `.hugin.yaml` ändert. In CI daher beide Variablen setzen. Im Schlüsselbund ist der + Key ohnehin an die Adresse gebunden. +- **Prompt-Injection im Inhalt:** Prüfer sehen den Text, wie der Agent ihn sieht. + Hinweis im Linter bei auffälligen Mustern, z. B. „ignoriere vorherige Anweisungen“ + oder versteckte Unicode-Zeichen. +- **Später:** Signatur der Versionen, damit das CLI auch einem kompromittierten + Server nicht blind vertraut. + +### 16.3 Keys, Anmeldung, Sitzungen + +| Art | Präfix | Gehört zu | Zweck | +|---|---|---|---| +| Projekt-Key | `hk_` | Projekt eines Tenants | CLI (`sync`, `check`), Agenten über MCP | +| Tenant-Key | `ht_` | Tenant | Verwaltung des Tenants (Rolle `tenant_admin`) bis Stufe 4; vergibt nur der Plattform-Admin; danach durch Personen ersetzt | +| Integrations-Key | `hi_` | Tenant, optional auf Projekte eingeschränkt | Auslieferung an Integrationen (→ 14.7), **nur** unter `/api/v1/delivery/`; übrige API, MCP und Weboberfläche lehnen ihn ab | +| Persönlicher Key | `hp_` | Person (ab Stufe 4) | Web und CLI für Autoren, Prüfer, Admins | +| Admin-Token | – | Plattform | Umgebungsvariable `HUGIN_ADMIN_TOKEN`; nur für die Plattform-Verwaltung | + +- **Format:** Präfix + 32 Byte Zufall, Base32 ohne Füllzeichen. Der Key wird nur + einmal beim Anlegen angezeigt. +- **Speicherung:** nur SHA-256 des Keys (Keys sind lang und zufällig, ein + langsamer Hash ist nicht nötig) plus die ersten acht Zeichen zur Anzeige. + Vergleich in konstanter Zeit. +- **Übertragung:** `Authorization: Bearer `, nie in der URL. +- **Ablauf:** persönliche Keys 180 Tage, Integrations-Keys 365 Tage, Projekt-Keys + ohne Ablauf, aber mit Hinweis im Überblick nach 90 Tagen ohne Nutzung. +- **Weboberfläche:** Anmeldung mit persönlichem Key (bis Stufe 4: mit Projekt-Key + der Rolle `author` oder `reviewer`). Danach eine Sitzung als signiertes, + `HttpOnly`-, `Secure`-, `SameSite=Lax`-Cookie, 8 Stunden gültig, serverseitig + widerrufbar. +- **CSRF:** Token je Sitzung in jedem Formular und als Header `X-CSRF-Token` für + HTMX-Anfragen (über `hx-headers` im Basis-Template). Die API mit Bearer-Key + braucht kein CSRF-Token. +- **Rate-Limit** je Key bzw. Sitzung (`HUGIN_RATE_LIMIT`, Standard 600 je Minute); nach + `HUGIN_FAILED_ATTEMPTS` falschen Keys (Standard 20 in 5 Minuten) antwortet Hugin der IP + mit 429, für API, Anmeldung und MCP. Gezählt wird je Server-Prozess; bei mehreren + Pods gilt die Grenze je Pod. +- **Client-IP hinter dem Router:** `X-Forwarded-For` gilt nur von Adressen in + `HUGIN_PROXY_IPS` (Standard `127.0.0.1`). In OpenShift auf das Netz des Routers + setzen, z. B. `10.128.0.0/14`; sonst sehen Sperre und Audit nur die IP des Routers, + und 20 falsche Keys sperren alle. `*` nur, wenn der Pod ausschließlich über den + Router erreichbar ist, denn sonst kann jeder seine IP vorgeben. +- **Admin-Sitzungen** sind an das Admin-Token gebunden: Nach einem Wechsel von + `HUGIN_ADMIN_TOKEN` gelten bestehende Sitzungen sofort nicht mehr. +- **Nur verschlüsselt:** Das CLI sendet Keys nur über `https://` oder an den eigenen + Rechner, auch bei Weiterleitungen; zum Testen `HUGIN_ALLOW_HTTP=1`. Der + PowerShell-Client warnt bei `http://`. +- **Kein Browser-Cache:** Antworten außer `/static/` mit `Cache-Control: no-store` + (neue Keys erscheinen einmal auf einer Seite). + +### 16.4 Content Security Policy + +Strenge CSP ohne `'unsafe-inline'` und ohne `'unsafe-eval'`, gesetzt per Middleware +für jede Antwort der Weboberfläche: + +``` +default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; +connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; +object-src 'none' +``` + +Nonces reichen nicht, weil sie bei `style`-Attributen nicht helfen. Deshalb wird +das CSS-Framework CSP-fest gemacht. Das kommt allen Projekten zugute, die es nutzen. + +| Heute im Framework | Künftig | +|---|---| +| `onclick="toggleSidebar()"` usw. | `data-action="toggle-sidebar"` (+ `data-target`); ein gemeinsamer Listener in `framework.js` per Event-Delegation, funktioniert auch für Inhalte, die HTMX nachlädt | +| Inline-Skript für das Theme im `` | eigene Datei `theme-init.js`, im `` ohne `defer` geladen (kein Aufflackern) | +| Toast: `innerHTML` mit `style="color: …"` | Aufbau per DOM, Farbe über Klasse `toast-icon-{type}`, Text über `textContent` (behebt zugleich eine XSS-Lücke) | +| `el.style.…` für Animationen | unter CSP erlaubt, sauberer als Klasse (`.toast-leaving`) | +| `style="…"` in Beispielen und Templates | Utility-Klassen (z. B. `min-w-0`, Größen für Skeletons) | + +HTMX wird so konfiguriert: + +```html + +``` + +Die Styles für `.htmx-indicator` kommen aus dem Framework. `hx-on` wird nicht +verwendet (braucht `eval`). + +--- + +## 17. Kompatibilitätstests + +Beide Werkzeuge lassen sich ohne Oberfläche starten (`claude -p …`, `opencode run …`). +Zwei Ebenen, beide als manuelle Routine (es gibt keine CI): + +### 17.1 Übersetzer-Tests (pytest, schnell, immer) + +Tabellentests je Art und Ziel: Manifest + Inhalt → erwartete Dateien. +Goldene Bundles unter `contract/examples/bundles/`, erzeugt aus den Beispielpaketen in +`contract/examples/packages/`. Teil von `pytest`; die CLI-Tests wenden dieselben Bundles an. + +### 17.2 Rauchtests mit den echten Werkzeugen (vor Releases) + +Das Skript `scripts/tool-smoke-test.sh` startet einen Hugin, veröffentlicht Testpakete +mit Erkennungswörtern (`scripts/tool-smoke/packages/`), installiert sie per `hugin sync` +in ein leeres Test-Repo und fragt beide Werkzeuge. `hugin sync` statt `hugin try`, weil +`try` nur ein Paket auf einmal installiert. Die Modellaufrufe laufen ohne Lesewerkzeuge, +damit die Antwort aus dem geladenen Kontext kommt und nicht aus den Dateien im Repo: + +| Prüfung | Wie | +|---|---| +| Skill wird gefunden | Werkzeug nach der Liste seiner Skills fragen, Name muss vorkommen | +| Skill wird geladen | Beispiel-Anfrage aus dem Paket, Ausgabe auf Erkennungswort prüfen | +| Befehl mit Argumenten | Befehl mit zwei Argumenten aufrufen, beide müssen richtig ankommen | +| Subagent vorhanden | Werkzeug nach seinen Agenten fragen | +| Regeln wirken | Frage, deren Antwort nur in den Regeln steht | +| MCP eingetragen | `claude mcp get`, `opencode mcp list` | +| MCP verbunden | Claude: Werkzeug `search_packages` des Test-Hugin aufrufen; OpenCode: Status `connected` | + +Das Ergebnis ist eine Tabelle Paket × Werkzeug × Version. Es landet als Markdown in +`COMPATIBILITY.md` und wird vor jedem Hugin-Release und nach jedem größeren +Update eines Werkzeugs neu erzeugt. + +Die Rauchtests brauchen Zugänge zu beiden Werkzeugen und kosten Tokens. Sie +gehören deshalb nicht in `pytest` oder `go test`. + +--- + +## 18. Betrieb + +**Plattform: OpenShift.** Deployment, Ingress, Datenbank und Sicherung der +Datenbank verantwortet der Betrieb; sie sind nicht Teil dieses Konzepts. Hugin +liefert dafür: + +- ein Container-Image ohne Zustand im Dateisystem (alles in der Datenbank), + lauffähig als beliebiger Benutzer ohne Root (OpenShift-Standard), +- Konfiguration nur über Umgebungsvariablen (`HUGIN_DATABASE_URL`, + `HUGIN_ADMIN_TOKEN`, `HUGIN_BASE_URL`, `HUGIN_DOWNLOAD_DIR` …), +- Health-Endpunkte `/healthz` (lebt) und `/readyz` (Datenbank erreichbar, + Migrationen aktuell) für die Probes, +- Migrationen als eigener Schritt (`hugin-server migrate`), z. B. als Job vor dem + Rollout, nicht beim Start jedes Pods, +- Logs als JSON auf stdout. + +Ergänzend: + +- **Sicherung:** Die Dateien liegen als BLOB in der Datenbank; deren Sicherung + (Betrieb) ist also vollständig. +- **Export:** `hugin-server export [--commit]`, z. B. als CronJob in einen + Git-Ordner; auch als ZIP auf der Systemseite. +- **Export:** täglich alle freigegebenen Versionen im Quellformat + (`public///…` bzw. `///…`). Ein Git-Repository darauf ergibt eine + lesbare Historie und eine Notfall-Verteilung: Im Notfall kann man Pakete von + Hand aus dem Export kopieren. +- **Systemseite:** zusätzlich offene Prüfungen, Untergrenzen der Werkzeuge, + Verteilung der Werkzeugversionen über alle Projekte (wer ist zu alt?). +- **Ausfall:** Fällt Hugin aus, laufen alle Agenten weiter, denn die Dateien liegen + im Repo. Nur `sync` geht nicht. Hugin ist deshalb **nicht** kritisch im Betrieb, + anders als Munin. +- **Versionen des CLI:** `scripts/build-cli.sh ` baut für + macOS, Linux und Windows; der Server bietet die neueste Version auf der Seite + „Programm hugin“ an (`HUGIN_DOWNLOAD_DIR`). `hugin check` meldet ein CLI älter als + `HUGIN_CLI_MIN_VERSION` als Befund und weist auf neuere Versionen hin. + +--- + +## 19. Zusammenspiel mit Munin + +- **Erstes Paket:** `pub-munin-nutzen` (Skill), dazu `pub-munin-anbindung` (MCP), + beide als Pflichtpakete. Damit nutzen alle Agenten Munin einheitlich und richtig. + Das ist der direkteste Hebel auf die Wissensqualität in Munin. +- **Gleiche Tenants und Projekte:** Ein Hugin-Tenant entspricht einem Munin-Workspace, mit denselben Slugs. In + der ersten Fassung werden sie getrennt gepflegt. Ein gemeinsamer Identitäts- und + Key-Dienst ist ein eigenes, späteres Thema. +- **Rückmeldungen** zu `pub-munin-nutzen` zeigen, wo Agenten Munin falsch + verstehen. Daraus entstehen Verbesserungen an Munin selbst (Werkzeugbeschreibungen, Hilfe). +- **Abgrenzung im Alltag:** + + | Gehört nach Munin | Gehört nach Hugin | + |---|---| + | „Die Staging-DB wird jeden Sonntag zurückgesetzt.“ | „Bevor du eine Migration schreibst, prüfe in Munin, ob es Besonderheiten der DB gibt.“ | + | Fakten, Erkenntnisse, Entscheidungen | Arbeitsweisen, Abläufe, Rollen, Werkzeug-Anbindungen | + +--- + +## 20. Ausbaustufen + +Jede Stufe endet mit etwas Nutzbarem und einer Abnahme. + +**Umsetzungsstand (18.10.2026):** + +| Stufe | Stand | +|---|---| +| 0 Machbarkeit | für OpenCode 1 geklärt (STUFE0.md); offen: Untergrenze für benannte Argumente, OpenCode 2 | +| 1 Kern: Skills und Regeln | umgesetzt: Server, Weboberfläche, CLI (`sync`, `check`, `status`) | +| 2 Befehle, Subagenten, MCP | umgesetzt, inkl. Autoren-Befehle im CLI (`new`, `validate`, `try`, `upload`) | +| 2b Auslieferung für Integrationen | umgesetzt, inkl. `hugin-install.ps1`; mit PowerShell 7 getestet, mit 5.1 noch nicht; für Plattformen (AI Workspace) zusätzlich `resolve`, `source`, `feedback` (25.10.2026) | +| 3 Rückkanal | umgesetzt: Nutzung, Rückmeldungen, MCP-Endpunkt, Überblick, Systemseite | +| 4 Absicherung | umgesetzt: Personen, persönliche Keys, vier Augen nach Risiko, Kanal `test`, Audit mit Namen, Rauchtest-Skript, `COMPATIBILITY.md` (alle Prüfungen grün in Claude Code 2.1.303 und OpenCode 1.19.3) | +| 5 Lokale Server, Plugins, Werkzeugspezifisches | 5b und 5d (ohne npm-Abhängigkeiten) umgesetzt (25.10.2026), Windows von Hand offen; geplant: 5a Windows-Prüfung, 5c fertige Programme; danach 5e Hooks, Paare, Signaturen | + +### Stufe 0 — Machbarkeit prüfen (vor dem ersten Code) + +Offene Verhaltensfragen mit den aktuellen Versionen beider Werkzeuge klären. Dazu +genügen ein Test-Repo und Handarbeit. + +1. Ignoriert OpenCode unbekannte Felder in der Frontmatter einer `SKILL.md` + (z. B. `allowed-tools`), oder warnt es bzw. lehnt es ab? +2. Was tut OpenCode, wenn derselbe Skill-Name in `.claude/skills/` und + `.opencode/skills/` liegt? +3. Liest OpenCode `.claude/commands/` oder `.claude/agents/` mit? +4. Löst OpenCode `@`-Verweise in einer `CLAUDE.md` auf, wenn es keine `AGENTS.md` gibt? +5. Claude Code liest `AGENTS.md` ab 2.1.277, wenn es keine `CLAUDE.md` gibt + (bestätigt). Offen: Verhalten, wenn beide da sind. +6. Erlaubt `opencode.json` Kommentare (JSONC)? +7. Genaue Werkzeugnamen (Claude) und Rechtenamen (OpenCode) für die Fähigkeitsklassen. +8. Untergrenzen sind ermittelt (→ 5.5). Offen: ab wann Claude Code benannte + Argumente (`arguments`, `$name`) kennt und ab wann OpenCode die `metadata`-Schlüssel + `opencode/slash` und `opencode/autoinvoke` versteht. +9. Unterstützt OpenCode MCP-Prompts und -Ressourcen? (Nur zur Information; Hugin baut nicht darauf.) +10. **OpenCode 2:** Liest es MCP-Einträge in der alten Form (`mcp.`, `enabled`)? + Kennt es `opencode/slash`? Ignoriert es unbekannte Felder in der Frontmatter + von Skills? Gibt es Doppel, wenn ein Skill in `.claude/skills/` und + `.opencode/skills/` liegt (die ID kommt jetzt aus dem Pfad)? + +**Stand:** Fragen 1, 2, 3, 6, 7 und MCP sind für OpenCode 1 geklärt, ohne Modellaufruf +(`scripts/stage0.sh`); 4, 5 und 8 (Claude Code) mit `scripts/tool-smoke-test.sh`. +`opencode/slash` und `opencode/autoinvoke` wirken in OpenCode 1.19.3 nicht (`stage0.sh` +und `tool-smoke-test.sh`). Offen: ab welcher Version Claude Code benannte Argumente kennt, +und alles zu OpenCode 2 (Frage 10). + +**Ergebnis:** `STUFE0.md` mit Antworten, Versionen und den nötigen Änderungen an +diesem Konzept. + +### Stufe 1 — Kern: Skills und Regeln + +- Projektgerüst (`server/` mit uv, `cli/` mit Go); Speicher, Auth, Audit, Web und Hilfe nach dem Vorbild von Munin neu bauen. +- CSS-Framework CSP-fest machen (→ 21.2). +- Quellformat für `skill` und `rules`, Manifest-Parser. +- Linter (Regeln für diese Arten). +- Übersetzer für Skills und Regeln, beide Ziele, mit Tabellentests. +- Lebenszyklus mit einer Freigabe, noch ohne Personen (→ 14.5): Autor- und + Prüfer-Key getrennt; nur Pakete mit Risiko `text` und `tools`. +- Tabelle `people` schon anlegen, aber noch nicht nutzen. +- Weboberfläche: Katalog, Paket, Version mit Diff und Vorschau je Werkzeug, Prüfungen. +- CLI: `init`, `sync`, `status`, `validate`, `upload`. +- HTTP-API. +- Erstes Paket: `pub-munin-nutzen`. + +**Abnahme:** Ein Test-Repo bekommt per `hugin sync` einen Skill und eine Regeldatei; +beide funktionieren nachweislich in Claude Code und OpenCode. + +### Stufe 2 — Übersetzung: Befehle, Subagenten, MCP + +- Befehle mit benannten Argumenten. +- Subagenten mit Fähigkeits- und Modellklassen; Modellzuordnung auf der Systemseite. +- MCP-Anbindungen; Bearbeiten von JSON-Dateien mit verwalteten Schlüsseln. +- `pub-munin-anbindung` als Paket. + +**Abnahme:** Rauchtests (→ 17.2) für alle Arten grün in beiden Werkzeugen. + +### Stufe 2b — Auslieferung für Integrationen + +- Endpunkte unter `/api/v1/delivery/`, Integrations-Key, Bundle-Format (→ 14.7). +- Referenz-Skript `client.ps1` für Windows PowerShell 5.1. +- Für Plattformen: Auflösen ohne Übersetzung, Quellformat je Version, Rückmeldung (→ 14.7). + +### Stufe 3 — Rückkanal + +- Nutzungsmeldung, Seite „Mein Projekt“, Überblick für Tenant-Admins. +- MCP-Server (`search_packages`, `get_package`, `report_feedback`, `list_subscriptions`). +- `hugin check` mit Handänderungen, neuen Versionen, Werkzeugversionen. +- Untergrenzen der Werkzeuge. + +### Stufe 4 — Absicherung + +- Persönliche Keys (→ 14.5): Personen anlegen und sperren, Rollen je Projekt oder + Tenant, Anmeldung in Web und CLI, Ablauf und Hinweise im Überblick. +- Vier-Augen-Prinzip nach Risiko; ab jetzt auch Pakete mit `scripts` und `permissions`. +- Audit mit Namen. +- Kanäle `stable` und `test`, Versionsbereiche, `--major`. +- Pflichtpakete. +- `hugin try`, Rauchtest-Skript, `COMPATIBILITY.md`. +- Export, Sicherung, Systemseite vollständig. + +### Stufe 5 — Lokale Server, Plugins, Werkzeugspezifisches + +Anforderung: MCP-Server nicht nur anbinden, sondern **samt Code verteilen** und lokal +betreiben, dazu OpenCode-Plugins. Zielplattformen der Clients sind **Windows und +Linux**; entwickelt wird auf macOS. Windows wird von Hand geprüft (kein Windows-CI). + +Geprüft mit OpenCode 1.19.4 (25.10.2026, ohne Modellaufruf): + +- Lokaler MCP-Server mit `"type": "local", "command": ["hugin", "mcp", "run", ""]`: + OpenCode findet `hugin` über den `PATH` und löst `{env:NAME}` in `environment` auf. + Arbeitsordner ist der aktuelle Ordner, auch ein Unterordner des Repos. +- Plugins: `.ts`-Dateien unter `.opencode/plugins/` (auch `.opencode/plugin/`) werden + geladen. OpenCode legt beim Start selbst `.opencode/package.json` und `node_modules` an. + +#### 5a Windows als Grundlage + +- Alle Pfade im Repo mit `/` im Bundle; das CLI wandelt beim Schreiben. Prüfen unter + Windows: `instructions` in `opencode.json`, Zeilenenden, Pfade über 260 Zeichen + (venv unter dem Benutzerordner). +- `scripts/windows-test.ps1` und eine Checkliste in `TESTEN.md` für die Prüfung von Hand: + Go-Tests, `hugin sync` mit allen Arten, echtes OpenCode (`debug skill`, `agent list`, + `mcp list`), lokaler Server startet. +- `hugin-install.ps1` lernt alles, was `hugin sync` neu lernt. + +#### 5b Lokale MCP-Server mit Python + +- Manifest: `server.local` mit `runtime: python`, `entry` (Modul oder Skript) und + `env` (Namen der Umgebungsvariablen). Im Paket `requirements.txt` mit Hashes und der + Code. Risiko mindestens `scripts`. +- Ablage außerhalb des Repos: `~/.local/share/mcp///` (Windows: + `%USERPROFILE%\.local\share\mcp\…`), änderbar mit `HUGIN_MCP_HOME`. Jede Version in + einem eigenen Ordner; mehrere Repos teilen sie. +- `hugin sync` (und `hugin try`) legt dort eine venv an (`python -m venv`; Python aus + `HUGIN_PYTHON`, unter Windows `py -3`, sonst das neueste `python3.x`, mindestens + `local.python`) und + installiert mit `pip install --require-hashes -r requirements.txt`. pip liest die + `pip.conf` (Windows `pip.ini`) mit dem Artifactory selbst; ohne Anmeldung. +- In `opencode.json` bzw. `.mcp.json` nur `hugin mcp run `, nie ein Pfad: gleich auf + jedem Rechner, einzuchecken. `hugin mcp run` sucht das Repo nach oben, liest die + Version aus `hugin.lock` und startet den Server aus der venv; Ein- und Ausgabe gehen + durch (Windows: Kindprozess statt `exec`). +- `hugin check` meldet eine fehlende Installation; `hugin mcp prune` räumt Versionen + auf, die kein Repo mehr nutzt (Repos merkt sich `hugin` in `.hugin-repos` der Ablage; + Ordner ohne `.hugin-install.json` bleiben unberührt). +- Geprüft (macOS, OpenCode 1.19.4): Beispiel ohne Abhängigkeiten und ein Server mit dem + Python-SDK `mcp` (30 Abhängigkeiten über pip mit Hashes) verbinden; Aufruf aus einem + Unterordner bekommt die Umgebungsvariablen. + +#### 5c Fertige Programme + +- Manifest: `runtime: binary` mit `artifacts` je Plattform (`windows-amd64`, + `linux-amd64`, optional `darwin-arm64` zum Ausprobieren): `url` und `sha256`. Die + Dateien bleiben im Artifactory, nicht in Hugin. +- `hugin sync` lädt nur die passende Datei, prüft die Prüfsumme (sonst Abbruch) und + legt sie in den Ordner aus 5b, unter Windows als `.exe`. +- Linter: Fehler bei fehlender oder falscher Prüfsumme, Hinweis, wenn Windows oder + Linux fehlt. Prüfer sehen Adressen und Prüfsummen. + +#### 5d OpenCode-Plugins + +Umgesetzt (25.10.2026), zunächst **ohne npm-Abhängigkeiten**: + +- Art `plugin`, nur OpenCode: `targets: [opencode]` ist Pflicht (H038). Inhaltsdatei + `index.ts`, dazu weitere `.ts`/`.js`-Dateien. +- Im Repo: das Paket unter `.opencode/plugins//`, dazu die Startdatei + `.opencode/plugins/.ts` mit `export * from ".//index.ts"`. So bleiben die + relativen Importe des Pakets gültig, und Hilfsdateien gelten nicht als eigene Plugins. +- Importe nur aus dem Paket, `node:…`, `bun` und `@opencode-ai/plugin` (H039); das + letzte trägt OpenCode selbst in `.opencode/package.json` ein. +- Risiko `permissions`: Ein Plugin läuft in OpenCode mit allen Rechten und sieht jeden + Werkzeugaufruf; zwei Freigaben, eine vom Tenant-Admin. + +Geprüft mit OpenCode 1.19.4: + +- Geladen werden nur Dateien direkt in `.opencode/plugins/` (und `.opencode/plugin/`), + keine Unterordner. Jede exportierte Funktion der geladenen Datei wird aufgerufen, auch + über `export *`; importierte Hilfsdateien nicht. +- `opencode debug config` lädt die Plugins und listet sie unter `plugin`; `--pure` + lädt keine. `ctx.worktree` ist der Wurzelordner des Repos, auch aus einem Unterordner. +- `.opencode/package.json`, `node_modules` und `.opencode/.gitignore` (schließt beides + aus) legt OpenCode selbst an, sobald es im Repo läuft. +- Ein Plugin mit Syntaxfehler übergeht OpenCode ohne Meldung, auch mit `--print-logs`. +- Werkzeug aus einem Plugin (`tool()` aus `@opencode-ai/plugin`) ruft das Modell auf. + +Später: + +- npm-Abhängigkeiten als verwaltete Schlüssel in `.opencode/package.json` (aus dem + Artifactory, wenn npm so eingestellt ist). Achtung: `.opencode/.gitignore` von OpenCode + schließt `package.json` aus; feste Versionen ohne `^`, Konflikte zwischen Paketen. +- OpenCode 2 führt V1-Plugins nicht aus (→ 5.5): eigene Fassung nötig, sobald Teams + umsteigen. + +#### 5e Danach + +- Hook-Pakete (Claude Code), Paare. +- Signaturen. +- Optional: native Verteilwege (Claude-Plugin-Marktplatz, OpenCode über npm oder + `instructions`-URL), falls Teams das wollen. + +--- + +## 21. Entscheidungen und offene Fragen + +### 21.1 Getroffen + +| Entscheidung | Begründung | +|---|---| +| Name **Hugin** | Gegenstück zu Munin: Gedanke und Gedächtnis | +| Nur **Claude Code und OpenCode**, beide voll | Das sind die Werkzeuge im Haus; andere sind nicht nötig | +| **Neutrales Quellformat mit Übersetzern** statt „ein Werkzeug ist Standard“ | Claude Code liest keine OpenCode-Ordner; OpenCode liest Claude-Ordner. Nur Übersetzer decken beide sauber ab (→ 5.1) | +| **Strengere Regel gewinnt**, Skills liegen in `.claude/skills/` | eine Datei für beide, keine Doppel | +| **Server in Python/FastAPI mit Jinja2, HTMX und CSS-Framework; CLI in Go** | später in ein anderes FastAPI-Projekt einbettbar; CLI bleibt eine Binärdatei ohne Abhängigkeiten | +| **Konzepte wie Munin**: SQLite/PostgreSQL, Tenants, Keys, Audit, MCP | ein Betriebsmodell, bekannte Bedienung | +| **Übersetzer nur im Server**, CLI wendet Operationen an | keine doppelte Umsetzung in zwei Sprachen (→ 14.2) | +| **Auslieferung für Integrationen** über `/api/v1/delivery/` mit eigenem Key, ZIP + `hugin-bundle.json`; Client in **PowerShell 5.1** | Vorgabe der Integration; ein Bundle-Format für CLI und Skript (→ 14.7) | +| **Datenbank führend**, Export nach Git als Sicherung | Prüfung und Freigabe in der eigenen Weboberfläche; kein Git-Server mit Review nötig | +| **Abos im Repository**, nicht auf dem Server | versioniert, im Code-Review sichtbar | +| **Verteilung über `hugin sync`**, MCP nur für Suche und Rückmeldung | nur installierte Skills nutzen die eingebaute Skill-Logik beider Werkzeuge | +| **Maschinen mit Projekt-Keys, Menschen in der Freigabekette als Personen** | Vier Augen und Verantwortung im Audit brauchen Namen; `sync` und Agenten nicht (→ 14.5) | +| **Personen über persönliche Keys ab Stufe 4, SSO später gemeinsam mit Munin** | keine neue Infrastruktur nötig; Tabelle `people` mit `external_id` hält den Weg zu SSO offen | +| **Bis Stufe 4 nur Pakete mit Risiko `text` und `tools`** | ohne echte vier Augen keine Skripte und keine Rechte verteilen | +| **Zwei Ebenen: öffentlich und Tenants**; Tenants sehen nur Öffentliches und Eigenes | klare Trennung ohne Freigabelisten je Paket (→ 10.1) | +| **Eine Datenbank** mit `tenant_id` statt einer Datenbank je Tenant | einfacher mit SQLAlchemy, einbettbar in andere Projekte; Trennung zentral im Code (→ 15.1) | +| **Strenge CSP** ohne `'unsafe-inline'`; das CSS-Framework wird CSP-fest gemacht | Nonces helfen nicht bei `style`-Attributen; das Framework profitiert auch in anderen Projekten (→ 16.4) | +| **Keine persönlichen Pakete**, nur öffentlich oder je Tenant/Projekt | weniger Pfade, keine Nutzerordner (→ 3) | +| **Keine Volltextsuche** in Stufe 1, `LIKE` über ID und Beschreibung | Katalog bleibt klein; im Store nachrüstbar (→ 13) | +| **Untergrenzen vorläufig: Claude Code 2.1.105, OpenCode 1.2.11** | aus Changelogs ermittelt; Stufe 0 hat nur aktuelle Versionen geprüft (Claude Code 2.1.303, OpenCode 1.15.4 und 1.19.3). Endgültig erst nach einem Rauchtest mit den Untergrenzen selbst (→ 5.5) | +| **OpenCode 1 und 2 werden unterstützt** | OpenCode 2 ist seit 11.10.2026 verfügbar, viele Teams sind noch auf 1 (→ 5.5) | +| **Zu alte Werkzeugversion: warnen**, abbrechen nur mit `tool_check: strict` | Teams nicht blockieren, wer will, kann es erzwingen (→ 5.4) | +| **Repo-Kennung = vereinheitlichte Git-Remote-URL**; Integrationen ohne Git melden einen Client-Namen | intern ohnehin bekannt, genau genug für die Übersicht (→ 11.5) | +| **Betrieb in OpenShift**, Deployment außerhalb dieses Konzepts | wird vom Betreiber übernommen (→ 18) | +| **Lokale MCP-Server starten über `hugin mcp run `**, Code unter `~/.local/share/mcp/` | Pfade sind je Rechner verschieden; so bleibt `opencode.json` gleich und einzuchecken, und die Version kommt aus `hugin.lock` (→ 20, Stufe 5b) | +| **Python über venv und pip**, Artifactory aus `pip.conf`; Programme als Adresse und Prüfsumme | so ist es im Haus eingerichtet; große Dateien bleiben im Artifactory (→ 20, Stufe 5b, 5c) | +| **Clients: Windows und Linux**, Prüfung unter Windows von Hand | Zielplattformen; entwickelt wird auf macOS (→ 20, Stufe 5a) | + +### 21.2 Offen + +Keine offenen Entscheidungen. Offen sind noch zwei Prüfungen (STUFE0.md): ab welcher +Version Claude Code benannte Argumente kennt (davon hängt die Untergrenze ab) und das +Verhalten von OpenCode 2. Dazu Stufe 5 (→ 20). + +--- + +## 22. Quellen und was neu zu prüfen ist + +Stand der Dokus: 17.10.2026. Beide Werkzeuge ändern sich schnell. Nach größeren +Updates neu nachsehen und `scripts/stage0.sh` sowie `scripts/tool-smoke-test.sh` +laufen lassen. + +| Thema | Quelle | +|---|---| +| OpenCode Skills | https://opencode.ai/docs/skills/ | +| OpenCode Regeln (`AGENTS.md`, `instructions`, Claude-Kompatibilität) | https://opencode.ai/docs/rules/ | +| OpenCode Agenten | https://opencode.ai/docs/agents/ | +| OpenCode Befehle | https://opencode.ai/docs/commands/ | +| OpenCode MCP-Server | https://opencode.ai/docs/mcp-servers/ | +| OpenCode Plugins | https://opencode.ai/docs/plugins/ | +| OpenCode 2: Umstieg von V1 | https://opencode.ai/v2/docs/migrate-v1/ | +| OpenCode 2: Skills | https://opencode.ai/v2/docs/skills/ | +| OpenCode Release-Notes | https://github.com/sst/opencode/releases | +| Claude Code Changelog | https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md | +| Claude Code Skills | https://code.claude.com/docs/en/skills | +| Claude Code Subagenten | https://code.claude.com/docs/en/sub-agents | +| Claude Code Memory (`CLAUDE.md`, `@`-Import) | https://code.claude.com/docs/en/memory | +| Claude Code MCP | https://code.claude.com/docs/en/mcp | +| Claude Code Hooks | https://code.claude.com/docs/en/hooks | +| Agent-Skills-Standard | https://agentskills.io | + +**Unbestätigt zum Stand dieses Dokuments** (nur Berichte Dritter): + +- dass Claude Code ab 2.1.277 `AGENTS.md` von selbst liest, solange keine `CLAUDE.md` da ist. + +**In der Doku nicht beantwortet** und deshalb in Stufe 0 selbst geprüft (STUFE0.md): + +- Fragen 1–8 aus Abschnitt 20; offen sind 9 (nur zur Information) und 10 (OpenCode 2). diff --git a/README.md b/README.md new file mode 100644 index 0000000..8adec65 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# Hugin + +Register für Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen für +Claude Code und OpenCode. Konzept: [KONZEPT.md](KONZEPT.md). + +| Ordner | Inhalt | +|---|---| +| `contract/` | Bundle-Format (JSON-Schema), Beispielpakete, goldene Bundles | +| `server/` | Python: `hugin_core` (Quellformat, Linter, Übersetzer, Auflösung, Bundles) und `hugin_app` (FastAPI, Datenbank, API, Weboberfläche) | +| `cli/` | Go: Programm `hugin` — Teams: `init`, `sync`, `check`, `status`, `search`, `login`; Autoren: `new`, `validate`, `try`, `upload`, `import` | +| `scripts/` | siehe „Skripte“ unten | +| `static/css-framework/` | CSS-Framework für die Weboberfläche | +| `BETRIEB.md` | Container-Image, SQLite oder PostgreSQL, Sicherung | +| `TESTEN.md` | Schritt für Schritt lokal testen: Demo, Test-Repo, Autorenablauf, alle Testskripte | +| `STUFE0.md` | Ergebnisse der Verhaltensprüfungen von Claude Code und OpenCode | +| `COMPATIBILITY.md` | Ergebnis des letzten Rauchtests mit den echten Werkzeugen (`scripts/tool-smoke-test.sh`) | + +## Starten und ausprobieren + +Voraussetzungen: `uv`, Go ≥ 1.26, `zip`, `curl`. Für die Browser-Prüfungen Google Chrome. + +```sh +scripts/demo.sh # Server mit Beispieldaten auf http://127.0.0.1:8080; Keys stehen in .demo/keys.txt +``` + +In der Weboberfläche mit einem der Keys anmelden, z. B. als Bert (Prüfer): unter „Prüfungen“ wartet +`pub-munin-nutzen 1.5.0`. Als Anna (Autorin) hochladen, als Shop-Admin Personen und Keys anlegen, +als Plattform-Admin (`demo-admin`) die Systemseite. + +Mit dem CLI ein Repo abgleichen: + +```sh +(cd cli && go build -o dist/hugin ./cmd/hugin) +cd /path/to/test-repo +export HUGIN_KEY= +/cli/dist/hugin init --server http://127.0.0.1:8080 --project api +# in .hugin.yaml unter packages: z. B. - pub-munin-nutzen und - pub-release-notiz +/cli/dist/hugin sync +``` + +MCP in Claude Code oder OpenCode: Server `http://127.0.0.1:8080/mcp` mit Kopfzeile +`Authorization: Bearer `. + +## Testen + +```sh +cd server && uv run pytest # Server: Kern, API, Web, MCP, Stufe 4, Isolation der Tenants (SQLite) +scripts/postgres-test.sh # dieselbe Suite gegen PostgreSQL in Docker +cd server && uv run pytest --update # goldene Bundles neu schreiben (nach gewollter Änderung) +cd cli && go test ./... # CLI, wendet die goldenen Bundles an +``` + +Erst die Server-Tests, dann die CLI-Tests: Die CLI testet gegen die Bundles, +die der Server erzeugt. + +Vor einem Release und nach größeren Updates von Claude Code oder OpenCode zusätzlich +mit den echten Werkzeugen (kostet Tokens, braucht für beide einen Modellzugang): + +```sh +scripts/stage0.sh # Verhalten von OpenCode, ohne Modellaufruf +OPENCODE_MODEL= scripts/tool-smoke-test.sh # schreibt COMPATIBILITY.md +``` + +## Stand + +Umgesetzt sind die Stufen 0 bis 4 aus [KONZEPT.md](KONZEPT.md) (Abschnitt 20). Offen: + +- Stufe 5: lokale MCP-Server mit Python und OpenCode-Plugins (ohne npm-Abhängigkeiten) + sind umgesetzt (Windows von Hand ungeprüft, `TESTEN.md` 5.11); offen sind fertige + Programme als lokale Server, npm-Abhängigkeiten für Plugins, Hooks (Claude Code), + Paare, Signaturen +- OpenCode 2 ist ungeprüft (STUFE0.md, Frage 10) +- Untergrenzen der Werkzeuge sind vorläufig: geprüft sind nur aktuelle Versionen +- `hugin-install.ps1` ist mit PowerShell 7 getestet, unter Windows PowerShell 5.1 noch nicht + +## CLI bauen und ohne Server ausprobieren + +```sh +cd cli && go build -o dist/hugin ./cmd/hugin +cd /path/zum/test-repo +hugin init --server https://hugin.intern --project shop +hugin sync --bundle /contract/examples/bundles/first-install +hugin check --bundle /contract/examples/bundles/first-install +``` + +## Server lokal starten + +```sh +cd server +export HUGIN_DATABASE_URL=sqlite:///hugin.db HUGIN_ADMIN_TOKEN=geheim +uv run hugin-server migrate +uv run hugin-server run --port 8080 # API-Doku unter http://localhost:8080/docs +``` + +Erste Schritte mit dem Admin-Token: Tenant anlegen (`POST /api/v1/admin/tenants`), +Projekt (`…/tenants/{slug}/projects`), Keys (`…/tenants/{slug}/keys`). +`scripts/smoke-test-local.sh` macht genau das und gleicht danach ein Test-Repo ab. + +| Umgebungsvariable | Bedeutung | +|---|---| +| `HUGIN_DATABASE_URL` | z. B. `sqlite:///hugin.db` oder `postgresql+psycopg://…` | +| `HUGIN_ADMIN_TOKEN` | Plattform-Admin | +| `HUGIN_BASE_URL` | steht im Kopf verwalteter Dateien | +| `HUGIN_DOWNLOAD_DIR` | Ordner mit gebauten CLI-Programmen (`scripts/build-cli.sh`), für die Seite „Programm hugin“ | +| `HUGIN_CLI_MIN_VERSION` | ältere CLIs meldet `hugin check` als Befund (Standard 0.1.0) | +| `HUGIN_RATE_LIMIT` | Anfragen je Key und Minute (Standard 600) | +| `HUGIN_FAILED_ATTEMPTS` | falsche Keys je IP in 5 Minuten, danach 429 (Standard 20) | +| `HUGIN_PROXY_IPS` | Router/Ingress, deren `X-Forwarded-For` gilt (Standard `127.0.0.1`; OpenShift: Router-Netz, z. B. `10.128.0.0/14`) | +| `HUGIN_MAX_UPLOAD_MB` | größter Request-Body, vor der Anmeldung geprüft (Standard 40) | + +Die Weboberfläche liegt unter `/` (Anmeldung mit einem Key), die Online-Hilfe unter `/help` (ohne Anmeldung), die API-Doku unter `/api/docs`. + +Mit PostgreSQL statt SQLite: `docker compose up -d`, dann +`HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh`. + +## Container-Image + +`server/Dockerfile` baut auf Red Hat UBI 9 minimal mit Python 3.12 (ca. 90 MB), die +Abhängigkeiten kommen fest aus `uv.lock`. Das Image läuft als beliebiger Benutzer der +Gruppe 0 (OpenShift) und auch mit schreibgeschütztem Dateisystem. + +```sh +docker build -t hugin server/ +docker run --rm -e HUGIN_DATABASE_URL=… hugin migrate # Migrations-Job vor dem Rollout +docker run -p 8080:8080 -e HUGIN_DATABASE_URL=… -e HUGIN_ADMIN_TOKEN=… hugin # Server (CMD run) +``` + +Probes: `/healthz` (lebt), `/readyz` (Datenbank erreichbar, Migrationen aktuell), Port 8080. +Ohne `HUGIN_DATABASE_URL` nutzt das Image SQLite unter `/data/hugin.db`: dort ein Volume +einhängen, nur ein Pod, Strategie `Recreate`. Mit PostgreSQL braucht es kein Volume. +Ausführlich (SQLite oder PostgreSQL, Sicherung, Probes): [BETRIEB.md](BETRIEB.md). + +## Skripte + +| Skript | Zweck | +|---|---| +| `scripts/demo.sh` | Server mit Beispieldaten und Keys für alle Rollen | +| `scripts/integration-test.sh` | Auslieferung an Integrationen mit `hugin-install.ps1` (`PWSH=`) | +| `scripts/integration-test.ps1` | dasselbe unter Windows PowerShell 5.1, gegen einen laufenden Hugin (siehe unten) | +| `scripts/postgres-test.sh [--compose]` | ganze Server-Testsuite gegen PostgreSQL 17.6 in Docker (Wegwerf-Container oder die aus `docker-compose.yml`) | +| `docker compose up -d` | dauerhafte PostgreSQL auf 127.0.0.1:55432 (Daten im Volume; `docker compose down -v` löscht sie) | +| `scripts/smoke-test-local.sh` | Server starten, Pakete veröffentlichen, mit dem CLI abgleichen; dazu der Ablauf für Autoren (`new`, `validate`, `try`, `upload`) | +| `scripts/stage0.sh` | Verhalten von OpenCode prüfen, ohne Modellaufruf (→ STUFE0.md) | +| `scripts/tool-smoke-test.sh` | Rauchtest mit echtem Claude Code und OpenCode, schreibt `COMPATIBILITY.md`; kostet Tokens (`TOOLS`, `CLAUDE_MODEL`, `OPENCODE_MODEL`, `KEEP=1`) | +| `scripts/csp-test.sh` | CSS-Framework unter strenger CSP: Verstöße zählen, alle `data-action` durchklicken (Chrome) | +| `uv run --no-project --with websockets --with httpx python scripts/csp/web_browser.py ` | Weboberfläche in Chrome: CSP-Verstöße je Seite, Bildschirmfotos hell und dunkel | +| `uv run --no-project --with websockets --with httpx python scripts/csp/web_actions.py` | Weboberfläche in Chrome bedienen: Live-Suche, Tabs, Kopieren, Rückfragen, Felder je Key-Art, Prüfungs-Dialog, Editor | +| `scripts/build-cli.sh [download-ordner]` | CLI für macOS, Linux, Windows bauen, mit SHA256SUMS | +| `uv run hugin-server export [--commit]` | freigegebene Pakete im Quellformat exportieren (in `server/`) | +| `scripts/sync-framework.sh` | gebautes CSS-Framework nach `server/src/hugin_app/static/` kopieren | + +## Integration unter Windows PowerShell 5.1 testen + +1. Auf dem Mac oder Linux-Rechner: `HOST=0.0.0.0 scripts/demo.sh` +2. `scripts/integration-test.ps1` auf den Windows-Rechner kopieren. +3. Dort in Windows PowerShell 5.1 (nicht `pwsh`): + + ```powershell + powershell -ExecutionPolicy Bypass -File integration-test.ps1 ` + -Server http://:8080 -Key + ``` + +Das Skript lädt `hugin-install.ps1` vom Server, installiert in einen Temp-Ordner, +prüft Encoding, Zeilenenden, JSON, zweiten Lauf, Abbestellen, Handänderung und +JSONC und meldet je Prüfung OK oder FEHLER. Es ändert nichts außerhalb des Temp-Ordners. diff --git a/STUFE0.md b/STUFE0.md new file mode 100644 index 0000000..ca47262 --- /dev/null +++ b/STUFE0.md @@ -0,0 +1,50 @@ +# Stufe 0: Ergebnisse + +Verhaltensfragen aus KONZEPT.md 20, Stufe 0: Wie lesen Claude Code und OpenCode die +Dateien, die Hugin schreibt? Stand 19.10.2026. + +| Werkzeug | Version | +|---|---| +| Claude Code | 2.1.303 (Changelog-Auswertung mit 2.1.302) | +| OpenCode | 1.19.3; Fragen 1–3, 6, 7 zuerst mit 1.15.4, gleiches Ergebnis. OpenCode 2 nicht installiert | + +**So geprüft** (Spalte „Wie“): + +- **S0** – `scripts/stage0.sh`: nur `opencode debug …` und der Server von `opencode serve`, kein Modellaufruf, kostenlos. +- **RT** – `scripts/tool-smoke-test.sh`: echte Modellaufrufe (Claude Code mit `haiku`, OpenCode mit `openai/gpt-6-luna`). Ergebnis auch in `COMPATIBILITY.md`. +- **CL** – Changelog bzw. Release-Notes. + +## Geklärt + +| # | Frage | Ergebnis | Folge für Hugin | Wie | +|---|---|---|---|---| +| 1 | Unbekannte Felder in `SKILL.md` (`allowed-tools`, `paths`, `effort`)? | OpenCode übergeht sie still, der Skill lädt | Zusätze für Claude stehen in derselben Datei; der Skill bleibt `portable` (→ 7.1) | S0 | +| 2 | Gleicher Skill-Name in `.claude/skills/` und `.opencode/skills/`? | nur **eine** Fassung wird geladen, **welche, ist nicht verlässlich** | Hugin legt einen Skill nie an beiden Orten ab; `.opencode/skills/` nur bei `opencode_reads_claude: false` | S0 | +| 3 | Liest OpenCode `.claude/commands/` oder `.claude/agents/`? | nein, beide nicht | Befehle und Subagenten je Werkzeug am eigenen Ort, keine Doppel (→ 7.3, 7.4) | S0 | +| 3a | Liest OpenCode `.opencode/commands/` und `.opencode/agents/`? | ja; `permission` mit `deny` wirkt | Format der Übersetzer bestätigt | S0, RT | +| 4 | Liest OpenCode ohne `AGENTS.md` die `CLAUDE.md` und löst deren `@`-Verweise auf? | liest `CLAUDE.md`, löst `@` **nicht** auf | keine Doppel: Regeln kommen in OpenCode nur über `instructions` an; keine eigene `AGENTS.md` nötig | RT | +| 5 | Claude Code und `AGENTS.md` | liest `AGENTS.md` ab 2.1.277 nur ohne `CLAUDE.md`; mit beiden Dateien nur `CLAUDE.md` | unkritisch: Hugin legt `CLAUDE.md` an, Regeln kommen nicht doppelt | CL, RT | +| 6 | JSONC in `opencode.json`? | ja, Kommentare und Komma am Ende werden gelesen | das CLI erhält Kommentare beim Ändern (hujson) | S0 | +| 7 | Werkzeug- und Rechtenamen | OpenCode-Werkzeuge: `read`, `grep`, `glob`, `edit`, `write`, `bash`, `webfetch`, `websearch`, `task`, `skill`, `todowrite`, `question`; Rechte u. a. `edit`, `bash`, `webfetch`, `external_directory` | Fähigkeitsklasse `read` sperrt auch `websearch` (→ KONZEPT.md 7.4) | S0 | +| 8a | Benannte Argumente in Claude Code (`arguments`, `$name`)? | wirken in 2.1.303 | Übersetzer bestätigt; ab welcher Version, ist offen (siehe unten) | RT | +| 8b | Wirkt `metadata: opencode/slash`? | nein: jeder Skill ist ohnehin ein Slash-Befehl, auch mit `opencode/slash: "false"` | Feld nicht nötig; Linter-Hinweis H023 | S0 | +| 8c | Wirkt `metadata: opencode/autoinvoke: "false"`? | nein: das Modell lädt den Skill trotzdem selbst, auch mit `disable-model-invocation: true` in derselben Datei. Claude Code hält sich an `disable-model-invocation` | „nur von Hand“ geht in OpenCode 1 nicht; Linter-Hinweis H023, Ausweg `targets: [claude]` | RT | +| – | MCP: `type: remote`, `headers`, `{env:VAR}` | funktioniert; die Verbindung mit dem Key aus der Umgebungsvariable klappt | Übersetzer bestätigt. Ab 1.19 zeigt `debug config` Header nur maskiert | S0, RT | +| – | MCP in Claude Code aus `.mcp.json` | Server stehen auf „Pending approval“, bis jemand zustimmt; `settings.local.json` im Repo hilft dabei nicht | in der Hilfe für Teams beschrieben; der Rauchtest stimmt mit `--settings` zu | RT | + +Wie die Slash-Liste geprüft wird: `opencode serve` starten und `GET /command` +abfragen. Das ist dieselbe Liste, die das TUI bei `/` anbietet. + +## Offen + +| # | Frage | So prüfen | +|---|---|---| +| 8a | Ab welcher Version kennt Claude Code benannte Argumente? Davon hängt die Untergrenze 2.1.105 ab | ältere Version installieren, dann `TOOLS=claude scripts/tool-smoke-test.sh` | +| 9 | Unterstützt OpenCode MCP-Prompts und -Ressourcen? | nur zur Information, Hugin baut nicht darauf | +| 10 | OpenCode 2: alte MCP-Form (`mcp.`, `enabled`)? unbekannte Felder? Doppel bei Skills? `opencode/autoinvoke`? | `OPENCODE= scripts/stage0.sh` und `OPENCODE= OPENCODE_MODEL=… scripts/tool-smoke-test.sh` | + +## Nebenbefund + +Auf dem Entwicklungsrechner ist global ein OpenCode-Plugin `skill-registry` +installiert, das Skills, Agenten und Befehle von `http://localhost:8000` +synchronisieren will. Beide Skripte schalten es mit `--pure` ab. diff --git a/TESTEN.md b/TESTEN.md new file mode 100644 index 0000000..4a66755 --- /dev/null +++ b/TESTEN.md @@ -0,0 +1,354 @@ +# Hugin lokal testen + +Schritt für Schritt: Demo-Server starten, Pakete in ein Test-Repo holen, als Autor ein +Paket ausprobieren, die automatischen Tests laufen lassen und die Funktionen der +Weboberfläche und des CLI durchgehen (Checkliste in Abschnitt 5). + +Voraussetzungen: `uv`, Go ≥ 1.26, `zip`, `curl`; für Schritt 2, 3 und 5 außerdem +OpenCode (vorrangig) und/oder Claude Code; für die Browser-Prüfungen Google Chrome. Alle Pfade gehen vom Hugin-Repo aus (hier `~/repos/dev/hugin`). + +## 1. Demo-Server mit Beispieldaten + +```sh +cd ~/repos/dev/hugin +scripts/demo.sh # http://127.0.0.1:8080, Keys stehen in .demo/keys.txt +scripts/demo.sh --neu # Beispieldaten verwerfen und neu anlegen +``` + +Im Browser mit einem Key aus `.demo/keys.txt` anmelden: + +| Key | Rolle | Was ausprobieren | +|---|---|---| +| Bert Prüfer | Prüfer | unter „Prüfungen“ wartet `pub-munin-nutzen 1.5.0` | +| Anna Autorin | Autorin | Pakete anlegen und hochladen | +| Shop-Admin | Tenant-Admin | Projekte, Personen und Keys anlegen | +| `demo-admin` | Plattform-Admin | Systemseite, Audit, alle Tenants | + +Online-Hilfe: http://127.0.0.1:8080/help · API-Doku: http://127.0.0.1:8080/api/docs + +## 2. Pakete in ein Test-Repo holen + +In einem zweiten Terminal, während der Server läuft: + +```sh +cd ~/repos/dev/hugin/cli && go build -o dist/hugin ./cmd/hugin +export PATH=~/repos/dev/hugin/cli/dist:$PATH # kein alias: OpenCode und Claude Code sehen nur den PATH + +mkdir -p /tmp/test-repo && cd /tmp/test-repo && git init -q +export HUGIN_KEY= +hugin init --server http://127.0.0.1:8080 --project api +``` + +In `.hugin.yaml` Werkzeuge und Pakete eintragen: + +```yaml +tools: [claude, opencode] # fest eintragen: „auto“ erkennt im leeren Repo nichts Passendes +packages: + - pub-munin-nutzen # Skill + - pub-hausregeln # Regeln + - pub-release-notiz # Befehl mit Argumenten + - pub-code-review # Subagent +``` + +`pub-munin-anbindung` (MCP) kommt als Pflichtpaket von selbst mit. + +```sh +hugin sync # schreibt die Dateien, legt hugin.lock an +hugin status # Abos, Versionen, erkannte Werkzeuge +hugin check # ändert nichts; Rückgabewert 1, wenn etwas zu tun ist +``` + +Dann im Werkzeug ausprobieren, z. B.: + +```sh +opencode # oder: claude +> /pub-release-notiz 2.0 Kunden +``` + +**Was sich zu testen lohnt:** + +- Eine verwaltete Datei von Hand ändern, dann `hugin sync` → Befund „Handänderung“; + `hugin sync --overwrite` setzt sie zurück. +- Als Bert `pub-munin-nutzen 1.5.0` freigeben, dann `hugin check` (meldet die neue + Version) und `hugin sync`. +- Ein Paket aus `packages` entfernen und `hugin sync` → seine Dateien verschwinden. +- Claude Code fragt beim ersten Start, ob es den MCP-Server aus `.mcp.json` nutzen darf. + +## 3. Als Autor ein Paket ausprobieren + +```sh +cd /tmp +export HUGIN_KEY= HUGIN_SERVER=http://127.0.0.1:8080 +hugin new skill pub-mein-test +``` + +In `pub-mein-test/hugin.yaml` mindestens `owner: kern` und eine echte `description` +eintragen, in `SKILL.md` den Inhalt. Format und Beispiele: Hilfe → Quellformat. + +```sh +hugin validate pub-mein-test # Linter und Vorschau je Werkzeug, speichert nichts +cd /tmp/test-repo +hugin try ../pub-mein-test # ins Repo installieren, ohne hugin.lock zu ändern +opencode # ausprobieren (oder claude) +hugin try --remove # wieder aufräumen +cd /tmp && hugin upload --submit pub-mein-test # hochladen und zur Prüfung einreichen +``` + +Danach als Bert im Browser unter „Prüfungen“ freigeben. + +**Linter ausprobieren:** + +| Änderung | Meldung | +|---|---| +| `description` aus der Vorlage stehen lassen | H003 (Fehler): noch der Platzhalter | +| unter `extra.opencode` z. B. `temperature: 0.1` bei einem Skill | H020 (Fehler): Feld unbekannt | +| `extra.claude.disable-model-invocation: true` | H023 (Hinweis): wirkt in OpenCode 1 nicht | +| „nutze Read“ im Text | H021 (Hinweis): neutral formulieren | + +## 4. Automatische Tests + +| Befehl | Prüft | Kosten | +|---|---|---| +| `cd server && uv run pytest` | Server: Linter, Übersetzer, API, Web, MCP, Tenants | frei | +| `scripts/postgres-test.sh` | dieselbe Suite gegen PostgreSQL (Docker) | frei | +| `cd cli && go test ./...` | CLI, gegen die goldenen Bundles des Servers | frei | +| `scripts/smoke-test-local.sh` | ganzer Ablauf Server → CLI, ohne echte Werkzeuge | frei | +| `scripts/stage0.sh` | wie OpenCode die Dateien liest, ohne Modellaufruf | frei | +| `uv run --no-project --with websockets --with httpx python scripts/csp/web_browser.py /tmp/shots` | Weboberfläche in Chrome: CSP-Verstöße je Seite, Bildschirmfotos | frei | +| `uv run --no-project --with websockets --with httpx python scripts/csp/web_actions.py` | Weboberfläche in Chrome bedienen: Topbar, Live-Suche, Kopieren, Rückfragen, Formulare, Editor, Tabs | frei | +| `OPENCODE_MODEL=openai/gpt-6-luna scripts/tool-smoke-test.sh` | echtes Claude Code und OpenCode, schreibt `COMPATIBILITY.md` | ca. 30 Modellaufrufe | + +Reihenfolge: erst die Server-Tests, dann die CLI-Tests (die CLI testet gegen die +Bundles, die der Server erzeugt). + +Optionen für `tool-smoke-test.sh`: + +| Variable | Wirkung | +|---|---| +| `TOOLS=opencode` | nur ein Werkzeug prüfen | +| `CLAUDE_MODEL=sonnet` | anderes Modell für Claude Code (Standard `haiku`) | +| `OPENCODE_MODEL=anbieter/modell` | Modell für OpenCode; ohne Angabe sein Standard | +| `KEEP=1` | Arbeitsordner und Logs bleiben zum Nachsehen | +| `OUT=/pfad/datei.md` | Ergebnis woandershin statt nach `COMPATIBILITY.md` | + +OpenCode braucht dafür einen Anbieter-Zugang (`opencode providers login`); die +kostenlosen Modelle lehnen `opencode run` ab. + +## 5. Checkliste: Funktionen von Hand durchgehen + +Server aus Abschnitt 1 und Test-Repo aus Abschnitt 2. Jede Zeile: was tun, was erwartet +wird. Was die Browser-Prüfung (`web_actions.py`) schon abdeckt, ist mit ⚙ markiert; von +Hand lohnt es trotzdem einmal, um das Verhalten zu kennen. + +### 5.1 Weboberfläche: überall + +| Schritt | Erwartung | +|---|---| +| Fenster schmal ziehen oder Handyansicht (Chrome: F12 → Gerätesymbol) | Topbar oben mit ☰, „Hilfe“, Hell/Dunkel, „Abmelden“; nichts abgeschnitten; „Programm hugin“ steht dann im Menü ⚙ | +| Lange Seite (als `demo-admin` „Tenants, Projekte, Keys“) ganz nach unten scrollen | Topbar mit „Abmelden“ bleibt sichtbar, die Sidebar bleibt stehen ⚙ | +| Mond/Sonne in der Topbar | Hell/Dunkel wechselt | +| Hilfe → „Arten von Paketen“ | jede Art erklärt (Skill, Regeln, Befehl, Subagent, MCP, Hook) mit „Welche Art passt?“ | +| Auf einer Paketseite das Abzeichen der Art (z. B. „Skill“) anklicken | führt zu „Arten von Paketen“ | + +### 5.2 Als Shop CI (Team) + +| Schritt | Erwartung | +|---|---| +| Katalog: ins Suchfeld „haus“ tippen | Liste filtert ohne Enter, Suche steht in der URL ⚙ | +| Katalog: Spalte „Paket“ anklicken | sortiert (nur, solange alles auf eine Seite passt) | +| Paket `pub-munin-nutzen` → „Im Repo nutzen“ → „Kopieren“ | Meldung „Kopiert“; in der Zwischenablage `packages:` mit dem Paket ⚙ | +| Version `1.4.0` öffnen, Tab „Inhalt“ anklicken, dann ← → Pos1 Ende | Tabs wechseln per Tastatur ⚙ | +| „Mein Projekt“ | Repos gruppiert; Filter „Alle“ / „Mit neuerer Version“; „Gemeldet“ als relative Zeit („vor 2 Tagen“), genaue Zeit im Tooltip | + +### 5.3 Als Bert (Prüfer) und Carla (Prüferin + Tenant-Admin) + +| Schritt | Erwartung | +|---|---| +| „Prüfungen“ | `pub-munin-nutzen 1.5.0` mit „eingereicht vor …“ | +| Version öffnen → „Prüfen“ → ohne Kommentar „Zurückgeben“ | Browser verlangt einen Kommentar, nichts wird abgeschickt ⚙ | +| Mit Kanal „stabil“ freigeben | zurück in „Prüfungen“; danach meldet im Test-Repo `hugin check` die neue Version | +| Danach als Shop-Admin „Überblick“ → Karte „Veraltete Installationen“ | springt zur Tabelle der Repos mit alter Version | +| Rechte-Paket: als Anna eine Skill-Version mit `extra: {claude: {allowed-tools: [Bash]}}` hochladen und einreichen, Bert gibt frei | Bert sieht im Dialog kein Kanal-Feld, sondern „Den Kanal wählt die letzte nötige Freigabe“; danach sieht Carla in „Prüfungen“ „Freigaben 1 von 2“ und „Tenant-Admin nötig“ | +| Carla gibt frei | Kanal-Feld mit Hinweis zum Risiko; danach freigegeben | + +### 5.4 Als Anna (Autorin) + +| Schritt | Erwartung | +|---|---| +| „Hochladen“ → „direkt im Browser anlegen“ | ID ist mit `pub-` vorausgefüllt | +| Version `pub-munin-nutzen 1.4.0` → „Neue Version auf dieser Basis“ | Editor öffnet mit 1.4.1 | +| Im Editor Tab drücken; Esc und dann Tab | Tab rückt zwei Leerzeichen ein; Esc+Tab springt zum nächsten Feld ⚙ | +| Etwas ändern und die Seite verlassen | Browser warnt vor ungespeicherten Änderungen ⚙ | +| In `SKILL.md` `$1` schreiben und speichern | Linter lehnt ab (H010), der Dateiname im Befund springt zur Datei | +| Alle Dateien unverändert speichern (nur `changes` ausfüllen) | Entwurf 1.4.1 mit denselben Dateien; nichts geht verloren | +| ZIP mit `hugin.yaml` (`kind: hook`) hochladen | klare Ablehnung H008 „Hooks kommen mit Stufe 5“, kein Serverfehler | +| „Zur Prüfung einreichen“ | Rückfrage „Danach lässt sich die Version nicht mehr ändern“ | + +### 5.5 Als Shop-Admin (Verwaltung) + +| Schritt | Erwartung | +|---|---| +| „Neuer Key“ → Art umschalten | Projekt-Key: nur Feld „Projekt“ (Pflicht); Integrations-Key: nur „Nur für diese Projekte“ ⚙ | +| Rollen ansehen | lesbare Namen („Autor“, „Prüfer (öffentlich)“, „Tenant-Admin“) mit Tooltip | +| Key anlegen → „Kopieren“ | Key in der Zwischenablage; er wird nur dieses eine Mal angezeigt | +| „Widerrufen“ bei einem Key | Rückfrage; „Abbrechen“ ändert nichts ⚙ | +| Als `demo-admin`: Tenant „plattform“ → Person „Sperren“; Pflichtpaket „Keine Pflicht mehr“ | jeweils Rückfrage (das Pflichtpaket gilt für alle Tenants, nur der Plattform-Admin kann es lösen) | +| Tabelle „Keys“ | „Zuletzt genutzt“ als „vor …“ | +| Als `demo-admin` „Audit“ → Aktion „Keys“ wählen | Liste filtert sofort; ab 50 Einträgen bleibt der Filter beim Blättern („Ältere“) erhalten ⚙ | + +### 5.6 CLI im Test-Repo (OpenCode zuerst) + +| Schritt | Erwartung | +|---|---| +| Frisches Repo, `hugin sync` | `opencode.json` beginnt mit `"$schema": "https://opencode.ai/config.json"`; `opencode debug config` läuft ohne Fehler | +| In `.hugin.yaml` `tools: [opencode]`, dann `hugin sync` | Skills unter `.opencode/skills/`; kein `.claude/`, keine `.mcp.json`, kein Block in `CLAUDE.md`; `opencode debug skill` findet den Skill | +| Zurück auf `tools: [claude, opencode]`, `hugin sync` | Skills wandern nach `.claude/skills/`, `.opencode/skills/` ist leer | +| `unset MUNIN_KEY; hugin sync` | `Warnung: pub-munin-anbindung braucht die Umgebungsvariable MUNIN_KEY; sie ist nicht gesetzt …` | +| `export MUNIN_KEY=x; hugin sync` | keine Warnung mehr | +| `unset MUNIN_KEY; hugin check; echo $?` | Warnung, aber Rückgabewert 0 (CI ohne Keys bleibt grün) | +| In `opencode.json` unter `mcp` von Hand `"jira": { "url": "https://jira.intern/mcp" }` eintragen, dann `hugin check; echo $?` | „OpenCode lehnt die Konfiguration ab“ mit `mcp.jira`, Rückgabewert 1, Text „das kann hugin sync nicht“ | +| Eintrag reparieren (`"type": "remote"`, `"enabled": true`), `hugin check` | „Alles aktuell.“ | +| `opencode` im Repo starten, `/pub-release-notiz 2.0 Kunden` | Befehl läuft; Regeln aus `pub-hausregeln` werden beachtet | +| In `opencode.json` einen Tippfehler wie `"modell": "x"` | wird **nicht** gemeldet: OpenCode 1.19.3 nimmt unbekannte Felder hin | + +### 5.7 Agent installiert über Hugins MCP-Endpunkt (OpenCode) + +Frisches Test-Repo, damit nichts vom Abschnitt 2 stört: + +```sh +mkdir -p /tmp/mcp-repo && cd /tmp/mcp-repo && git init -q +export HUGIN_KEY= +export MUNIN_KEY=x # nur gegen die Warnung; Munin selbst gibt es in der Demo nicht +hugin init --server http://127.0.0.1:8080 --project api +sed -i '' 's/^tools: auto/tools: [opencode]/' .hugin.yaml +hugin sync # nur das Pflichtpaket pub-munin-anbindung +``` + +In `opencode.json` unter `"mcp"` Hugin selbst als MCP-Server eintragen (von Hand; Hugin +lässt diesen Eintrag in Ruhe): + +```json +"hugin": { + "type": "remote", + "url": "http://127.0.0.1:8080/mcp", + "enabled": true, + "headers": { "Authorization": "Bearer {env:HUGIN_KEY}" } +} +``` + +| Schritt | Erwartung | +|---|---| +| `opencode mcp list` | `hugin` steht auf „connected“ (`munin` verbindet in der Demo nicht, das ist erwartet) | +| `hugin check` | „Alles aktuell.“ (OpenCode akzeptiert den Eintrag) | +| `which hugin` in derselben Shell, aus der du `opencode` startest | zeigt `…/cli/dist/hugin`; sonst findet der Agent `hugin sync` nicht (Abschnitt 2: `PATH`, kein `alias`) | +| `opencode` starten: „Gibt es in Hugin einen Befehl für Release-Notizen?“ | ruft `search_packages` auf, nennt `pub-release-notiz` | +| „Zeig mir, was pub-release-notiz macht, ohne es zu installieren.“ | ruft `get_package` auf, erklärt den Inhalt; im Repo ändert sich nichts (`git status`) | +| „Richte pub-release-notiz dauerhaft in diesem Repo ein.“ | trägt `pub-release-notiz` in `.hugin.yaml` ein und führt `hugin sync` aus (OpenCode fragt vorher nach Erlaubnis für den Befehl); **schreibt nicht selbst** nach `.opencode/commands/` | +| Danach `hugin status` und `cat hugin.lock` | `pub-release-notiz` steht in beiden; die Datei `.opencode/commands/pub-release-notiz.md` hat Hugin geschrieben | +| OpenCode neu starten, `/pub-release-notiz 2.0 Kunden` | der Befehl läuft | +| Als Anna eine neue Version von `pub-release-notiz` hochladen, Bert gibt frei; dann `hugin check` | meldet die neue Version (das geht nur, weil `hugin sync` installiert hat) | + +Gegenprobe ohne `hugin` im `PATH` (z. B. `env PATH=/usr/bin:/bin opencode`): Der Agent +soll melden, dass `hugin` fehlt, und **nichts selbst schreiben**. Schreibt er die Datei +doch selbst, fehlt `pub-release-notiz` in `hugin.lock`, und das nächste `hugin sync` +meldet „Datei gibt es schon, sie stammt nicht von Hugin (Konflikt)“. Dann bitte Modell +und Wortlaut notieren, das wäre ein Befund. Aufräumen: `hugin sync --overwrite`. + +Der automatische Test dazu: `scripts/tool-smoke-test.sh`, Prüfungen „Installieren auf Bitte“ und „Ohne hugin nichts schreiben“. + +### 5.8 Demo und Betrieb + +| Schritt | Erwartung | +|---|---| +| `scripts/demo.sh` mit einer Datenbank aus einem alten Stand | statt Traceback: „Migration gescheitert …“ mit dem Hinweis auf `--neu` | +| `cd server && uv run pytest tests/test_migration.py` nach einer Änderung an `0001_initial_tables.py` | Fehler „ausgelieferte Migrationen nie ändern, sondern eine neue anlegen“ | + +### 5.9 Lokaler MCP-Server (Python) + +Das Beispiel `pub-zeit-lokal` braucht nur die Standardbibliothek. Als Anna im Test-Repo +aus Abschnitt 3 (mit `tools: [opencode]` oder `[claude, opencode]`): + +```sh +export HUGIN_MCP_HOME=/tmp/hugin-mcp ZEIT_ZONE_HINWEIS=test # Ablage zum Wegwerfen +hugin try ~/repos/dev/hugin/contract/examples/packages/pub-zeit-lokal/1.0.0 +``` + +| Schritt | Erwartung | +|---|---| +| Ausgabe von `hugin try` | „installiere pub-zeit-lokal 1.0.0 (lokaler Server) nach /tmp/hugin-mcp/…“ | +| `ls -a /tmp/hugin-mcp/pub-zeit-lokal/1.0.0` | `zeit_server.py`, `.venv`, `.hugin-install.json` | +| `grep -A6 zeit opencode.json` | `"command": ["hugin", "mcp", "run", "pub-zeit-lokal"]`, kein Pfad | +| `opencode mcp list` | `zeit connected` | +| in OpenCode: „Rufe das Werkzeug zeit auf“ | Uhrzeit mit `UHR-4711 (test)` | +| `rm -rf /tmp/hugin-mcp`, dann `opencode mcp list` | `zeit failed`; `hugin mcp run pub-zeit-lokal` sagt „nicht installiert; im Repo hugin sync ausführen“ | +| `hugin try` erneut | installiert wieder | +| `hugin try --remove`, dann `hugin mcp prune --dry-run` | „würde löschen pub-zeit-lokal 1.0.0“ | +| `mkdir -p /tmp/hugin-mcp/eigener && hugin mcp prune` | löscht nur das Hugin-Paket; `eigener` bleibt | + +Freigabe und Verteilung: als Anna `hugin upload --submit` (persönlicher Key nötig, H043 +beim Projekt-Key), Risiko „Skripte“, also zwei Freigaben (Bert und Carla). Die +Versionsseite zeigt „Lokaler Server“ mit Ablage. Danach im Repo `pub-zeit-lokal` unter +`packages` eintragen und `hugin sync`; `hugin check` meldet, wenn der Server auf dem +Rechner fehlt. + +**Mit Abhängigkeiten:** `requirements.txt` mit Hashes erzeugen (Hilfe → Quellformat, +`uv pip compile --universal --generate-hashes`). pip nimmt den Index aus der +`pip.conf`; zum Test einen anderen Index: `PIP_CONFIG_FILE=/tmp/pip.conf`. Ohne Hash +lehnt der Linter mit H037 ab. + +### 5.10 OpenCode-Plugin + +Das Beispiel `pub-specht-plugin` gibt OpenCode das Werkzeug `specht_marke`. Als Anna im +Test-Repo (`tools: [opencode]` oder `[claude, opencode]`): + +```sh +hugin validate ~/repos/dev/hugin/contract/examples/packages/pub-specht-plugin/1.0.0 +hugin try ~/repos/dev/hugin/contract/examples/packages/pub-specht-plugin/1.0.0 +``` + +| Schritt | Erwartung | +|---|---| +| Ausgabe von `hugin validate` | `Risiko: permissions`, `Kompatibilität: opencode-only`, keine Fehler | +| `find .opencode/plugins` | `pub-specht-plugin.ts` und `pub-specht-plugin/index.ts`, `…/lib/marke.ts`; nichts unter `.claude/` | +| `opencode debug config \| grep -A2 '"plugin"'` | `…/.opencode/plugins/pub-specht-plugin.ts` | +| in OpenCode: „Rufe das Werkzeug specht_marke auf“ | `SPECHT-5127` | +| dasselbe mit `opencode --pure` | Werkzeug unbekannt (so gewollt) | +| `git status` | `.opencode/plugins/…` neu; `node_modules`, `package.json` unter `.opencode/` nicht (eigene `.gitignore` von OpenCode) | +| in `index.ts` `import _ from "lodash"` ergänzen, `hugin validate` | H039 | +| `targets` auf `[claude, opencode]`, `hugin validate` | H038 | +| `hugin try --remove` | Ordner und Startdatei weg | + +Freigabe: `hugin upload --submit`, dann Bert und **Carla** (Tenant-Admin); mit Bert und +Dora allein bleibt es bei „Tenant-Admin nötig“. Danach abonnieren und `hugin sync`. + +### 5.11 Windows (von Hand) + +Auf einem Windows-Rechner mit Python (Starter `py`), OpenCode und Zugang zu Hugin. +Programm bauen auf dem Mac: `cd cli && GOOS=windows GOARCH=amd64 go build -o dist/hugin.exe ./cmd/hugin`, +`hugin.exe` in einen Ordner im `PATH` legen. In PowerShell: + +| Schritt | Erwartung | +|---|---| +| `hugin init …`, `.hugin.yaml` wie in Abschnitt 2 mit `tools: [opencode]`, `hugin sync` | Dateien unter `.opencode\…`, keine unter `.claude` | +| `opencode debug skill > s.txt; opencode agent list; opencode mcp list` | Skill, Subagent, MCP wie unter macOS | +| `hugin try …\pub-zeit-lokal\1.0.0` (mit `$env:ZEIT_ZONE_HINWEIS="test"`) | installiert nach `%USERPROFILE%\.local\share\mcp\pub-zeit-lokal\1.0.0`, venv unter `.venv\Scripts` | +| `opencode mcp list` | `zeit connected` | +| in OpenCode das Werkzeug zeit aufrufen | Antwort mit `UHR-4711`, Umlaute korrekt | +| aus einem Unterordner des Repos starten | lokaler Server verbindet trotzdem | +| ohne `py`, nur `python` aus python.org im `PATH` | wird gefunden; nur der Store-Platzhalter `python.exe`: Meldung „kein Python gefunden“ | +| `$env:HUGIN_PYTHON="C:\Pfad\python.exe"` | wird genommen | +| Paket mit `requirements.txt`, `pip.ini` unter `%APPDATA%\pip\` mit Artifactory | pip installiert aus dem Artifactory | +| `hugin mcp prune` | wie unter macOS | +| Repo unter einem langen Pfad (> 200 Zeichen), dann `hugin sync` | Befund notieren, falls Windows-Pfadgrenze greift | +| `hugin try …\pub-specht-plugin\1.0.0`, OpenCode neu starten, Werkzeug specht_marke aufrufen | `SPECHT-5127` | +| `hugin-install.ps1` mit einem Paket mit lokalem Server | Warnung „ist ein lokaler MCP-Server; installieren mit dem Programm hugin“, sonst läuft alles durch | + +Befunde bitte mit Ausgabe und Windows-Version notieren. + +## Noch nicht testbar + +- Hooks, Paare, Signaturen, fertige Programme als lokale Server, npm-Abhängigkeiten für + Plugins: noch nicht gebaut (Stufe 5c–5e); ein Hook-Paket lehnt der Linter mit H008 ab. +- OpenCode 2: ungeprüft (STUFE0.md, Frage 10); `OPENCODE= scripts/stage0.sh`. +- `hugin-install.ps1` unter Windows PowerShell 5.1: braucht einen Windows-Rechner, + siehe README „Integration unter Windows PowerShell 5.1 testen“. diff --git a/cli/cmd/hugin/author.go b/cli/cmd/hugin/author.go new file mode 100644 index 0000000..996f84f --- /dev/null +++ b/cli/cmd/hugin/author.go @@ -0,0 +1,313 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/spf13/cobra" + + "hugin/cli/internal/api" + "hugin/cli/internal/bundle" + "hugin/cli/internal/config" + "hugin/cli/internal/keystore" + "hugin/cli/internal/localmcp" + "hugin/cli/internal/lock" + "hugin/cli/internal/pkgdir" + "hugin/cli/internal/reconcile" +) + +// Lock-Datei von hugin try; hugin.lock bleibt dabei unberührt (→ KONZEPT.md 11.1). +const trialLock = localmcp.TrialLock + +func newCommand() *cobra.Command { + var folder string + cmd := &cobra.Command{ + Use: "new ", + Short: "legt einen Paketordner mit Manifest-Gerüst an (Art: skill, rules, command, subagent, mcp, plugin)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + target, err := pkgdir.New(folder, args[0], args[1]) + if err != nil { + return err + } + fmt.Printf("%s angelegt. hugin.yaml ausfüllen, dann: hugin validate %s\n", target, target) + return nil + }, + } + cmd.Flags().StringVar(&folder, "in", ".", "Ordner, in dem der Paketordner entsteht") + return cmd +} + +// authorClient: Server aus --server, sonst aus .hugin.yaml, sonst HUGIN_SERVER. +func authorClient(server string) (*api.Client, error) { + if server == "" { + if k, err := config.Load(repoFolder); err == nil { + server = k.Server + } + } + if server == "" { + server = os.Getenv("HUGIN_SERVER") + } + if server == "" { + return nil, errors.New("kein Server: --server angeben, HUGIN_SERVER setzen oder in einem Repo mit .hugin.yaml arbeiten") + } + key, err := keystore.Get(server) + if err != nil { + return nil, err + } + return api.New(server, key) +} + +func folderArg(args []string) string { + if len(args) == 1 { + return args[0] + } + return "." +} + +// printFindings druckt Linter-Befunde; true, wenn ein Fehler dabei ist. +func printFindings(findings []api.Finding) bool { + errs := false + for _, b := range findings { + location := "" + if b.File != "" { + location = " (" + b.File + ")" + } + fmt.Printf(" %-7s %s %s%s\n", b.Severity, b.Id, b.Text, location) + errs = errs || b.Severity == "error" + } + return errs +} + +// linterError macht aus einer 422-Antwort mit Befunden eine lesbare Ausgabe. +func linterError(err error) error { + var f *api.APIError + if errors.As(err, &f) && len(f.Findings) > 0 { + fmt.Println("Der Linter hat das Paket abgelehnt:") + printFindings(f.Findings) + return findingError{"Paket nicht angenommen"} + } + return err +} + +func validateCommand() *cobra.Command { + var server string + cmd := &cobra.Command{ + Use: "validate [folder]", + Short: "prüft einen Paketordner über den Server und zeigt, was in jedem Werkzeug ankommt; speichert nichts", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := authorClient(server) + if err != nil { + return err + } + raw, err := pkgdir.Zip(folderArg(args)) + if err != nil { + return err + } + p, err := client.Validate(raw) + if err != nil { + return linterError(err) + } + if len(p.Findings) > 0 { + fmt.Println("Befunde:") + } + errs := printFindings(p.Findings) + fmt.Printf("Risiko: %s\n", p.Risk) + if p.Compatibility != nil { + fmt.Printf("Kompatibilität: %s\n", *p.Compatibility) + } + toolNames := make([]string, 0, len(p.Preview)) + for w := range p.Preview { + toolNames = append(toolNames, w) + } + sort.Strings(toolNames) + for _, w := range toolNames { + fmt.Printf("\n%s:\n", w) + for _, op := range p.Preview[w] { + fmt.Printf(" %-10v %v\n", op["kind"], target(op)) + } + } + if errs { + return findingError{"Paket hat Fehler"} + } + return nil + }, + } + cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)") + return cmd +} + +func target(op map[string]any) string { + if path, ok := op["path"]; ok { + return fmt.Sprint(path) + } + text := fmt.Sprint(op["file"]) + if s, ok := op["key"]; ok { + text += fmt.Sprintf(" %v", s) + } + if b, ok := op["block"]; ok { + text += fmt.Sprintf(" Block %v", b) + } + return text +} + +func tryCommand() *cobra.Command { + var server string + var remove, overwrite bool + cmd := &cobra.Command{ + Use: "try [folder]", + Short: "installiert einen Entwurf zum Ausprobieren ins Repo, ohne hugin.lock zu ändern (--remove räumt auf)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + lockPath := filepath.Join(repoFolder, trialLock) + old, err := lock.LoadFrom(lockPath) + if err != nil { + return err + } + b := bundle.Empty() + if !remove { + client, err := authorClient(server) + if err != nil { + return err + } + raw, err := pkgdir.Zip(folderArg(args)) + if err != nil { + return err + } + k, _ := config.Load(repoFolder) + detected, _ := detectTools(k) + details := map[string]string{} + for _, w := range detected { + details[w.name] = w.version + } + zip, err := client.Trial(raw, details) + if err != nil { + return linterError(err) + } + if b, err = bundle.FromZip(zip); err != nil { + return err + } + } + plan, err := reconcile.MakePlan(repoFolder, b, old, reconcile.Options{Overwrite: overwrite}) + if err != nil { + return err + } + for _, f := range plan.Findings { + fmt.Fprintln(os.Stderr, "Befund:", f) + } + if plan.Open() { + return findingError{"nichts geändert; mit --overwrite setzt try die Dateien trotzdem"} + } + if err := plan.RunWithLock(repoFolder, lockPath); err != nil { + return err + } + if !remove { + home, err := localmcp.Home() + if err != nil { + return err + } + if err := install(b, home); err != nil { + return fmt.Errorf("Probe geschrieben, aber lokaler Server nicht installiert: %w", err) + } + } + if remove { + os.Remove(lockPath) + fmt.Printf("Probe entfernt: %d Dateien gelöscht.\n", len(plan.Delete)) + return nil + } + fmt.Printf("Probe installiert: %d Dateien geschrieben (%d neu).\n", len(plan.Write), len(plan.New)) + for _, rel := range plan.New { + fmt.Println(" ", rel) + } + fmt.Println("Jetzt in Claude Code oder OpenCode ausprobieren. Aufräumen mit: hugin try --remove") + return nil + }, + } + cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)") + cmd.Flags().BoolVar(&remove, "remove", false, "Dateien der letzten Probe wieder entfernen") + cmd.Flags().BoolVar(&overwrite, "overwrite", false, "vorhandene Dateien überschreiben") + return cmd +} + +func uploadCommand() *cobra.Command { + var server string + var submit bool + cmd := &cobra.Command{ + Use: "upload [folder]", + Short: "lädt einen Paketordner als Entwurf hoch (mit --submit gleich zur Prüfung)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := authorClient(server) + if err != nil { + return err + } + raw, err := pkgdir.Zip(folderArg(args)) + if err != nil { + return err + } + v, err := client.Upload(raw) + if err != nil { + return linterError(err) + } + printFindings(v.Linter) + fmt.Printf("Entwurf %s %s gespeichert (Risiko %s).\n", v.Package, v.Version, v.Risk) + if submit { + if v, err = client.Submit(v.Package, v.Version); err != nil { + return err + } + fmt.Printf("Eingereicht: %s %s ist jetzt %s. Eine zweite Person prüft.\n", v.Package, v.Version, v.Status) + } + return nil + }, + } + cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)") + cmd.Flags().BoolVar(&submit, "submit", false, "nach dem Hochladen zur Prüfung einreichen") + return cmd +} + +func searchCommand() *cobra.Command { + var server, kind string + cmd := &cobra.Command{ + Use: "search [text]", + Short: "sucht im Katalog in ID und Beschreibung (für Teams und Autoren)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := authorClient(server) + if err != nil { + return err + } + text := "" + if len(args) == 1 { + text = args[0] + } + matches, err := client.Search(text, kind) + if err != nil { + return err + } + if len(matches) == 0 { + fmt.Println("Keine Pakete gefunden.") + return nil + } + for _, t := range matches { + fmt.Printf("%-32s %-9s %-8s %s\n", t.ID, t.Kind, t.Version, shorten(t.Description, 80)) + } + fmt.Printf("\n%d Paket(e). Abonnieren: in .hugin.yaml unter packages eintragen, dann hugin sync.\n", len(matches)) + return nil + }, + } + cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)") + cmd.Flags().StringVar(&kind, "kind", "", "nur diese Art: skill, rules, command, subagent, mcp, plugin") + return cmd +} + +func shorten(text string, n int) string { + r := []rune(text) + if len(r) <= n { + return text + } + return string(r[:n-1]) + "…" +} diff --git a/cli/cmd/hugin/import.go b/cli/cmd/hugin/import.go new file mode 100644 index 0000000..bc8cca8 --- /dev/null +++ b/cli/cmd/hugin/import.go @@ -0,0 +1,68 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "hugin/cli/internal/config" + "hugin/cli/internal/importer" +) + +func importCommand() *cobra.Command { + var opt importer.Options + cmd := &cobra.Command{ + Use: "import", + Short: "wandelt vorhandene Skills, Agenten, Befehle, Regeln und MCP-Server des Repos in Hugin-Pakete um (OpenCode zuerst)", + Long: `Liest .opencode/, opencode.json, AGENTS.md und danach .claude/, .mcp.json, CLAUDE.md +und legt je Fund einen Paketordner im Quellformat an. Gibt es denselben Namen in +OpenCode und Claude Code, gilt die OpenCode-Fassung. Ändert im Repo nichts. + +Danach jedes Paket prüfen (IMPORT.md im Zielordner listet Hinweise) und mit +hugin validate / hugin upload --submit einreichen.`, + Example: " hugin import --prefix shop- --target ../hugin-packages\n hugin import --prefix pub- --owner kern", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opt.Repo = repoFolder + if opt.Owner == "" { + var k *config.Config + if _, err := os.Stat(filepath.Join(repoFolder, config.Filename)); err == nil { + if k, err = config.Load(repoFolder); err != nil { + return err + } + } + if k == nil || k.Project == "" { + return errors.New("--owner fehlt (Projekt, das die Pakete pflegt); ohne .hugin.yaml bitte angeben") + } + opt.Owner = k.Project + } + b, err := importer.Import(opt) + if err != nil { + return err + } + for _, p := range b.Packages { + fmt.Printf("%-9s %-36s ← %v\n", p.Kind, p.ID, p.Sources[0]) + for _, h := range p.Hints { + fmt.Printf(" Hinweis: %s\n", h) + } + } + for _, g := range b.Skipped { + fmt.Printf("nicht übernommen: %s: %s\n", g.Source, g.Text) + } + fmt.Printf("\n%d Paket(e) in %s, Übersicht in %s/IMPORT.md\n", len(b.Packages), opt.Target, opt.Target) + if len(b.Packages) > 0 { + fmt.Printf("Weiter: hugin validate %s/, dann hugin upload --submit %s/\n", opt.Target, opt.Target) + } + return nil + }, + } + cmd.Flags().StringVar(&opt.Prefix, "prefix", "", "Präfix der IDs: pub- (öffentlich) oder - (Pflicht)") + cmd.Flags().StringVar(&opt.Owner, "owner", "", "Projekt, das die Pakete pflegt (Standard: project aus .hugin.yaml)") + cmd.Flags().StringVar(&opt.Target, "target", "hugin-import", "Ordner für die Pakete") + cmd.Flags().BoolVar(&opt.Overwrite, "overwrite", false, "vorhandene Paketordner im Ziel ersetzen") + cmd.MarkFlagRequired("prefix") + return cmd +} diff --git a/cli/cmd/hugin/init.go b/cli/cmd/hugin/init.go new file mode 100644 index 0000000..98bd7e4 --- /dev/null +++ b/cli/cmd/hugin/init.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "hugin/cli/internal/config" + "hugin/cli/internal/tools" +) + +func initCommand() *cobra.Command { + var server, project string + cmd := &cobra.Command{ + Use: "init", + Short: "legt .hugin.yaml an", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if server == "" || project == "" { + return errors.New("--server und --project sind Pflicht") + } + if _, err := os.Stat(filepath.Join(repoFolder, config.Filename)); err == nil { + return fmt.Errorf("%s gibt es schon", config.Filename) + } + k := &config.Config{ + Server: server, + Project: project, + Tools: config.Tools{Auto: true}, + Packages: []string{}, + } + if err := k.Save(repoFolder); err != nil { + return err + } + fmt.Printf("%s angelegt. Erkannte Werkzeuge: %v\n", config.Filename, tools.InRepo(repoFolder)) + fmt.Println("Pakete unter packages: eintragen, dann hugin sync.") + return nil + }, + } + cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin, z. B. https://hugin.intern") + cmd.Flags().StringVar(&project, "project", "", "Projekt-Slug in Hugin") + return cmd +} diff --git a/cli/cmd/hugin/main.go b/cli/cmd/hugin/main.go new file mode 100644 index 0000000..49a671d --- /dev/null +++ b/cli/cmd/hugin/main.go @@ -0,0 +1,42 @@ +// hugin ist das Kommandozeilenprogramm für Teams und Autoren (→ KONZEPT.md 11). +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// Version wird beim Bauen gesetzt: -ldflags "-X main.Version=…" +var Version = "dev" + +// findingError bedeutet: Befehl lief, hat aber etwas gefunden (Rückgabewert 1). +type findingError struct{ text string } + +func (e findingError) Error() string { return e.text } + +func main() { + root := &cobra.Command{ + Use: "hugin", + Short: "Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen aus Hugin abgleichen", + Version: Version, + SilenceUsage: true, + SilenceErrors: true, + } + root.PersistentFlags().StringVarP(&repoFolder, "repo", "C", ".", "Wurzel des Repos") + root.AddCommand(initCommand(), syncCommand(), checkCommand(), statusCommand(), loginCommand(), + newCommand(), validateCommand(), tryCommand(), uploadCommand(), searchCommand(), importCommand(), mcpCommand()) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "hugin:", err) + var finding findingError + if errors.As(err, &finding) { + os.Exit(1) + } + os.Exit(2) + } +} + +var repoFolder string diff --git a/cli/cmd/hugin/mcp.go b/cli/cmd/hugin/mcp.go new file mode 100644 index 0000000..7f40fb2 --- /dev/null +++ b/cli/cmd/hugin/mcp.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "hugin/cli/internal/bundle" + "hugin/cli/internal/localmcp" + "hugin/cli/internal/lock" +) + +func mcpCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "lokale MCP-Server: starten und aufräumen", + } + cmd.AddCommand(mcpRunCommand(), mcpPruneCommand()) + return cmd +} + +// mcpRunCommand startet einen lokalen Server; so steht es in opencode.json bzw. .mcp.json. +// Die Version kommt aus hugin.lock des Repos, in dem das Werkzeug läuft; läuft dort eine +// Probe (hugin try), gilt deren Version. +func mcpRunCommand() *cobra.Command { + return &cobra.Command{ + Use: "run ", + Short: "lokalen MCP-Server in der Version aus hugin.lock starten (rufen OpenCode und Claude Code auf)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + repo, err := localmcp.FindRepo(repoFolder) + if err != nil { + return err + } + p, ok, err := lockedVersion(repo, id) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%s steht nicht in %s; im Repo hugin sync ausführen", id, lock.Filename) + } + home, err := localmcp.Home() + if err != nil { + return err + } + folder := localmcp.Folder(home, id, p.Version) + m, err := localmcp.Read(folder) + if err != nil || m.Checksum != p.Checksum { + return fmt.Errorf("lokaler Server %s %s ist auf diesem Rechner nicht installiert; im Repo hugin sync ausführen", + id, p.Version) + } + program, arguments, env := localmcp.Command(folder, m) + return localmcp.Exec(program, arguments, env) + }, + } +} + +func mcpPruneCommand() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "prune", + Short: "installierte lokale Server löschen, die kein bekanntes Repo mehr nutzt", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + home, err := localmcp.Home() + if err != nil { + return err + } + removed, err := localmcp.Prune(home, dryRun) + for _, r := range removed { + if dryRun { + fmt.Println(" würde löschen", r) + } else { + fmt.Println(" gelöscht ", r) + } + } + if err == nil && len(removed) == 0 { + fmt.Println("Nichts zu löschen.") + } + return err + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "nur anzeigen") + return cmd +} + +func lockedVersion(repo, id string) (*lock.Package, bool, error) { + for _, path := range []string{filepath.Join(repo, filepath.FromSlash(localmcp.TrialLock)), filepath.Join(repo, lock.Filename)} { + l, err := lock.LoadFrom(path) + if err != nil { + return nil, false, err + } + if p, ok := l.Packages[id]; ok { + return p, true, nil + } + } + return nil, false, nil +} + +// missingInstalls nennt die lokalen Server des Bundles, die auf diesem Rechner fehlen. +func missingInstalls(b *bundle.Bundle, home string) []bundle.Operation { + var missing []bundle.Operation + for _, op := range b.Installs() { + if !localmcp.Installed(home, op) { + missing = append(missing, op) + } + } + return missing +} + +// install installiert fehlende lokale Server und merkt sich das Repo für prune. +func install(b *bundle.Bundle, home string) error { + for _, op := range missingInstalls(b, home) { + fmt.Printf(" installiere %s %s (lokaler Server) nach %s\n", op.Package, op.Version, + localmcp.Folder(home, op.Package, op.Version)) + if err := localmcp.Install(home, b, op, os.Stderr); err != nil { + return err + } + } + if len(b.Installs()) > 0 { + return localmcp.Register(home, repoFolder) + } + return nil +} diff --git a/cli/cmd/hugin/status.go b/cli/cmd/hugin/status.go new file mode 100644 index 0000000..c913648 --- /dev/null +++ b/cli/cmd/hugin/status.go @@ -0,0 +1,106 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "maps" + "os" + "slices" + "strings" + + "github.com/spf13/cobra" + + "hugin/cli/internal/config" + "hugin/cli/internal/keystore" + "hugin/cli/internal/lock" + "hugin/cli/internal/tools" +) + +func statusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "listet Abos, installierte Versionen und Werkzeuge", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + k, err := config.Load(repoFolder) + if err != nil { + return err + } + l, err := lock.Load(repoFolder) + if err != nil { + return err + } + fmt.Printf("Server: %s\nProjekt: %s\n\nAbos:\n", k.Server, k.Project) + for _, subscription := range k.Packages { + id, _, _ := strings.Cut(subscription, "@") + installed := "nicht installiert" + if p, ok := l.Packages[id]; ok { + installed = p.Version + } + fmt.Printf(" %-40s %s\n", subscription, installed) + } + var more []string + for _, id := range slices.Sorted(maps.Keys(l.Packages)) { + if !slices.ContainsFunc(k.Packages, func(a string) bool { return strings.Split(a, "@")[0] == id }) { + more = append(more, fmt.Sprintf(" %-40s %s", id, l.Packages[id].Version)) + } + } + if len(more) > 0 { + fmt.Println("\nInstalliert ohne Abo (z. B. Pflichtpakete):") + fmt.Println(strings.Join(more, "\n")) + } + fmt.Println("\nWerkzeuge:") + names := k.Tools.List + if len(names) == 0 { + names = tools.InRepo(repoFolder) + } + for _, name := range names { + version := tools.Version(name) + state := "ok" + switch { + case version == "": + version, state = "–", "nicht gefunden" + case tools.Less(version, tools.Floors[name]): + state = "älter als " + tools.Floors[name] + } + fmt.Printf(" %-10s %-10s %s\n", name, version, state) + } + return nil + }, + } +} + +func loginCommand() *cobra.Command { + var logout bool + cmd := &cobra.Command{ + Use: "login", + Short: "speichert den Key im Schlüsselbund des Betriebssystems", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + k, err := config.Load(repoFolder) + if err != nil { + return err + } + if logout { + return keystore.Delete(k.Server) + } + fmt.Fprintf(os.Stderr, "Key für %s: ", k.Server) + row, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && row == "" { + return err + } + key := strings.TrimSpace(row) + if key == "" { + return errors.New("kein Key eingegeben") + } + if err := keystore.Save(k.Server, key); err != nil { + return fmt.Errorf("Schlüsselbund: %w; alternativ %s setzen", err, keystore.Env) + } + fmt.Fprintln(os.Stderr, "gespeichert.") + return nil + }, + } + cmd.Flags().BoolVar(&logout, "logout", false, "Key aus dem Schlüsselbund löschen") + return cmd +} diff --git a/cli/cmd/hugin/sync.go b/cli/cmd/hugin/sync.go new file mode 100644 index 0000000..8530d2b --- /dev/null +++ b/cli/cmd/hugin/sync.go @@ -0,0 +1,338 @@ +package main + +import ( + "errors" + "fmt" + "maps" + "os" + "regexp" + "slices" + "strings" + + "github.com/spf13/cobra" + + "hugin/cli/internal/api" + "hugin/cli/internal/bundle" + "hugin/cli/internal/config" + "hugin/cli/internal/keystore" + "hugin/cli/internal/localmcp" + "hugin/cli/internal/lock" + "hugin/cli/internal/reconcile" + "hugin/cli/internal/repo" + "hugin/cli/internal/tools" +) + +type syncOptions struct { + bundle string + major bool + overwrite bool + strict bool + dryRun bool +} + +func syncCommand() *cobra.Command { + var opt syncOptions + cmd := &cobra.Command{ + Use: "sync", + Short: "holt die abonnierten und die Pflichtpakete und gleicht das Repo ab", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runSync(opt, false) + }, + } + cmd.Flags().StringVar(&opt.bundle, "bundle", "", "Bundle aus Datei oder Ordner statt vom Server") + cmd.Flags().BoolVar(&opt.major, "major", false, "Major-Sprünge erlauben") + cmd.Flags().BoolVar(&opt.overwrite, "overwrite", false, "Handänderungen und Konflikte überschreiben") + cmd.Flags().BoolVar(&opt.strict, "strict", false, "bei zu alten Werkzeugen abbrechen") + cmd.Flags().BoolVar(&opt.dryRun, "dry-run", false, "nur anzeigen, nichts schreiben") + return cmd +} + +func checkCommand() *cobra.Command { + var opt syncOptions + cmd := &cobra.Command{ + Use: "check", + Short: "ändert nichts; meldet Neues, Handänderungen und zu alte Werkzeuge (Rückgabewert 1 bei Befund)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opt.dryRun = true + return runSync(opt, true) + }, + } + cmd.Flags().StringVar(&opt.bundle, "bundle", "", "Bundle aus Datei oder Ordner statt vom Server") + return cmd +} + +type tool struct{ name, version string } + +// checkCliVersion vergleicht die eigene Version mit den Angaben des Servers (→ KONZEPT.md 18). +// Eine Entwicklungsfassung (Version "dev") prüft nichts. +func checkCliVersion(conf *api.Configuration) []string { + if Version == "dev" || !semverPattern.MatchString(Version) { + return nil + } + if m := conf.CLI.MinVersion; m != "" && tools.Less(Version, m) { + return []string{fmt.Sprintf("hugin %s ist älter als die nötige Version %s; neues Programm unter %s", + Version, m, "„Programm hugin“ in der Weboberfläche")} + } + if n := conf.CLI.Newest; n != "" && tools.Less(Version, n) { + fmt.Fprintf(os.Stderr, "Hinweis: hugin %s ist verfügbar (installiert: %s)\n", n, Version) + } + return nil +} + +var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + +// detectTools liefert die Werkzeuge laut .hugin.yaml (oder erkannt im +// Repo) mit installierter Version und Warnungen zu alten Versionen. +func detectTools(k *config.Config) ([]tool, []string) { + var names []string + if k != nil { + names = k.Tools.List + } + if len(names) == 0 { + names = tools.InRepo(repoFolder) + } + var detected []tool + var warnings []string + for _, name := range names { + w := tool{name, tools.Version(name)} + detected = append(detected, w) + if floor := tools.Floors[name]; w.version != "" && tools.Less(w.version, floor) { + warnings = append(warnings, fmt.Sprintf("%s %s ist älter als die Untergrenze %s", name, w.version, floor)) + } + } + return detected, warnings +} + +func runSync(opt syncOptions, check bool) error { + k, err := config.Load(repoFolder) + if err != nil { + return err + } + var cliWarnings []string + old, err := lock.Load(repoFolder) + if err != nil { + return err + } + + // 1. Untergrenzen vom Server (sonst die eingebauten), Werkzeuge erkennen und prüfen + var client *api.Client + if opt.bundle == "" { + key, err := keystore.Get(k.Server) + if err != nil { + return err + } + if client, err = api.New(k.Server, key); err != nil { + return err + } + conf, err := client.Configuration() + if err != nil { + return err + } + for name, version := range conf.Floors { + tools.Floors[name] = version + } + cliWarnings = checkCliVersion(conf) + } + detected, warnings := detectTools(k) + warnings = append(cliWarnings, warnings...) + for _, w := range warnings { + fmt.Fprintln(os.Stderr, "Warnung:", w) + } + if len(warnings) > 0 && !check && (opt.strict || k.Strict()) { + return errors.New("Werkzeuge zu alt (tool_check: strict)") + } + + // 2. Bundle holen + var b *bundle.Bundle + if opt.bundle != "" { + b, err = bundle.Read(opt.bundle) + } else { + b, err = get(client, k, old, detected, opt.major) + } + if err != nil { + return err + } + for _, w := range detected { + if !slices.Contains(b.Instructions.Tools, tools.Target(w.name, w.version)) { + fmt.Fprintf(os.Stderr, "Warnung: Bundle enthält nichts für %s\n", tools.Target(w.name, w.version)) + } + } + + // 3. Planen + plan, err := reconcile.MakePlan(repoFolder, b, old, reconcile.Options{Overwrite: opt.overwrite}) + if err != nil { + return err + } + report(plan, b, old) + // Fehlende Variablen nur melden: hugin check läuft oft in CI, wo die Keys der MCP-Server fehlen dürfen + for _, w := range missingEnv(b.Instructions.Hints, os.LookupEnv) { + fmt.Fprintln(os.Stderr, "Warnung:", w) + } + + // Lokale Server liegen außerhalb des Repos: auch ein frisch geklontes, sonst aktuelles Repo + // braucht sie noch auf diesem Rechner + home, err := localmcp.Home() + if err != nil { + return err + } + missing := missingInstalls(b, home) + for _, op := range missing { + if check { + fmt.Fprintf(os.Stderr, "Befund: lokaler Server %s %s ist auf diesem Rechner nicht installiert\n", op.Package, op.Version) + } else if opt.dryRun { + fmt.Printf(" würde installieren %s %s (lokaler Server)\n", op.Package, op.Version) + } + } + + // check prüft den Ist-Stand, sync den Stand nach dem Schreiben (unten) + var opencodeError string + if check { + if opencodeError = checkOpenCode(detected); opencodeError != "" { + fmt.Fprintln(os.Stderr, "Befund: OpenCode lehnt die Konfiguration ab:\n"+opencodeError) + } + } + + switch { + case check && opencodeError != "" && !plan.Changed() && len(plan.Findings) == 0 && len(missing) == 0: + return findingError{"OpenCode-Konfiguration reparieren; das kann hugin sync nicht"} + case check: + if plan.Changed() || len(plan.Findings) > 0 || len(warnings) > 0 || opencodeError != "" || len(missing) > 0 { + return findingError{"Abweichungen gefunden; hugin sync gleicht ab"} + } + fmt.Println("Alles aktuell.") + return nil + case plan.Open(): + return findingError{"nichts geändert; Handänderungen erst klären oder mit --overwrite zurücksetzen"} + case opt.dryRun: + fmt.Println("Trockenlauf: nichts geschrieben.") + return nil + } + + // 4. Schreiben + if err := plan.Run(repoFolder); err != nil { + return err + } + if err := install(b, home); err != nil { + return fmt.Errorf("Dateien geschrieben, aber lokaler Server nicht installiert: %w", err) + } + if !plan.Changed() && len(missing) == 0 { + fmt.Println("Alles aktuell.") + } else if msg := checkOpenCode(detected); msg != "" { + return fmt.Errorf("geschrieben, aber OpenCode startet so nicht; erst die Konfiguration reparieren:\n%s", msg) + } + + // 5. Nutzung melden; ein Fehler hier macht den sync nicht ungültig + if client != nil { + if err := client.Usage(usage(plan.Lock, detected)); err != nil { + fmt.Fprintln(os.Stderr, "Warnung: Nutzung nicht gemeldet:", err) + } + } + return nil +} + +func get(client *api.Client, k *config.Config, old *lock.Lock, detected []tool, major bool) (*bundle.Bundle, error) { + request := api.BundleRequest{ + Packages: k.Packages, + Unsubscribed: k.Unsubscribed, + Tools: map[string]string{}, + Mandatory: true, + Major: major, + Options: map[string]bool{"opencode_reads_claude": k.ReadsClaude()}, + Installed: map[string]string{}, + } + for _, w := range detected { + request.Tools[w.name] = w.version + } + for id, p := range old.Packages { + request.Installed[id] = p.Version + } + raw, err := client.Bundle(request) + if err != nil { + return nil, err + } + return bundle.FromZip(raw) +} + +func usage(l *lock.Lock, detected []tool) api.Usage { + n := api.Usage{Repo: repo.Id(repoFolder), Tools: map[string]string{}, Installed: map[string]string{}} + for _, w := range detected { + n.Tools[w.name] = w.version + } + for id, p := range l.Packages { + n.Installed[id] = p.Version + } + return n +} + +// checkOpenCode lässt OpenCode die Konfiguration laden, wenn das Repo OpenCode nutzt und es +// installiert ist; liefert OpenCodes Fehlermeldung oder nichts. +func checkOpenCode(detected []tool) string { + for _, w := range detected { + if w.name == "opencode" && w.version != "" { + return tools.CheckOpenCode(repoFolder) + } + } + return "" +} + +func report(plan *reconcile.Plan, b *bundle.Bundle, old *lock.Lock) { + for _, p := range b.Instructions.Packages { + before, ok := old.Packages[p.ID] + switch { + case !ok: + fmt.Printf(" neu %s %s\n", p.ID, p.Version) + case before.Version != p.Version: + fmt.Printf(" aktualisiert %s %s → %s\n", p.ID, before.Version, p.Version) + } + } + for _, id := range slices.Sorted(maps.Keys(old.Packages)) { + if _, stays := plan.Lock.Packages[id]; !stays { + fmt.Printf(" entfernt %s %s\n", id, old.Packages[id].Version) + } + } + if plan.Changed() { + fmt.Printf("Dateien: %d schreiben (%d neu), %d löschen\n", len(plan.Write), len(plan.New), len(plan.Delete)) + } + for _, f := range plan.Findings { + fmt.Fprintln(os.Stderr, "Befund:", f) + } + for _, h := range b.Instructions.Hints { + if h.Kind != "env_variable" { // eigene Prüfung in missingEnv + fmt.Println("Hinweis:", hintText(h)) + } + } +} + +// missingEnv nennt die Umgebungsvariablen, die Pakete brauchen (MCP-Kopfzeilen) und die +// nicht oder leer gesetzt sind. Gesetzte Variablen bleiben still. +func missingEnv(hints []bundle.Hint, lookup func(string) (string, bool)) []string { + var missing []string + for _, h := range hints { + if h.Kind != "env_variable" { + continue + } + if value, ok := lookup(h.Name); !ok || value == "" { + missing = append(missing, fmt.Sprintf("%s braucht die Umgebungsvariable %s; sie ist nicht gesetzt, "+ + "der MCP-Server bekommt sonst keinen Wert (z. B. keinen Key)", h.Package, h.Name)) + } + } + return missing +} + +func hintText(h bundle.Hint) string { + switch h.Kind { + case "min_version": + return fmt.Sprintf("%s braucht %s ≥ %s", h.Package, h.Tool, h.Version) + case "deprecated": + text := h.Package + " ist veraltet" + if h.Successor != "" { + text += "; Nachfolger: " + h.Successor + } + return text + case "major_available": + return fmt.Sprintf("%s %s ist verfügbar; übernehmen mit hugin sync --major", h.Package, h.Version) + } + return strings.TrimSpace(h.Kind + " " + h.Package) +} diff --git a/cli/cmd/hugin/sync_test.go b/cli/cmd/hugin/sync_test.go new file mode 100644 index 0000000..f0b98bf --- /dev/null +++ b/cli/cmd/hugin/sync_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "strings" + "testing" + + "hugin/cli/internal/bundle" +) + +func TestMissingEnv(t *testing.T) { + hints := []bundle.Hint{ + {Kind: "env_variable", Package: "pub-munin-anbindung", Name: "MUNIN_KEY"}, + {Kind: "env_variable", Package: "pub-jira", Name: "JIRA_KEY"}, + {Kind: "env_variable", Package: "pub-wiki", Name: "WIKI_KEY"}, + {Kind: "min_version", Package: "pub-x", Tool: "claude", Version: "2.1.84"}, + } + env := map[string]string{"MUNIN_KEY": "geheim", "WIKI_KEY": ""} + lookup := func(name string) (string, bool) { v, ok := env[name]; return v, ok } + + got := missingEnv(hints, lookup) + if len(got) != 2 { + t.Fatalf("erwartet JIRA_KEY (fehlt) und WIKI_KEY (leer), bekommen: %q", got) + } + if !strings.Contains(got[0], "pub-jira") || !strings.Contains(got[0], "JIRA_KEY") || + !strings.Contains(got[1], "WIKI_KEY") { + t.Errorf("falsche Meldungen: %q", got) + } + if missingEnv(hints[:1], lookup) != nil { + t.Error("gesetzte Variable darf nichts melden") + } +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..62bf837 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,18 @@ +module hugin/cli + +go 1.26.4 + +require ( + github.com/spf13/cobra v1.10.2 + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f + github.com/zalando/go-keyring v0.2.8 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.27.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..ca39003 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,33 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/api/api.go b/cli/internal/api/api.go new file mode 100644 index 0000000..b5a4824 --- /dev/null +++ b/cli/internal/api/api.go @@ -0,0 +1,279 @@ +// Package api spricht mit dem Hugin-Server unter /api/v1 (→ KONZEPT.md 14.6). +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type Client struct { + Base string // z. B. https://hugin.intern + Key string + HTTP *http.Client +} + +// AllowHttp: Umgebungsvariable, die Keys auch unverschlüsselt an andere Rechner schickt (nur zum Testen). +const AllowHttp = "HUGIN_ALLOW_HTTP" + +// New baut einen Client. Keys gehen nur über HTTPS oder an den eigenen Rechner; +// das gilt auch für Weiterleitungen. +func New(base, key string) (*Client, error) { + if err := safe(base); err != nil { + return nil, err + } + c := &Client{Base: strings.TrimSuffix(base, "/"), Key: key, HTTP: &http.Client{Timeout: 60 * time.Second}} + c.HTTP.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("zu viele Weiterleitungen") + } + return safe(req.URL.String()) + } + return c, nil +} + +func safe(address string) error { + u, err := url.Parse(address) + if err != nil || u.Host == "" { + return fmt.Errorf("Server %q: keine gültige Adresse (z. B. https://hugin.intern)", address) + } + switch { + case u.Scheme == "https": + return nil + case u.Scheme != "http": + return fmt.Errorf("Server %q: nur https:// (oder http:// zum eigenen Rechner)", address) + } + host := u.Hostname() + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return nil + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return nil + } + if os.Getenv(AllowHttp) == "1" { + fmt.Fprintf(os.Stderr, "hugin: Warnung: Key geht unverschlüsselt an %s (%s=1)\n", u.Host, AllowHttp) + return nil + } + return fmt.Errorf("Server %s: Keys gehen nicht unverschlüsselt über http:// an andere Rechner; https:// nutzen (zum Testen: %s=1)", u.Host, AllowHttp) +} + +// BundleRequest entspricht contract/bundle-request.schema.json. +type BundleRequest struct { + Packages []string `json:"packages"` + Unsubscribed []string `json:"unsubscribed,omitempty"` + Tools map[string]string `json:"tools"` + Mandatory bool `json:"mandatory"` + Major bool `json:"major,omitempty"` + Options map[string]bool `json:"options,omitempty"` + Installed map[string]string `json:"installed,omitempty"` +} + +// Bundle holt das ZIP für die Anfrage. +func (c *Client) Bundle(request BundleRequest) ([]byte, error) { + return c.post("/api/v1/bundle", request, "application/zip") +} + +// Configuration entspricht GET /api/v1/config. +type Configuration struct { + Floors map[string]string `json:"floors"` + Mandatory []string `json:"mandatory"` + CLI struct { + MinVersion string `json:"min_version"` + Newest string `json:"newest"` + } `json:"cli"` +} + +func (c *Client) Configuration() (*Configuration, error) { + var k Configuration + return &k, c.get("/api/v1/config", &k) +} + +// Matches ist ein Eintrag im Katalog (GET /api/v1/packages). +type Matches struct { + ID string `json:"id"` + Kind string `json:"kind"` + Version string `json:"version"` + Description string `json:"description"` + Risk string `json:"risk"` + Compatibility string `json:"compatibility"` +} + +func (c *Client) Search(text, kind string) ([]Matches, error) { + path := "/api/v1/packages?q=" + url.QueryEscape(text) + if kind != "" { + path += "&kind=" + url.QueryEscape(kind) + } + var t []Matches + return t, c.get(path, &t) +} + +func (c *Client) get(path string, target any) error { + req, err := http.NewRequest(http.MethodGet, c.Base+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Key) + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("Hugin nicht erreichbar: %w", err) + } + defer resp.Body.Close() + response, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return err + } + if resp.StatusCode >= 300 { + text, _ := fehlertext(response) + return &APIError{Status: resp.StatusCode, Text: "GET " + strings.SplitN(path, "?", 2)[0] + ": " + resp.Status + " " + text} + } + return json.Unmarshal(response, target) +} + +// Finding ist eine Meldung des Linters (→ KONZEPT.md 8). +type Finding struct { + Id string `json:"id"` + Severity string `json:"severity"` + Text string `json:"text"` + File string `json:"file"` +} + +// ValidationResult ist die Antwort von POST /api/v1/validate. +type ValidationResult struct { + Findings []Finding `json:"findings"` + Risk string `json:"risk"` + Compatibility *string `json:"compatibility"` + Preview map[string][]map[string]any `json:"preview"` +} + +func (c *Client) Validate(packageZip []byte) (*ValidationResult, error) { + raw, err := c.send("/api/v1/validate", packageZip, "application/zip", "application/json") + if err != nil { + return nil, err + } + var p ValidationResult + return &p, json.Unmarshal(raw, &p) +} + +// Trial liefert ein Bundle für einen ungespeicherten Paketordner. +func (c *Client) Trial(packageZip []byte, tools map[string]string) ([]byte, error) { + var parts []string + for name, version := range tools { + parts = append(parts, name+":"+version) + } + path := "/api/v1/trial?tools=" + url.QueryEscape(strings.Join(parts, ",")) + return c.send(path, packageZip, "application/zip", "application/zip") +} + +// Version ist der Stand einer Version nach Hochladen oder Einreichen. +type Version struct { + Package string `json:"package"` + Version string `json:"version"` + Status string `json:"status"` + Risk string `json:"risk"` + Linter []Finding `json:"linter"` +} + +func (c *Client) Upload(packageZip []byte) (*Version, error) { + raw, err := c.send("/api/v1/drafts", packageZip, "application/zip", "application/json") + if err != nil { + return nil, err + } + var v Version + return &v, json.Unmarshal(raw, &v) +} + +func (c *Client) Submit(pkg, version string) (*Version, error) { + raw, err := c.send("/api/v1/packages/"+url.PathEscape(pkg)+"/versions/"+url.PathEscape(version)+"/submit", + nil, "application/json", "application/json") + if err != nil { + return nil, err + } + var v Version + return &v, json.Unmarshal(raw, &v) +} + +// APIError ist eine Fehlerantwort des Servers; bei 422 vom Linter mit Befunden. +type APIError struct { + Status int + Text string + Findings []Finding +} + +func (f *APIError) Error() string { return f.Text } + +// Usage entspricht der Meldung nach sync (→ KONZEPT.md 11.5, Schritt 7). +type Usage struct { + Repo string `json:"repo"` + Tools map[string]string `json:"tools"` + Installed map[string]string `json:"installed"` +} + +func (c *Client) Usage(n Usage) error { + _, err := c.post("/api/v1/usage", n, "application/json") + return err +} + +func (c *Client) post(path string, body any, accept string) ([]byte, error) { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + return c.send(path, raw, "application/json", accept) +} + +func (c *Client) send(path string, body []byte, inhaltstyp, accept string) ([]byte, error) { + req, err := http.NewRequest(http.MethodPost, c.Base+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", inhaltstyp) + req.Header.Set("Accept", accept) + req.Header.Set("Authorization", "Bearer "+c.Key) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("Hugin nicht erreichbar: %w", err) + } + defer resp.Body.Close() + response, err := io.ReadAll(io.LimitReader(resp.Body, 256<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + f := &APIError{Status: resp.StatusCode} + f.Text, f.Findings = fehlertext(response) + f.Text = fmt.Sprintf("%s %s: %s %s", http.MethodPost, strings.SplitN(path, "?", 2)[0], resp.Status, f.Text) + return nil, f + } + return response, nil +} + +func fehlertext(response []byte) (string, []Finding) { + var f struct { + Detail json.RawMessage `json:"detail"` + } + if json.Unmarshal(response, &f) == nil && len(f.Detail) > 0 { + var withFindings struct { + Findings []Finding `json:"findings"` + } + if json.Unmarshal(f.Detail, &withFindings) == nil && len(withFindings.Findings) > 0 { + return "vom Linter abgelehnt", withFindings.Findings + } + var text string + if json.Unmarshal(f.Detail, &text) == nil { + return text, nil + } + return string(f.Detail), nil + } + if len(response) > 200 { + response = response[:200] + } + return strings.TrimSpace(string(response)), nil +} diff --git a/cli/internal/api/secure_test.go b/cli/internal/api/secure_test.go new file mode 100644 index 0000000..5851310 --- /dev/null +++ b/cli/internal/api/secure_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestKeysOnlyEncryptedOrLocal(t *testing.T) { + t.Setenv(AllowHttp, "") + for _, good := range []string{"https://hugin.intern", "http://127.0.0.1:8080", "http://localhost:8080", "http://[::1]:8080"} { + if _, err := New(good, "k"); err != nil { + t.Errorf("%s: %v", good, err) + } + } + for _, bad := range []string{"http://hugin.intern", "http://10.0.0.5:8080", "ftp://x", "hugin.intern", ""} { + if _, err := New(bad, "k"); err == nil { + t.Errorf("%s sollte abgelehnt werden", bad) + } + } + t.Setenv(AllowHttp, "1") + if _, err := New("http://hugin.intern", "k"); err != nil { + t.Errorf("mit %s=1: %v", AllowHttp, err) + } +} + +func TestNoRedirectToHTTP(t *testing.T) { + t.Setenv(AllowHttp, "") + seen := "" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Authorization") + http.Redirect(w, r, "http://boese.example/api/v1/config", http.StatusFound) + })) + defer server.Close() + c, err := New(server.URL, "hk_geheim") + if err != nil { + t.Fatal(err) + } + if _, err := c.Configuration(); err == nil || !strings.Contains(err.Error(), "nicht unverschlüsselt") { + t.Errorf("Weiterleitung auf http:// an fremden Rechner muss an der Prüfung scheitern: %v", err) + } + if seen != "Bearer hk_geheim" { + t.Errorf("erste Anfrage: %q", seen) + } +} diff --git a/cli/internal/bundle/bundle.go b/cli/internal/bundle/bundle.go new file mode 100644 index 0000000..85e1e2c --- /dev/null +++ b/cli/internal/bundle/bundle.go @@ -0,0 +1,313 @@ +// Package bundle liest Hugin-Bundles: ZIP (oder entpackter Ordner) mit +// hugin-bundle.json, den Dateien fürs Repo unter files/ und denen lokaler Server +// unter local/ (→ contract/hugin-bundle.schema.json). +package bundle + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "hugin/cli/internal/paths" +) + +const ( + InstructionsFile = "hugin-bundle.json" + Format = 1 + // Obergrenze je Datei im Bundle, gegen ZIP-Bomben + maxFile = 16 << 20 +) + +type Instructions struct { + Format int `json:"format"` + Generated string `json:"generated"` + Tools []string `json:"tools"` + Packages []Package `json:"packages"` + Operations []Operation `json:"operations"` + Hints []Hint `json:"hints"` +} + +type Package struct { + ID string `json:"id"` + Version string `json:"version"` + Checksum string `json:"checksum"` + Kind string `json:"kind"` + Risk string `json:"risk"` + Compatibility string `json:"compatibility"` + Mandatory bool `json:"mandatory"` +} + +// Operation fasst alle Arten zusammen; welche Felder gelten, hängt von Op ab. +type Operation struct { + Op string `json:"op"` + Package string `json:"package,omitempty"` + Path string `json:"path,omitempty"` + Source string `json:"source,omitempty"` + Sha256 string `json:"sha256,omitempty"` + File string `json:"file,omitempty"` + Block string `json:"block,omitempty"` + Content *string `json:"content,omitempty"` + Key []string `json:"key,omitempty"` + Mode string `json:"mode,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + Create string `json:"create,omitempty"` + // install: lokaler Server außerhalb des Repos + Version string `json:"version,omitempty"` + Checksum string `json:"checksum,omitempty"` + Runtime string `json:"runtime,omitempty"` + Entry string `json:"entry,omitempty"` + Python string `json:"python,omitempty"` + Files []InstallFile `json:"files,omitempty"` +} + +// InstallFile ist eine Datei eines lokalen Servers; Path gilt relativ zu seinem Ordner. +type InstallFile struct { + Path string `json:"path"` + Source string `json:"source"` + Sha256 string `json:"sha256"` +} + +// Installs liefert die install-Operationen, also die lokalen Server des Bundles. +func (b *Bundle) Installs() []Operation { + var ops []Operation + for _, op := range b.Instructions.Operations { + if op.Op == "install" { + ops = append(ops, op) + } + } + return ops +} + +// ListItem sagt, ob Value in einer Liste stehen soll statt den Schlüssel zu ersetzen. +func (o Operation) ListItem() bool { return o.Mode == "list_item" } + +type Hint struct { + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + Package string `json:"package,omitempty"` + Tool string `json:"tool,omitempty"` + Version string `json:"version,omitempty"` + Successor string `json:"successor,omitempty"` +} + +type Bundle struct { + Instructions Instructions + files map[string][]byte +} + +// Empty ist ein Bundle ohne Pakete; angewendet entfernt es alles, was die Lock-Datei nennt. +func Empty() *Bundle { + return &Bundle{Instructions: Instructions{Format: Format}, files: map[string][]byte{}} +} + +// File liefert den Inhalt einer Datei im Bundle (Pfad wie in source/create). +func (b *Bundle) File(source string) ([]byte, error) { + data, ok := b.files[source] + if !ok { + return nil, fmt.Errorf("%s fehlt im Bundle", source) + } + return data, nil +} + +// Read öffnet ein Bundle als ZIP-Datei oder entpackten Ordner und prüft es. +func Read(path string) (*Bundle, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + var files map[string][]byte + if info.IsDir() { + files, err = fromFolder(path) + } else { + var raw []byte + if raw, err = os.ReadFile(path); err == nil { + files, err = fromZip(raw) + } + } + if err != nil { + return nil, err + } + return fromFiles(files) +} + +// FromZip liest ein Bundle aus den Bytes einer ZIP-Datei (Antwort von POST /bundle). +func FromZip(raw []byte) (*Bundle, error) { + files, err := fromZip(raw) + if err != nil { + return nil, err + } + return fromFiles(files) +} + +func fromZip(raw []byte) (map[string][]byte, error) { + z, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return nil, fmt.Errorf("Bundle ist kein ZIP: %w", err) + } + files := map[string][]byte{} + for _, f := range z.File { + if strings.HasSuffix(f.Name, "/") { + continue + } + if f.UncompressedSize64 > maxFile { + return nil, fmt.Errorf("%s ist zu groß", f.Name) + } + r, err := f.Open() + if err != nil { + return nil, err + } + data, err := io.ReadAll(io.LimitReader(r, maxFile+1)) + r.Close() + if err != nil { + return nil, err + } + files[f.Name] = data + } + return files, nil +} + +func fromFolder(folder string) (map[string][]byte, error) { + files := map[string][]byte{} + err := filepath.WalkDir(folder, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, err := filepath.Rel(folder, p) + if err != nil { + return err + } + data, err := os.ReadFile(p) + files[filepath.ToSlash(rel)] = data + return err + }) + return files, err +} + +func fromFiles(files map[string][]byte) (*Bundle, error) { + raw, ok := files[InstructionsFile] + if !ok { + return nil, fmt.Errorf("%s fehlt im Bundle", InstructionsFile) + } + var a Instructions + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("%s: %w", InstructionsFile, err) + } + b := &Bundle{Instructions: a, files: files} + return b, b.validate() +} + +func (b *Bundle) validate() error { + if b.Instructions.Format != Format { + return fmt.Errorf("Bundle-Format %d wird nicht unterstützt (erwartet %d); hugin aktualisieren", + b.Instructions.Format, Format) + } + var errs []error + for i, op := range b.Instructions.Operations { + if err := b.validateOp(op); err != nil { + errs = append(errs, fmt.Errorf("Operation %d (%s): %w", i+1, op.Op, err)) + } + } + return errors.Join(errs...) +} + +func (b *Bundle) validateOp(op Operation) error { + switch op.Op { + case "file": + if err := paths.Validate(op.Path); err != nil { + return err + } + return b.validateSource(op.Source, op.Sha256) + case "remove": + return paths.Validate(op.Path) + case "block": + if op.Content == nil || op.Block == "" { + return errors.New("block und inhalt sind Pflicht") + } + if op.Create != "" { + if err := b.validateSource(op.Create, ""); err != nil { + return err + } + } + return paths.Validate(op.File) + case "json", "json_remove": + if len(op.Key) == 0 { + return errors.New("key fehlt") + } + if op.Op == "json" && len(op.Value) == 0 { + return errors.New("value fehlt") + } + if op.Create != "" { + if err := b.validateSource(op.Create, ""); err != nil { + return err + } + } + return paths.Validate(op.File) + case "install": + return b.validateInstall(op) + } + return fmt.Errorf("unbekannte Operation %q; hugin aktualisieren", op.Op) +} + +var ( + packageId = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + semver = regexp.MustCompile(`^\d+\.\d+\.\d+$`) +) + +func (b *Bundle) validateInstall(op Operation) error { + // Paket und Version werden Ordnernamen außerhalb des Repos: streng prüfen + if !packageId.MatchString(op.Package) || !semver.MatchString(op.Version) { + return fmt.Errorf("ungültiges Paket %q oder Version %q", op.Package, op.Version) + } + if op.Entry == "" || op.Checksum == "" { + return errors.New("entry und checksum sind Pflicht") + } + prefix := "local/" + op.Package + "/" + op.Version + "/" + for _, f := range op.Files { + if err := paths.Validate(f.Path); err != nil { + return err + } + if f.Source != prefix+f.Path { + return fmt.Errorf("source %q passt nicht zu %s%s", f.Source, prefix, f.Path) + } + data, err := b.File(f.Source) + if err != nil { + return err + } + if Sha256(data) != f.Sha256 { + return fmt.Errorf("%s: Prüfsumme stimmt nicht", f.Source) + } + } + return nil +} + +func (b *Bundle) validateSource(source, sha string) error { + if !strings.HasPrefix(source, "files/") { + return fmt.Errorf("source %q liegt nicht unter files/", source) + } + if err := paths.Validate(source); err != nil { + return err + } + data, err := b.File(source) + if err != nil { + return err + } + if sha != "" && Sha256(data) != sha { + return fmt.Errorf("%s: Prüfsumme stimmt nicht", source) + } + return nil +} + +func Sha256(data []byte) string { + total := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(total[:]) +} diff --git a/cli/internal/bundle/bundle_test.go b/cli/internal/bundle/bundle_test.go new file mode 100644 index 0000000..0e9ebdd --- /dev/null +++ b/cli/internal/bundle/bundle_test.go @@ -0,0 +1,92 @@ +package bundle + +import ( + "archive/zip" + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const examples = "../../../contract/examples/bundles" + +func TestReadExamples(t *testing.T) { + entries, err := os.ReadDir(examples) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if _, err := Read(filepath.Join(examples, e.Name())); err != nil { + t.Errorf("%s: %v", e.Name(), err) + } + } +} + +func zipWith(t *testing.T, instructions map[string]any, files map[string]string) []byte { + t.Helper() + var buffer bytes.Buffer + z := zip.NewWriter(&buffer) + raw, _ := json.Marshal(instructions) + files[InstructionsFile] = string(raw) + for name, content := range files { + w, _ := z.Create(name) + w.Write([]byte(content)) + } + z.Close() + return buffer.Bytes() +} + +func instructions(ops ...map[string]any) map[string]any { + return map[string]any{"format": 1, "generated": "2026-10-18T12:00:00Z", "tools": []string{"claude"}, + "packages": []any{}, "operations": ops, "hints": []any{}} +} + +func install(pkg, version, path, source, sha string) map[string]any { + return map[string]any{"op": "install", "package": pkg, "version": version, "checksum": sha, "runtime": "python", + "entry": "x", "files": []any{map[string]any{"path": path, "source": source, "sha256": sha}}} +} + +func TestInvalidBundles(t *testing.T) { + content := "x" + sha := Sha256([]byte(content)) + tests := []struct { + name string + zip []byte + message string + }{ + {"Pfad nach draußen", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x", + "path": "../x", "source": "files/x", "sha256": sha}), map[string]string{"files/x": content}), "Segment"}, + {"absoluter Pfad", zipWith(t, instructions(map[string]any{"op": "remove", "package": "pub-x", + "path": "/etc/passwd"}), map[string]string{}), "relative"}, + {"Windows-Pfad", zipWith(t, instructions(map[string]any{"op": "remove", "package": "pub-x", + "path": `C:\x`}), map[string]string{}), "relative"}, + {"falsche Prüfsumme", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x", + "path": "x", "source": "files/x", "sha256": Sha256([]byte("y"))}), map[string]string{"files/x": content}), "Prüfsumme"}, + {"Quelle fehlt", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x", + "path": "x", "source": "files/x", "sha256": sha}), map[string]string{}), "fehlt"}, + {"Quelle außerhalb files/", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x", + "path": "x", "source": "x", "sha256": sha}), map[string]string{"x": content}), "files/"}, + {"unbekannte Operation", zipWith(t, instructions(map[string]any{"op": "ausfuehren"}), map[string]string{}), "unbekannte"}, + {"lokaler Server: Paket als Pfad", zipWith(t, instructions(install("../x", "1.0.0", "x", "local/../x/1.0.0/x", sha)), + map[string]string{}), "ungültiges Paket"}, + {"lokaler Server: Version als Pfad", zipWith(t, instructions(install("pub-x", "..", "x", "local/pub-x/../x", sha)), + map[string]string{}), "ungültiges Paket"}, + {"lokaler Server: Datei nach draußen", zipWith(t, instructions(install("pub-x", "1.0.0", "../x", "local/pub-x/1.0.0/../x", sha)), + map[string]string{}), "Segment"}, + {"lokaler Server: Quelle passt nicht", zipWith(t, instructions(install("pub-x", "1.0.0", "x", "local/pub-y/1.0.0/x", sha)), + map[string]string{"local/pub-y/1.0.0/x": content}), "passt nicht"}, + {"lokaler Server: falsche Prüfsumme", zipWith(t, instructions(install("pub-x", "1.0.0", "x", "local/pub-x/1.0.0/x", Sha256([]byte("y")))), + map[string]string{"local/pub-x/1.0.0/x": content}), "Prüfsumme"}, + {"neues Format", zipWith(t, map[string]any{"format": 2}, map[string]string{}), "Format"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := FromZip(tt.zip) + if err == nil || !strings.Contains(err.Error(), tt.message) { + t.Errorf("Fehler %v, erwartet %q", err, tt.message) + } + }) + } +} diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go new file mode 100644 index 0000000..6fbeceb --- /dev/null +++ b/cli/internal/config/config.go @@ -0,0 +1,92 @@ +// Package config liest und schreibt .hugin.yaml (→ KONZEPT.md 11.2). +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const Filename = ".hugin.yaml" + +type Config struct { + Server string `yaml:"server"` + Project string `yaml:"project"` + // "auto" oder Liste aus claude, opencode + Tools Tools `yaml:"tools"` + OpencodeReadsClaude *bool `yaml:"opencode_reads_claude,omitempty"` + // warn | strict + ToolCheck string `yaml:"tool_check,omitempty"` + Packages []string `yaml:"packages"` + Unsubscribed []string `yaml:"unsubscribed"` +} + +// Tools ist entweder automatisch (leer) oder eine feste Liste. +type Tools struct { + Auto bool + List []string +} + +func (w Tools) MarshalYAML() (any, error) { + if w.Auto || len(w.List) == 0 { + return "auto", nil + } + return w.List, nil +} + +func (w *Tools) UnmarshalYAML(n *yaml.Node) error { + if n.Kind == yaml.ScalarNode { + if n.Value != "auto" { + return fmt.Errorf("tools: %q, erwartet auto oder eine Liste", n.Value) + } + *w = Tools{Auto: true} + return nil + } + var items []string + if err := n.Decode(&items); err != nil { + return err + } + for _, name := range items { + if name != "claude" && name != "opencode" { + return fmt.Errorf("tools: unbekanntes Werkzeug %q", name) + } + } + *w = Tools{List: items} + return nil +} + +func (k *Config) ReadsClaude() bool { return k.OpencodeReadsClaude == nil || *k.OpencodeReadsClaude } + +func (k *Config) Strict() bool { return k.ToolCheck == "strict" } + +func Load(repo string) (*Config, error) { + raw, err := os.ReadFile(filepath.Join(repo, Filename)) + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%s fehlt; zuerst hugin init aufrufen", Filename) + } + if err != nil { + return nil, err + } + k := &Config{} + if err := yaml.Unmarshal(raw, k); err != nil { + return nil, fmt.Errorf("%s: %w", Filename, err) + } + switch k.ToolCheck { + case "", "warn", "strict": + default: + return nil, fmt.Errorf("%s: tool_check %q, erwartet warn oder strict", Filename, k.ToolCheck) + } + return k, nil +} + +func (k *Config) Save(repo string) error { + raw, err := yaml.Marshal(k) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(repo, Filename), raw, 0o644) +} diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go new file mode 100644 index 0000000..0434ce3 --- /dev/null +++ b/cli/internal/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, text string) string { + t.Helper() + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, Filename), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + return repo +} + +func TestLoadToolsAutoAndList(t *testing.T) { + k, err := Load(write(t, "server: https://hugin.intern\nproject: shop\ntools: auto\npackages: [pub-a]\n")) + if err != nil { + t.Fatal(err) + } + if !k.Tools.Auto || len(k.Packages) != 1 || !k.ReadsClaude() || k.Strict() { + t.Errorf("auto: %+v", k) + } + k, err = Load(write(t, "server: x\nproject: y\ntools: [claude, opencode]\ntool_check: strict\nopencode_reads_claude: false\n")) + if err != nil { + t.Fatal(err) + } + if k.Tools.Auto || strings.Join(k.Tools.List, ",") != "claude,opencode" || k.ReadsClaude() || !k.Strict() { + t.Errorf("Liste: %+v", k) + } +} + +func TestLoadRejects(t *testing.T) { + for _, text := range []string{ + "tools: immer\n", + "tools: [claude, cursor]\n", + "tool_check: hart\n", + } { + if _, err := Load(write(t, text)); err == nil { + t.Errorf("%q: Fehler erwartet", text) + } + } + if _, err := Load(t.TempDir()); err == nil || !strings.Contains(err.Error(), "hugin init") { + t.Errorf("fehlende Datei: %v", err) + } +} + +func TestSaveRoundTrip(t *testing.T) { + repo := t.TempDir() + in := &Config{Server: "https://hugin.intern", Project: "shop", Tools: Tools{List: []string{"opencode"}}, + Packages: []string{"pub-a", "pub-b"}, Unsubscribed: []string{"pub-c"}} + if err := in.Save(repo); err != nil { + t.Fatal(err) + } + out, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if out.Server != in.Server || strings.Join(out.Tools.List, ",") != "opencode" || + strings.Join(out.Packages, ",") != "pub-a,pub-b" || out.Unsubscribed[0] != "pub-c" { + t.Errorf("zurückgelesen: %+v", out) + } + raw, _ := os.ReadFile(filepath.Join(repo, Filename)) + if !strings.Contains(string(raw), "tools:\n - opencode") { + t.Errorf("tools fehlt: %s", raw) + } +} + +func TestEmptyToolsIsSavedAsAuto(t *testing.T) { + repo := t.TempDir() + if err := (&Config{Server: "x", Project: "y"}).Save(repo); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(repo, Filename)) + if !strings.Contains(string(raw), "tools: auto") { + t.Errorf("erwartet tools: auto, war:\n%s", raw) + } +} diff --git a/cli/internal/importer/importer.go b/cli/internal/importer/importer.go new file mode 100644 index 0000000..1239f0a --- /dev/null +++ b/cli/internal/importer/importer.go @@ -0,0 +1,943 @@ +// Package importieren wandelt vorhandene Agenten-Dateien eines Repos in +// Hugin-Pakete im Quellformat um (hugin import). +// +// OpenCode zuerst: Gibt es denselben Namen an mehreren Orten, gewinnt die +// OpenCode-Fassung. Werkzeugspezifische Felder bleiben in extra.opencode bzw. +// extra.claude erhalten, damit nach dem Rückweg durch Hugin dasselbe ankommt. +package importer + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/tailscale/hujson" + "gopkg.in/yaml.v3" +) + +type Options struct { + Repo string + Target string + Prefix string // z. B. "pub-" oder "shop-" + Owner string // Projekt-Slug + Overwrite bool +} + +type Package struct { + ID string + Kind string + Sources []string + Hints []string +} + +type Report struct { + Packages []Package + Skipped []Reason +} + +type Reason struct { + Source string + Text string +} + +// manifest entspricht hugin.yaml; Reihenfolge der Felder = Reihenfolge in der Datei. +type manifest struct { + ID string `yaml:"id"` + Kind string `yaml:"kind"` + Version string `yaml:"version"` + Owner string `yaml:"owner"` + Scope string `yaml:"scope"` + Description string `yaml:"description"` + License string `yaml:"license,omitempty"` + Arguments []string `yaml:"arguments,omitempty,flow"` + Capability any `yaml:"capability,omitempty"` + Model string `yaml:"model,omitempty"` + Server *mcpServer `yaml:"server,omitempty"` + Extra map[string]map[string]*yaml.Node `yaml:"extra,omitempty"` +} + +type mcpServer struct { + Name string `yaml:"name"` + URL string `yaml:"url"` + Headers map[string]string `yaml:"headers,omitempty"` +} + +type draft struct { + manifest + contentFile string + content string + files map[string][]byte // Hilfsdateien + sources []string + hints []string +} + +type importer struct { + opt Options + packages map[string]*draft // nach ID + order []string + report *Report + opencode map[string]any + ocNode *yaml.Node +} + +// Import liest das Repo und schreibt die Pakete nach opt.Target. +func Import(opt Options) (*Report, error) { + if !idPattern.MatchString(strings.TrimSuffix(opt.Prefix, "-")) || !strings.HasSuffix(opt.Prefix, "-") { + return nil, fmt.Errorf("--prefix %q: z. B. pub- (öffentlich) oder - / -", opt.Prefix) + } + in := &importer{opt: opt, packages: map[string]*draft{}, report: &Report{}} + var err error + if in.opencode, in.ocNode, err = readJson(opt.Repo, "opencode.json", "opencode.jsonc"); err != nil { + return nil, err + } + for _, step := range []func() error{in.skills, in.agents, in.commands, in.rules, in.mcp, in.plugins} { + if err := step(); err != nil { + return nil, err + } + } + return in.report, in.write() +} + +// ---- Hilfen ---------------------------------------------------------------- + +var ( + idPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + notAlnum = regexp.MustCompile(`[^a-z0-9]+`) + managed = "Verwaltet von Hugin (" +) + +func (in *importer) id(name string) string { + slug := strings.Trim(notAlnum.ReplaceAllString(strings.ToLower(name), "-"), "-") + if !strings.HasPrefix(slug+"-", in.opt.Prefix) { + slug = in.opt.Prefix + slug + } + if len(slug) > 64 { + slug = strings.TrimRight(slug[:64], "-") + } + return slug +} + +func (in *importer) scope() string { + if in.opt.Prefix == "pub-" { + return "public" + } + return "tenant" +} + +func (in *importer) next(kind, name, description, source string) (*draft, bool) { + id := in.id(name) + if existing, ok := in.packages[id]; ok { + return existing, false + } + e := &draft{manifest: manifest{ID: id, Kind: kind, Version: "1.0.0", Owner: in.opt.Owner, + Scope: in.scope(), Description: singleLine(description)}, files: map[string][]byte{}} + if e.Description == "" { + e.Description = "TODO: Wann und wofür der Agent das nutzt." + e.hints = append(e.hints, "keine Beschreibung gefunden: in hugin.yaml ergänzen") + } + in.packages[id] = e + in.order = append(in.order, id) + e.sources = append(e.sources, source) + return e, true +} + +func (in *importer) skip(source, text string) { + in.report.Skipped = append(in.report.Skipped, Reason{source, text}) +} + +// extra übernimmt einen Wert unverändert, mit Reihenfolge (z. B. Muster unter +// permission.bash: in OpenCode gewinnt die letzte passende Regel). +func (e *draft) extra(tool, field string, value *yaml.Node) { + if value == nil { + return + } + clearStyle(value) + if e.Extra == nil { + e.Extra = map[string]map[string]*yaml.Node{} + } + if e.Extra[tool] == nil { + e.Extra[tool] = map[string]*yaml.Node{} + } + e.Extra[tool][field] = value +} + +func singleLine(s string) string { return strings.Join(strings.Fields(s), " ") } + +// child liefert den Wert eines Schlüssels in einem YAML-Mapping (oder nil). +func child(n *yaml.Node, key string) *yaml.Node { + if n != nil && n.Kind == yaml.DocumentNode && len(n.Content) == 1 { + n = n.Content[0] + } + if n == nil || n.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(n.Content); i += 2 { + if n.Content[i].Value == key { + return n.Content[i+1] + } + } + return nil +} + +// clearStyle schreibt Werte aus JSON als normales YAML (Block statt {…}). +func clearStyle(n *yaml.Node) { + n.Style &^= yaml.FlowStyle + if n.Kind == yaml.ScalarNode && n.Tag == "!!str" { + n.Style = 0 + } + n.HeadComment, n.LineComment, n.FootComment = "", "", "" + for _, c := range n.Content { + clearStyle(c) + } +} + +// frontmatter trennt YAML-Frontmatter vom Rumpf einer Markdown-Datei. Die Felder +// gibt es als Map (zum Auswerten) und als Knoten (zum Übernehmen). +func frontmatter(raw []byte) (map[string]any, *yaml.Node, string, error) { + text := strings.ReplaceAll(string(raw), "\r\n", "\n") + text = strings.TrimPrefix(text, "\ufeff") + if !strings.HasPrefix(text, "---\n") { + return map[string]any{}, nil, text, nil + } + end := strings.Index(text[4:], "\n---") + if end < 0 { + return nil, nil, "", errors.New("Frontmatter ohne Ende (---)") + } + header, body := text[4:4+end], text[4+end+4:] + body = strings.TrimPrefix(body, "\n") + fields, node := map[string]any{}, &yaml.Node{} + if err := yaml.Unmarshal([]byte(header), &fields); err != nil { + return nil, nil, "", fmt.Errorf("Frontmatter: %w", err) + } + if err := yaml.Unmarshal([]byte(header), node); err != nil { + return nil, nil, "", fmt.Errorf("Frontmatter: %w", err) + } + return fields, node, strings.TrimLeft(body, "\n"), nil +} + +func stringField(m map[string]any, field string) string { + if v, ok := m[field].(string); ok { + return v + } + return "" +} + +func isManaged(raw []byte) bool { + header := raw + if len(header) > 400 { + header = header[:400] + } + return bytes.Contains(header, []byte(managed)) +} + +func readJson(repo string, names ...string) (map[string]any, *yaml.Node, error) { + for _, name := range names { + p := filepath.Join(repo, name) + if info, err := os.Lstat(p); err == nil && info.Mode()&fs.ModeSymlink != 0 { + return nil, nil, fmt.Errorf("%s ist ein Symlink; Import liest nur Dateien im Repo", name) + } + raw, err := os.ReadFile(p) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, nil, err + } + value, err := hujson.Standardize(raw) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", name, err) + } + m, node := map[string]any{}, &yaml.Node{} + if err := yaml.Unmarshal(value, &m); err != nil { // JSON ist gültiges YAML + return nil, nil, fmt.Errorf("%s: %w", name, err) + } + if err := yaml.Unmarshal(value, node); err != nil { + return nil, nil, fmt.Errorf("%s: %w", name, err) + } + return m, node, nil + } + return map[string]any{}, nil, nil +} + +func object(m map[string]any, field string) map[string]any { + if v, ok := m[field].(map[string]any); ok { + return v + } + return map[string]any{} +} + +// markdownFiles liefert *.md unter den Ordnern (rekursiv), relativ zum Repo. +func (in *importer) markdownFiles(folder ...string) []string { + var result []string + for _, o := range folder { + base := filepath.Join(in.opt.Repo, o) + filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".md") || d.Type()&fs.ModeSymlink != 0 { + return nil + } + rel, _ := filepath.Rel(in.opt.Repo, p) + result = append(result, filepath.ToSlash(rel)) + return nil + }) + } + return result +} + +// read folgt keinen Symlinks: ein Import soll nichts von außerhalb des Repos mitnehmen. +func (in *importer) read(rel string) ([]byte, error) { + p := filepath.Join(in.opt.Repo, filepath.FromSlash(rel)) + info, err := os.Lstat(p) + if err != nil { + return nil, err + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s: keine normale Datei (Symlink?)", rel) + } + return os.ReadFile(p) +} + +// ---- Skills ---------------------------------------------------------------- + +// Bekannte Felder einer SKILL.md; der Rest sind Zusätze für Claude Code. +var skillCoreFields = map[string]bool{"name": true, "description": true, "license": true, "metadata": true, "compatibility": true} + +func (in *importer) skills() error { + for _, folder := range []string{".opencode/skills", ".opencode/skill", ".agents/skills", ".claude/skills"} { + entries, err := os.ReadDir(filepath.Join(in.opt.Repo, folder)) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return err + } + for _, d := range entries { + if !d.IsDir() { + continue + } + if err := in.skill(folder + "/" + d.Name()); err != nil { + return err + } + } + } + return nil +} + +func (in *importer) skill(folder string) error { + source := folder + "/SKILL.md" + raw, err := in.read(source) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + in.skip(source, err.Error()) + return nil + } + if isManaged(raw) { + in.skip(source, "wird schon von Hugin verwaltet") + return nil + } + fields, header, body, err := frontmatter(raw) + if err != nil { + in.skip(source, err.Error()) + return nil + } + name := stringField(fields, "name") + if name == "" { + name = filepath.Base(folder) + } + e, next := in.next("skill", name, stringField(fields, "description"), source) + if !next { + in.skip(source, fmt.Sprintf("gleicher Skill schon aus %s übernommen (OpenCode zuerst)", e.sources[0])) + return nil + } + e.contentFile, e.content = "SKILL.md", body + if e.ID != name { + e.hints = append(e.hints, fmt.Sprintf("Skill heißt künftig %s statt %s; Verweise in Prompts anpassen", e.ID, name)) + } + e.License = stringField(fields, "license") + if meta, ok := fields["metadata"].(map[string]any); ok && len(meta) > 0 { + e.extra("opencode", "metadata", child(header, "metadata")) + } + if _, ok := fields["compatibility"]; ok { + e.hints = append(e.hints, "Feld compatibility nicht übernommen (Hugin setzt die Werkzeuge über targets)") + } + for field := range fields { + if !skillCoreFields[field] { + e.extra("claude", field, child(header, field)) + } + } + // Hilfsdateien des Skill-Ordners + base := filepath.Join(in.opt.Repo, filepath.FromSlash(folder)) + return filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(base, p) + rel = filepath.ToSlash(rel) + if rel == "." || rel == "SKILL.md" { + return nil + } + if strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + e.hints = append(e.hints, "Symlink "+rel+" nicht übernommen") + return nil + } + if d.IsDir() { + return nil + } + data, err := os.ReadFile(p) + e.files[rel] = data + return err + }) +} + +// ---- Subagenten -------------------------------------------------------------- + +// Fähigkeitsklassen aus OpenCode-Rechten bzw. Claude-Werkzeugen (→ KONZEPT.md 7.4) +func capabilityFromPermissions(permissions map[string]any) []string { + allowed := func(field string, includeAsk bool) bool { + switch w := permissions[field].(type) { + case string: + return w == "allow" || (includeAsk && w == "ask") + case map[string]any: // Muster: erlaubt, wenn irgendein Muster nicht deny ist + for _, v := range w { + if s, _ := v.(string); s == "allow" || (includeAsk && s == "ask") { + return true + } + } + } + return false + } + classes := []string{"read"} + if allowed("edit", false) { + classes = append(classes, "write") + } + if allowed("bash", true) { + classes = append(classes, "shell") + } + if allowed("webfetch", false) || allowed("websearch", false) { + classes = append(classes, "web") + } + return classes +} + +func capabilityFromTools(tools string) []string { + classes := []string{"read"} + has := func(names ...string) bool { + for _, n := range names { + if regexp.MustCompile(`\b` + n + `\b`).MatchString(tools) { + return true + } + } + return false + } + if has("Edit", "Write", "MultiEdit") { + classes = append(classes, "write") + } + if has("Bash") { + classes = append(classes, "shell") + } + if has("WebFetch", "WebSearch") { + classes = append(classes, "web") + } + return classes +} + +func capabilityValue(classes []string) any { + if len(classes) == 1 { + return classes[0] + } + return classes +} + +// Felder eines OpenCode-Agenten, die Hugin selbst setzt +var agentCoreFields = map[string]bool{"description": true, "prompt": true, "name": true} + +func (in *importer) agents() error { + for _, source := range in.markdownFiles(".opencode/agents", ".opencode/agent") { + raw, err := in.read(source) + if err != nil { + in.skip(source, err.Error()) + continue + } + if isManaged(raw) { + in.skip(source, "wird schon von Hugin verwaltet") + continue + } + fields, header, body, err := frontmatter(raw) + if err != nil { + in.skip(source, err.Error()) + continue + } + name := strings.TrimSuffix(filepath.Base(source), ".md") + in.opencodeAgent(name, fields, header, body, source) + } + // Agenten direkt in opencode.json + agents := object(in.opencode, "agent") + for _, name := range sorted(agents) { + fields, _ := agents[name].(map[string]any) + prompt := stringField(fields, "prompt") + if prompt == "" || strings.HasPrefix(prompt, "{file:") { + in.skip("opencode.json agent."+name, "ohne eigenen Prompt (Einstellungen eines eingebauten Agenten oder {file:…})") + continue + } + in.opencodeAgent(name, fields, child(child(in.ocNode, "agent"), name), prompt, "opencode.json agent."+name) + } + // Claude Code: neue Agenten oder Zusätze zu schon übernommenen + for _, source := range in.markdownFiles(".claude/agents") { + raw, err := in.read(source) + if err != nil { + in.skip(source, err.Error()) + continue + } + if isManaged(raw) { + in.skip(source, "wird schon von Hugin verwaltet") + continue + } + fields, header, body, err := frontmatter(raw) + if err != nil { + in.skip(source, err.Error()) + continue + } + name := stringField(fields, "name") + if name == "" { + name = strings.TrimSuffix(filepath.Base(source), ".md") + } + e, next := in.next("subagent", name, stringField(fields, "description"), source) + if !next { + if e.Kind == "subagent" { + e.sources = append(e.sources, source) + e.hints = append(e.hints, "Prompt aus der OpenCode-Fassung; "+source+" nur für Claude-Felder genutzt") + in.claudeAgentFields(e, fields, header) + } + continue + } + e.contentFile, e.content, e.Model = "AGENT.md", body, "inherit" + e.Capability = capabilityValue(capabilityFromTools(fmt.Sprint(fields["tools"]))) + in.claudeAgentFields(e, fields, header) + } + return nil +} + +func (in *importer) opencodeAgent(name string, fields map[string]any, header *yaml.Node, prompt, source string) { + e, next := in.next("subagent", name, stringField(fields, "description"), source) + if !next { + in.skip(source, "gleicher Name schon übernommen aus "+e.sources[0]) + return + } + e.contentFile, e.content, e.Model = "AGENT.md", strings.TrimLeft(prompt, "\n"), "inherit" + permissions, _ := fields["permission"].(map[string]any) + e.Capability = capabilityValue(capabilityFromPermissions(permissions)) + for field := range fields { + if !agentCoreFields[field] { + e.extra("opencode", field, child(header, field)) + } + } + if _, ok := fields["mode"]; !ok { + e.hints = append(e.hints, "ohne mode: OpenCode nutzte den Agenten auch als Hauptagent, Hugin liefert ihn als Subagent (mode in extra.opencode setzen, falls nötig)") + } + if _, ok := fields["tools"]; ok { + e.hints = append(e.hints, "OpenCode-Feld tools ist veraltet; besser permission nutzen") + } +} + +func (in *importer) claudeAgentFields(e *draft, fields map[string]any, header *yaml.Node) { + for field := range fields { + switch field { + case "name", "description", "tools": + default: + e.extra("claude", field, child(header, field)) + } + } +} + +func sorted(m map[string]any) []string { + names := make([]string, 0, len(m)) + for k := range m { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// ---- Befehle ------------------------------------------------------------------- + +var ( + numberedOpenCode = regexp.MustCompile(`\$([1-9][0-9]*)\b`) + numberedClaude = regexp.MustCompile(`\$ARGUMENTS\[([0-9]+)\]|\$([0-9]+)\b`) +) + +// Platzhalter in {{argN}} umwandeln; ab bestimmt, ob $1 (OpenCode) oder $0 (Claude) das erste ist. +func placeholder(text string, pattern *regexp.Regexp, from int) (string, []string) { + highest := 0 + next := pattern.ReplaceAllStringFunc(text, func(t string) string { + parts := pattern.FindStringSubmatch(t) + number := parts[1] + if number == "" && len(parts) > 2 { + number = parts[2] + } + var n int + fmt.Sscanf(number, "%d", &n) + n = n - from + 1 + if n > highest { + highest = n + } + return fmt.Sprintf("{{arg%d}}", n) + }) + arguments := make([]string, highest) + for i := range arguments { + arguments[i] = fmt.Sprintf("arg%d", i+1) + } + return next, arguments +} + +var commandCoreFields = map[string]bool{"description": true, "template": true} + +func (in *importer) commands() error { + for _, source := range in.markdownFiles(".opencode/commands", ".opencode/command") { + raw, err := in.read(source) + if err != nil { + in.skip(source, err.Error()) + continue + } + if isManaged(raw) { + in.skip(source, "wird schon von Hugin verwaltet") + continue + } + fields, header, body, err := frontmatter(raw) + if err != nil { + in.skip(source, err.Error()) + continue + } + in.opencodeCommand(strings.TrimSuffix(filepath.Base(source), ".md"), fields, header, body, source) + } + commands := object(in.opencode, "command") + for _, name := range sorted(commands) { + fields, _ := commands[name].(map[string]any) + template := stringField(fields, "template") + if template == "" || strings.HasPrefix(template, "{file:") { + in.skip("opencode.json command."+name, "ohne Vorlage oder mit {file:…}") + continue + } + in.opencodeCommand(name, fields, child(child(in.ocNode, "command"), name), template, "opencode.json command."+name) + } + for _, source := range in.markdownFiles(".claude/commands") { + raw, err := in.read(source) + if err != nil { + in.skip(source, err.Error()) + continue + } + if isManaged(raw) { + in.skip(source, "wird schon von Hugin verwaltet") + continue + } + fields, header, body, err := frontmatter(raw) + if err != nil { + in.skip(source, err.Error()) + continue + } + name := strings.TrimSuffix(filepath.ToSlash(strings.TrimPrefix(source, ".claude/commands/")), ".md") + e, next := in.next("command", name, stringField(fields, "description"), source) + if !next { + in.skip(source, "gleicher Befehl schon übernommen aus "+e.sources[0]+" (OpenCode zuerst)") + continue + } + // Benannte Argumente von Claude Code ($name aus arguments) und nummerierte ($0, $ARGUMENTS[0]) + content := body + var arguments []string + if names, ok := fields["arguments"].([]any); ok { + for _, n := range names { + s := fmt.Sprint(n) + arguments = append(arguments, s) + content = regexp.MustCompile(`\$`+regexp.QuoteMeta(s)+`\b`).ReplaceAllString(content, "{{"+s+"}}") + } + } + if len(arguments) == 0 { + content, arguments = placeholder(content, numberedClaude, 0) + } + e.contentFile, e.content, e.Arguments = "COMMAND.md", content, arguments + for field := range fields { + switch field { + case "description", "arguments", "argument-hint": + default: + e.extra("claude", field, child(header, field)) + } + } + } + return nil +} + +func (in *importer) opencodeCommand(name string, fields map[string]any, header *yaml.Node, template, source string) { + e, next := in.next("command", name, stringField(fields, "description"), source) + if !next { + in.skip(source, "gleicher Name schon übernommen aus "+e.sources[0]) + return + } + content, arguments := placeholder(template, numberedOpenCode, 1) + e.contentFile, e.content, e.Arguments = "COMMAND.md", content, arguments + if len(arguments) > 0 { + e.hints = append(e.hints, fmt.Sprintf("Platzhalter $1 … in {{arg1}} … umbenannt (%d); sprechende Namen in COMMAND.md und arguments setzen", len(arguments))) + } + for field := range fields { + if !commandCoreFields[field] { + e.extra("opencode", field, child(header, field)) + } + } +} + +// ---- Regeln ---------------------------------------------------------------------- + +var hugBlock = regexp.MustCompile(`(?s)\n?\n?`) + +func (in *importer) rules() error { + repoName := filepath.Base(mustAbs(in.opt.Repo)) + agentsMd, err := in.read("AGENTS.md") + if err == nil { + in.rule(repoName+"-rules", "AGENTS.md", agentsMd) + } + if raw, err := in.read("CLAUDE.md"); err == nil { + if agentsMd != nil { + in.skip("CLAUDE.md", "es gibt eine AGENTS.md; die gilt für OpenCode (OpenCode 2 liest nur AGENTS.md)") + } else { + in.rule(repoName+"-rules", "CLAUDE.md", raw) + } + } + // Dateien aus instructions in opencode.json (lokale Pfade und Globs; URLs nicht) + entries, _ := in.opencode["instructions"].([]any) + for _, raw := range entries { + pattern := fmt.Sprint(raw) + if strings.Contains(pattern, "://") { + in.skip("opencode.json instructions "+pattern, "URL; Hugin übernimmt nur Dateien aus dem Repo") + continue + } + if strings.HasPrefix(pattern, ".hugin/") { + continue // verwaltet Hugin selbst + } + matches, _ := filepath.Glob(filepath.Join(in.opt.Repo, filepath.FromSlash(pattern))) + sort.Strings(matches) + for _, p := range matches { + rel, _ := filepath.Rel(in.opt.Repo, p) + rel = filepath.ToSlash(rel) + if strings.HasPrefix(rel, "../") { + in.skip("opencode.json instructions "+pattern, "liegt außerhalb des Repos") + continue + } + data, err := in.read(rel) + if err != nil { + in.skip(rel, err.Error()) + continue + } + in.rule(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)), rel, data) + } + } + return nil +} + +func (in *importer) rule(name, source string, raw []byte) { + text := strings.ReplaceAll(string(raw), "\r\n", "\n") + text = strings.TrimSpace(hugBlock.ReplaceAllString(text, "\n")) + if text == "" { + in.skip(source, "leer (oder nur der Block von Hugin)") + return + } + if isManaged(raw) { + in.skip(source, "wird schon von Hugin verwaltet") + return + } + title := "" + for _, row := range strings.Split(text, "\n") { + if strings.HasPrefix(row, "# ") { + title = strings.TrimPrefix(row, "# ") + break + } + } + description := "Regeln aus " + source + if title != "" { + description = title + " (aus " + source + ")" + } + e, next := in.next("rules", name, description, source) + if !next { + in.skip(source, "Name schon vergeben durch "+e.sources[0]) + return + } + e.contentFile, e.content = "RULES.md", text+"\n" + e.hints = append(e.hints, "Regeln aus einem Repo sind oft projektspezifisch: Geltung prüfen") +} + +func mustAbs(p string) string { + abs, err := filepath.Abs(p) + if err != nil { + return p + } + return abs +} + +// ---- MCP ---------------------------------------------------------------------------- + +var ( + envOpenCode = regexp.MustCompile(`\{env:([A-Za-z_][A-Za-z0-9_]*)\}`) + envClaude = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}`) + schemaWort = regexp.MustCompile(`^([A-Za-z]+)\s+`) +) + +// convertHeader wandelt Umgebungsvariablen um; feste Werte ersetzt sie durch eine Variable. +func convertHeader(server, name, value string, env *regexp.Regexp) (string, string) { + next := env.ReplaceAllString(value, "{{env:$1}}") + if strings.Contains(next, "{{env:") { + return next, "" + } + variable := strings.ToUpper(notAlnum.ReplaceAllString(strings.ToLower(server+"_"+name), "_")) + replacement := "{{env:" + variable + "}}" + if t := schemaWort.FindStringSubmatch(value); t != nil { + replacement = t[1] + " " + replacement + } + return replacement, fmt.Sprintf("Kopfzeile %s hatte einen festen Wert; ersetzt durch {{env:%s}} (Wert nicht übernommen)", name, variable) +} + +func (in *importer) mcp() error { + server := object(in.opencode, "mcp") + if s, ok := server["servers"].(map[string]any); ok { // OpenCode 2: mcp.servers + server = s + } + for _, name := range sorted(server) { + fields, _ := server[name].(map[string]any) + source := "opencode.json mcp." + name + if stringField(fields, "type") != "remote" || stringField(fields, "url") == "" { + in.skip(source, "lokaler MCP-Server (command); Hugin bindet nur entfernte Server an") + continue + } + in.mcpPackage(name, stringField(fields, "url"), object(fields, "headers"), source, envOpenCode) + } + claude, _, err := readJson(in.opt.Repo, ".mcp.json") + if err != nil { + return err + } + server = object(claude, "mcpServers") + for _, name := range sorted(server) { + fields, _ := server[name].(map[string]any) + source := ".mcp.json mcpServers." + name + typ := stringField(fields, "type") + if (typ != "http" && typ != "sse") || stringField(fields, "url") == "" { + in.skip(source, "lokaler MCP-Server (command); Hugin bindet nur entfernte Server an") + continue + } + if _, already := in.packages[in.id(name+"-mcp")]; already { + in.skip(source, "gleicher Server schon aus opencode.json übernommen") + continue + } + in.mcpPackage(name, stringField(fields, "url"), object(fields, "headers"), source, envClaude) + } + return nil +} + +func (in *importer) mcpPackage(name, url string, header map[string]any, source string, env *regexp.Regexp) { + e, next := in.next("mcp", name+"-mcp", "Bindet "+name+" als MCP-Server an.", source) + if !next { + in.skip(source, "Name schon vergeben durch "+e.sources[0]) + return + } + e.Server = &mcpServer{Name: name, URL: url} + for _, k := range sorted(header) { + value, hint := convertHeader(name, k, fmt.Sprint(header[k]), env) + if e.Server.Headers == nil { + e.Server.Headers = map[string]string{} + } + e.Server.Headers[k] = value + if hint != "" { + e.hints = append(e.hints, hint) + } + } +} + +// ---- Plugins ------------------------------------------------------------------------- + +func (in *importer) plugins() error { + for _, folder := range []string{".opencode/plugins", ".opencode/plugin"} { + entries, _ := os.ReadDir(filepath.Join(in.opt.Repo, folder)) + for _, d := range entries { + in.skip(folder+"/"+d.Name(), "OpenCode-Plugin: unterstützt Hugin noch nicht") + } + } + if items, ok := in.opencode["plugin"].([]any); ok { + for _, p := range items { + in.skip("opencode.json plugin "+fmt.Sprint(p), "OpenCode-Plugin: unterstützt Hugin noch nicht") + } + } + return nil +} + +// ---- Schreiben ----------------------------------------------------------------------- + +func (in *importer) write() error { + if err := os.MkdirAll(in.opt.Target, 0o755); err != nil { + return err + } + for _, id := range in.order { + e := in.packages[id] + folder := filepath.Join(in.opt.Target, id) + if _, err := os.Stat(folder); err == nil && !in.opt.Overwrite { + in.skip(strings.Join(e.sources, ", "), "Zielordner "+folder+" gibt es schon (--overwrite)") + continue + } + if err := os.RemoveAll(folder); err != nil { + return err + } + if err := os.MkdirAll(folder, 0o755); err != nil { + return err + } + var buffer bytes.Buffer + enc := yaml.NewEncoder(&buffer) + enc.SetIndent(2) + if err := enc.Encode(e.manifest); err != nil { + return err + } + header := fmt.Sprintf("# Importiert von hugin import aus: %s\n", strings.Join(e.sources, ", ")) + if err := os.WriteFile(filepath.Join(folder, "hugin.yaml"), append([]byte(header), buffer.Bytes()...), 0o644); err != nil { + return err + } + if e.contentFile != "" { + if err := os.WriteFile(filepath.Join(folder, e.contentFile), []byte(e.content), 0o644); err != nil { + return err + } + } + for rel, data := range e.files { + target := filepath.Join(folder, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.WriteFile(target, data, 0o644); err != nil { + return err + } + } + in.report.Packages = append(in.report.Packages, Package{e.ID, e.Kind, e.sources, e.hints}) + } + return os.WriteFile(filepath.Join(in.opt.Target, "IMPORT.md"), []byte(in.report.markdown()), 0o644) +} + +func (b *Report) markdown() string { + var s strings.Builder + s.WriteString("# Import nach Hugin\n\nVon `hugin import` erzeugt. Jedes Paket prüfen, dann:\n\n") + s.WriteString("```sh\nhugin validate \nhugin upload --submit \n```\n\n") + s.WriteString("## Pakete\n\n| Paket | Art | Quelle | Hinweise |\n|---|---|---|---|\n") + for _, p := range b.Packages { + fmt.Fprintf(&s, "| %s | %s | %s | %s |\n", p.ID, p.Kind, strings.Join(p.Sources, ", "), strings.Join(p.Hints, "; ")) + } + if len(b.Skipped) > 0 { + s.WriteString("\n## Nicht übernommen\n\n| Quelle | Grund |\n|---|---|\n") + for _, g := range b.Skipped { + fmt.Fprintf(&s, "| %s | %s |\n", g.Source, g.Text) + } + } + return s.String() +} diff --git a/cli/internal/importer/importer_test.go b/cli/internal/importer/importer_test.go new file mode 100644 index 0000000..9e5c251 --- /dev/null +++ b/cli/internal/importer/importer_test.go @@ -0,0 +1,332 @@ +package importer + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// Beispiel-Repo mit OpenCode- und Claude-Fassungen, teils mit gleichen Namen. +var example = map[string]string{ + ".opencode/skills/munin/SKILL.md": `--- +name: munin +description: Wie du Munin nutzt. +license: intern +metadata: + opencode/slash: "true" +--- +Nutze Munin. +`, + ".opencode/skills/munin/referenz/beispiele.md": "Beispiel\n", + ".claude/skills/munin/SKILL.md": `--- +name: munin +description: Claude-Fassung, soll verlieren. +--- +Alt. +`, + ".claude/skills/nur-claude/SKILL.md": `--- +name: nur-claude +description: Gibt es nur bei Claude. +allowed-tools: [Read] +--- +Inhalt. +`, + ".claude/skills/hugin-alt/SKILL.md": "---\nname: x\ndescription: y\n---\n\n", + ".opencode/agents/review.md": `--- +description: Prüft Änderungen. +mode: subagent +temperature: 0.1 +permission: + edit: deny + bash: + "git diff*": allow + "*": deny + webfetch: deny +--- +Du prüfst Code. +`, + ".claude/agents/review.md": `--- +name: review +description: Claude-Fassung. +tools: Read, Grep, Edit +model: sonnet +--- +Anderer Prompt. +`, + ".claude/agents/schreiber.md": `--- +name: schreiber +description: Schreibt Doku. +tools: Read, Write, WebFetch +--- +Du schreibst. +`, + ".opencode/commands/release.md": `--- +description: Release-Notiz +agent: build +--- +Notiz für Version $1, gedacht für $2. Weitere: $ARGUMENTS +`, + ".claude/commands/deploy.md": `--- +description: Deployt +arguments: [umgebung] +allowed-tools: Bash(make:*) +--- +Deploye nach $umgebung. +`, + ".claude/commands/release.md": "---\ndescription: verliert\n---\nx\n", + "AGENTS.md": "# Regeln des Shops\n\nImmer testen.\n\n\nalt\n\n", + "CLAUDE.md": "# Claude\n\nwird ignoriert, weil AGENTS.md da ist\n", + "doku/stil.md": "# Stil\n\nKurz schreiben.\n", + "opencode.json": `{ + // Kommentar + "instructions": ["doku/*.md", ".hugin/rules/*.md", "https://x.example/regeln.md"], + "mcp": { + "munin": {"type": "remote", "url": "https://munin.intern/mcp", "headers": {"Authorization": "Bearer {env:MUNIN_KEY}"}}, + "lokal": {"type": "local", "command": ["npx", "x"]} + }, + "agent": { + "build": {"model": "anthropic/claude-sonnet-5-5"}, + "doku": {"description": "Doku-Agent", "prompt": "Du dokumentierst.", "permission": {"edit": "allow"}} + }, + "command": {"kurz": {"template": "Fasse $1 zusammen.", "description": "Kurzfassung"}}, + "plugin": ["opencode-x"] +}`, + ".mcp.json": `{"mcpServers": { + "munin": {"type": "http", "url": "https://andere.url/mcp"}, + "jira": {"type": "http", "url": "https://jira.intern/mcp", "headers": {"Authorization": "Bearer geheim123"}} +}}`, + ".opencode/plugins/x.ts": "export default {}\n", +} + +func createRepo(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "shop-api") + for rel, content := range example { + p := filepath.Join(repo, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return repo +} + +func readManifest(t *testing.T, target, id string) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(target, id, "hugin.yaml")) + if err != nil { + t.Fatalf("%s: %v", id, err) + } + m := map[string]any{} + if err := yaml.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func file(t *testing.T, target, rel string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(target, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestImport(t *testing.T) { + repo := createRepo(t) + target := filepath.Join(t.TempDir(), "import") + b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"}) + if err != nil { + t.Fatal(err) + } + ids := map[string]string{} + for _, p := range b.Packages { + ids[p.ID] = p.Kind + } + expected := map[string]string{ + "shop-munin": "skill", "shop-nur-claude": "skill", + "shop-review": "subagent", "shop-doku": "subagent", "shop-schreiber": "subagent", + "shop-release": "command", "shop-kurz": "command", "shop-deploy": "command", + "shop-api-rules": "rules", "shop-stil": "rules", + "shop-munin-mcp": "mcp", "shop-jira-mcp": "mcp", + } + for id, kind := range expected { + if ids[id] != kind { + t.Errorf("%s: Art %q, erwartet %q", id, ids[id], kind) + } + } + if len(ids) != len(expected) { + t.Errorf("Pakete: %v", ids) + } + + // Skill: OpenCode-Fassung gewinnt, Hilfsdateien und metadata bleiben + m := readManifest(t, target, "shop-munin") + if m["description"] != "Wie du Munin nutzt." || m["scope"] != "tenant" || m["owner"] != "api" || m["license"] != "intern" { + t.Errorf("Manifest: %v", m) + } + if extra := m["extra"].(map[string]any)["opencode"].(map[string]any)["metadata"].(map[string]any); extra["opencode/slash"] != "true" { + t.Errorf("metadata: %v", extra) + } + if file(t, target, "shop-munin/SKILL.md") != "Nutze Munin.\n" || file(t, target, "shop-munin/referenz/beispiele.md") != "Beispiel\n" { + t.Error("Skill-Inhalt") + } + if strings.HasPrefix(file(t, target, "shop-munin/SKILL.md"), "---") { + t.Error("Inhaltsdatei darf keine Frontmatter haben") + } + // Claude-only Skill: unbekannte Felder als extra.claude + if z := readManifest(t, target, "shop-nur-claude")["extra"].(map[string]any)["claude"].(map[string]any); z["allowed-tools"] == nil { + t.Errorf("extra.claude: %v", z) + } + + // Subagent: Fähigkeit aus OpenCode-Rechten (bash mit Muster = shell), Rechte bleiben als Zusatz + m = readManifest(t, target, "shop-review") + if f, _ := m["capability"].([]any); len(f) != 2 || f[0] != "read" || f[1] != "shell" { + t.Errorf("capability: %v", m["capability"]) + } + oc := m["extra"].(map[string]any)["opencode"].(map[string]any) + if oc["mode"] != "subagent" || oc["temperature"] != 0.1 || oc["permission"] == nil { + t.Errorf("extra.opencode: %v", oc) + } + if cl := m["extra"].(map[string]any)["claude"].(map[string]any); cl["model"] != "sonnet" { + t.Errorf("Claude-Felder der Zweitfassung: %v", cl) + } + // Reihenfolge der Muster bleibt (in OpenCode gewinnt die letzte passende Regel) + if y := file(t, target, "shop-review/hugin.yaml"); strings.Index(y, "git diff*") > strings.Index(y, "'*': deny") { + t.Errorf("Reihenfolge unter permission.bash verloren:\n%s", y) + } + if y := file(t, target, "shop-doku/hugin.yaml"); !strings.Contains(y, "permission:\n edit: allow") { + t.Errorf("Rechte aus opencode.json als Block-YAML:\n%s", y) + } + if file(t, target, "shop-review/AGENT.md") != "Du prüfst Code.\n" { + t.Error("Prompt muss aus der OpenCode-Fassung kommen") + } + if f, _ := readManifest(t, target, "shop-schreiber")["capability"].([]any); len(f) != 3 || f[1] != "write" || f[2] != "web" { + t.Errorf("Fähigkeit aus Claude-Werkzeugen: %v", f) + } + if file(t, target, "shop-doku/AGENT.md") != "Du dokumentierst." { + t.Error("Agent aus opencode.json") + } + + // Befehle: $1/$2 → {{arg1}}/{{arg2}}, $ARGUMENTS bleibt; Claude-Namen bleiben benannt + m = readManifest(t, target, "shop-release") + if a, _ := m["arguments"].([]any); len(a) != 2 || a[0] != "arg1" { + t.Errorf("arguments: %v", m["arguments"]) + } + if s := file(t, target, "shop-release/COMMAND.md"); s != "Notiz für Version {{arg1}}, gedacht für {{arg2}}. Weitere: $ARGUMENTS\n" { + t.Errorf("COMMAND.md: %q", s) + } + if m["extra"].(map[string]any)["opencode"].(map[string]any)["agent"] != "build" { + t.Error("agent als extra.opencode") + } + if s := file(t, target, "shop-deploy/COMMAND.md"); s != "Deploye nach {{umgebung}}.\n" { + t.Errorf("Claude-Befehl: %q", s) + } + + // Regeln: AGENTS.md ohne Hugin-Block, CLAUDE.md übersprungen, instructions-Datei übernommen + if s := file(t, target, "shop-api-rules/RULES.md"); s != "# Regeln des Shops\n\nImmer testen.\n" { + t.Errorf("RULES.md: %q", s) + } + if readManifest(t, target, "shop-api-rules")["description"] != "Regeln des Shops (aus AGENTS.md)" { + t.Error("Beschreibung aus Überschrift") + } + + // MCP: OpenCode-Fassung gewinnt; feste Kopfzeile wird zur Variable, Wert nie übernommen + m = readManifest(t, target, "shop-munin-mcp") + s := m["server"].(map[string]any) + if s["url"] != "https://munin.intern/mcp" || s["headers"].(map[string]any)["Authorization"] != "Bearer {{env:MUNIN_KEY}}" { + t.Errorf("MCP munin: %v", s) + } + jira := file(t, target, "shop-jira-mcp/hugin.yaml") + if strings.Contains(jira, "geheim123") || !strings.Contains(jira, "Bearer {{env:JIRA_AUTHORIZATION}}") { + t.Errorf("Geheimnis darf nicht ins Paket:\n%s", jira) + } + + // Übersprungen mit Grund + reasons := map[string]string{} + for _, g := range b.Skipped { + reasons[g.Source] = g.Text + } + for source, part := range map[string]string{ + ".claude/skills/munin/SKILL.md": "OpenCode zuerst", + ".claude/skills/hugin-alt/SKILL.md": "von Hugin verwaltet", + ".claude/commands/release.md": "OpenCode zuerst", + "CLAUDE.md": "AGENTS.md", + "opencode.json mcp.lokal": "lokaler MCP-Server", + ".mcp.json mcpServers.munin": "schon aus opencode.json", + "opencode.json agent.build": "ohne eigenen Prompt", + "opencode.json instructions https://x.example/regeln.md": "URL", + ".opencode/plugins/x.ts": "noch nicht", + "opencode.json plugin opencode-x": "noch nicht", + } { + if !strings.Contains(reasons[source], part) { + t.Errorf("%s: Grund %q, erwartet %q", source, reasons[source], part) + } + } + if !strings.Contains(file(t, target, "IMPORT.md"), "| shop-munin | skill | .opencode/skills/munin/SKILL.md |") { + t.Error("IMPORT.md") + } + // Im Repo ändert sich nichts + if file(t, repo, "AGENTS.md") != example["AGENTS.md"] { + t.Error("Repo verändert") + } +} + +func TestImportPublicAndExistingTarget(t *testing.T) { + repo := createRepo(t) + target := t.TempDir() + if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"}); err != nil { + t.Fatal(err) + } + if readManifest(t, target, "pub-munin")["scope"] != "public" { + t.Error("pub- heißt öffentlich") + } + os.WriteFile(filepath.Join(target, "pub-munin", "SKILL.md"), []byte("bearbeitet\n"), 0o644) + b, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"}) + if err != nil { + t.Fatal(err) + } + if len(b.Packages) != 0 || file(t, target, "pub-munin/SKILL.md") != "bearbeitet\n" { + t.Error("vorhandene Ordner nur mit Ueberschreiben ersetzen") + } + if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern", Overwrite: true}); err != nil { + t.Fatal(err) + } + if file(t, target, "pub-munin/SKILL.md") != "Nutze Munin.\n" { + t.Error("Ueberschreiben") + } + if _, err := Import(Options{Repo: repo, Target: target, Prefix: "Shop"}); err == nil { + t.Error("ungültiges Präfix") + } +} + +func TestImportFollowsNoSymlinks(t *testing.T) { + repo := createRepo(t) + outside := filepath.Join(t.TempDir(), "geheim.md") + os.WriteFile(outside, []byte("# Geheim\n"), 0o644) + os.MkdirAll(filepath.Join(repo, ".opencode/skills/link"), 0o755) + os.Symlink(outside, filepath.Join(repo, ".opencode/skills/link/SKILL.md")) + os.Symlink(outside, filepath.Join(repo, ".opencode/skills/munin/referenz/geheim.md")) + os.Symlink(outside, filepath.Join(repo, "doku/link.md")) + target := t.TempDir() + b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"}) + if err != nil { + t.Fatal(err) + } + for _, p := range b.Packages { + if p.ID == "shop-link" { + t.Error("Symlink-Skill übernommen") + } + } + if _, err := os.Stat(filepath.Join(target, "shop-munin/referenz/geheim.md")); err == nil { + t.Error("Symlink-Hilfsdatei übernommen") + } + if _, err := os.Stat(filepath.Join(target, "shop-link")); err == nil { + t.Error("Symlink in instructions übernommen") + } +} diff --git a/cli/internal/keystore/keystore.go b/cli/internal/keystore/keystore.go new file mode 100644 index 0000000..700e60e --- /dev/null +++ b/cli/internal/keystore/keystore.go @@ -0,0 +1,54 @@ +// Package keystore verwaltet den Key für den Hugin-Server: zuerst die +// Umgebungsvariable HUGIN_KEY, sonst der Schlüsselbund des Betriebssystems. +package keystore + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/zalando/go-keyring" +) + +const ( + Env = "HUGIN_KEY" + ServerEnv = "HUGIN_SERVER" + service = "hugin" +) + +func normalize(server string) string { + return strings.ToLower(strings.TrimRight(strings.TrimSpace(server), "/")) +} + +var ErrNoKey = errors.New("kein Key: hugin login aufrufen oder HUGIN_KEY setzen") + +// Get liefert den Key für server. +// +// Ein Key aus HUGIN_KEY gilt für jeden Server; in CI kommt server aber aus der +// eingecheckten .hugin.yaml, die ein Pull Request ändern kann. Ist HUGIN_SERVER +// gesetzt, geht der Key deshalb nur an diesen Server. Im Schlüsselbund ist der +// Key ohnehin an die Adresse gebunden. +func Get(server string) (string, error) { + if key := os.Getenv(Env); key != "" { + if fixed := os.Getenv(ServerEnv); fixed != "" && normalize(fixed) != normalize(server) { + return "", fmt.Errorf("Server %s weicht von %s=%s ab; %s wird nicht gesendet", server, ServerEnv, fixed, Env) + } + return key, nil + } + key, err := keyring.Get(service, server) + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrNoKey + } + return key, err +} + +func Save(server, key string) error { return keyring.Set(service, server, key) } + +func Delete(server string) error { + err := keyring.Delete(service, server) + if errors.Is(err, keyring.ErrNotFound) { + return nil + } + return err +} diff --git a/cli/internal/keystore/keystore_test.go b/cli/internal/keystore/keystore_test.go new file mode 100644 index 0000000..4ae00b0 --- /dev/null +++ b/cli/internal/keystore/keystore_test.go @@ -0,0 +1,18 @@ +package keystore + +import "testing" + +func TestKeyFromEnvOnlyToFixedServer(t *testing.T) { + t.Setenv(Env, "hk_test") + t.Setenv(ServerEnv, "") + if k, err := Get("https://irgendwo"); err != nil || k != "hk_test" { + t.Fatalf("ohne HUGIN_SERVER: %q, %v", k, err) + } + t.Setenv(ServerEnv, "https://hugin.intern/") + if k, err := Get("https://HUGIN.intern"); err != nil || k != "hk_test" { + t.Errorf("gleicher Server: %q, %v", k, err) + } + if k, err := Get("https://boese.example"); err == nil || k != "" { + t.Errorf("fremder Server bekommt den Key: %q", k) + } +} diff --git a/cli/internal/localmcp/exec_unix.go b/cli/internal/localmcp/exec_unix.go new file mode 100644 index 0000000..8fbd706 --- /dev/null +++ b/cli/internal/localmcp/exec_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package localmcp + +import "syscall" + +// Exec ersetzt hugin durch den Server: Ein- und Ausgabe, Signale und Rückgabewert +// gehören dann direkt dem Werkzeug. +func Exec(program string, args, env []string) error { + return syscall.Exec(program, append([]string{program}, args...), env) +} diff --git a/cli/internal/localmcp/exec_windows.go b/cli/internal/localmcp/exec_windows.go new file mode 100644 index 0000000..3bf34e2 --- /dev/null +++ b/cli/internal/localmcp/exec_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package localmcp + +import ( + "errors" + "os" + "os/exec" +) + +// Exec startet den Server als Kindprozess (Windows kennt kein exec), reicht Ein- und +// Ausgabe durch und endet mit seinem Rückgabewert. +func Exec(program string, args, env []string) error { + c := exec.Command(program, args...) + c.Env, c.Stdin, c.Stdout, c.Stderr = env, os.Stdin, os.Stdout, os.Stderr + err := c.Run() + var exit *exec.ExitError + if errors.As(err, &exit) { + os.Exit(exit.ExitCode()) + } + if err != nil { + return err + } + os.Exit(0) + return nil +} diff --git a/cli/internal/localmcp/localmcp.go b/cli/internal/localmcp/localmcp.go new file mode 100644 index 0000000..8771828 --- /dev/null +++ b/cli/internal/localmcp/localmcp.go @@ -0,0 +1,340 @@ +// Package localmcp installiert und startet lokale MCP-Server (→ KONZEPT.md 20, Stufe 5b). +// +// Ablage außerhalb des Repos: ///, +// unter Windows also %USERPROFILE%\.local\share\mcp\…. Jede Version in einem eigenen +// Ordner, mehrere Repos teilen sie. Fertig ist eine Installation erst, wenn die +// Markierungsdatei geschrieben ist; nur Ordner mit dieser Datei gehören Hugin. +package localmcp + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + + "hugin/cli/internal/bundle" + "hugin/cli/internal/lock" + "hugin/cli/internal/paths" +) + +const ( + markerFile = ".hugin-install.json" + registryFile = ".hugin-repos" + requirements = "requirements.txt" + // TrialLock ist die Lock-Datei von hugin try; ihre Versionen gelten im Repo vor hugin.lock + TrialLock = ".hugin/trial.lock" +) + +// Marker steht in jedem fertig installierten Ordner. +type Marker struct { + Package string `json:"package"` + Version string `json:"version"` + Checksum string `json:"checksum"` + Runtime string `json:"runtime"` + Entry string `json:"entry"` +} + +// Home ist der Ordner für alle lokalen Server. +func Home() (string, error) { + if h := os.Getenv("HUGIN_MCP_HOME"); h != "" { + return filepath.Abs(h) + } + user, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(user, ".local", "share", "mcp"), nil +} + +// Folder ist der Ordner einer Version. +func Folder(home, pkg, version string) string { return filepath.Join(home, pkg, version) } + +// Read liefert die Markierung einer Installation; fehlt sie, ist die Version nicht installiert. +func Read(folder string) (*Marker, error) { + raw, err := os.ReadFile(filepath.Join(folder, markerFile)) + if err != nil { + return nil, err + } + var m Marker + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", markerFile, err) + } + return &m, nil +} + +// Installed sagt, ob genau diese Fassung (Prüfsumme) fertig installiert ist. +func Installed(home string, op bundle.Operation) bool { + m, err := Read(Folder(home, op.Package, op.Version)) + return err == nil && m.Checksum == op.Checksum +} + +// Install legt Dateien und venv an und installiert die Abhängigkeiten mit pip. pip liest +// den Index (Artifactory) aus der pip.conf bzw. pip.ini des Rechners. Ausgaben von venv +// und pip gehen nach log. +func Install(home string, b *bundle.Bundle, op bundle.Operation, log io.Writer) error { + if op.Runtime != "python" { + return fmt.Errorf("%s: Laufzeit %q kennt dieses hugin nicht; hugin aktualisieren", op.Package, op.Runtime) + } + folder := Folder(home, op.Package, op.Version) + // Halb fertige oder andere Fassung derselben Version: neu anlegen + if err := os.RemoveAll(folder); err != nil { + return err + } + for _, f := range op.Files { + data, err := b.File(f.Source) + if err != nil { + return err + } + target, err := paths.InRepo(folder, f.Path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.WriteFile(target, data, 0o644); err != nil { + return err + } + } + python, err := FindPython(op.Python) + if err != nil { + return fmt.Errorf("%s: %w", op.Package, err) + } + venv := filepath.Join(folder, ".venv") + if err := run(log, folder, python[0], append(python[1:], "-m", "venv", venv)...); err != nil { + return fmt.Errorf("%s: venv anlegen: %w", op.Package, err) + } + if _, err := os.Stat(filepath.Join(folder, requirements)); err == nil { + err := run(log, folder, VenvPython(folder), "-m", "pip", "install", "--quiet", "--no-input", + "--disable-pip-version-check", "--require-hashes", "-r", requirements) + if err != nil { + return fmt.Errorf("%s: pip install: %w (Index aus pip.conf bzw. pip.ini erreichbar?)", op.Package, err) + } + } + marker, _ := json.MarshalIndent(Marker{op.Package, op.Version, op.Checksum, op.Runtime, op.Entry}, "", " ") + return os.WriteFile(filepath.Join(folder, markerFile), append(marker, '\n'), 0o644) +} + +func run(log io.Writer, dir, name string, args ...string) error { + c := exec.Command(name, args...) + c.Dir, c.Stdout, c.Stderr = dir, log, log + return c.Run() +} + +// FindPython sucht ein Python für die venv, das mindestens Version minimum hat (z. B. "3.10"; +// leer: jedes Python 3): HUGIN_PYTHON, unter Windows der Starter py -3, sonst python3.14 bis +// python3.9, python3 und python. Auf vielen Rechnern ist python3 älter als ein daneben +// installiertes python3.12. +func FindPython(minimum string) ([]string, error) { + var candidates [][]string + if p := os.Getenv("HUGIN_PYTHON"); p != "" { + candidates = [][]string{{p}} + } else if runtime.GOOS == "windows" { + candidates = [][]string{{"py", "-3"}, {"python"}} + } else { + for minor := 14; minor >= 9; minor-- { + candidates = append(candidates, []string{fmt.Sprintf("python3.%d", minor)}) + } + candidates = append(candidates, []string{"python3"}, []string{"python"}) + } + var found []string + for _, c := range candidates { + if _, err := exec.LookPath(c[0]); err != nil { + continue + } + out, err := exec.Command(c[0], append(c[1:], "-c", + "import sys; print('%d.%d' % sys.version_info[:2])")...).Output() + if err != nil { + continue // z. B. der Platzhalter python.exe aus dem Microsoft Store + } + version := strings.TrimSpace(string(out)) + if strings.HasPrefix(version, "3.") && !olderThan(version, minimum) { + return c, nil + } + found = append(found, strings.Join(c, " ")+" "+version) + } + need := "Python 3" + if minimum != "" { + need = "Python ≥ " + minimum + } + if len(found) > 0 { + return nil, fmt.Errorf("%s nötig, gefunden: %s; neueres installieren oder HUGIN_PYTHON setzen", + need, strings.Join(found, ", ")) + } + return nil, fmt.Errorf("%s nötig, aber keins gefunden (python3, python oder unter Windows py); "+ + "installieren oder HUGIN_PYTHON setzen", need) +} + +// olderThan vergleicht Versionen wie 3.9 und 3.10 zahlenweise. +func olderThan(version, minimum string) bool { + if minimum == "" { + return false + } + var a, b, c, d int + fmt.Sscanf(version, "%d.%d", &a, &b) + fmt.Sscanf(minimum, "%d.%d", &c, &d) + return a < c || (a == c && b < d) +} + +// VenvPython ist das Python der venv einer Installation. +func VenvPython(folder string) string { + if runtime.GOOS == "windows" { + return filepath.Join(folder, ".venv", "Scripts", "python.exe") + } + return filepath.Join(folder, ".venv", "bin", "python") +} + +// Command liefert Programm, Argumente und Umgebung zum Start eines installierten Servers. +func Command(folder string, m *Marker) (string, []string, []string) { + args := []string{"-m", m.Entry} + if strings.HasSuffix(m.Entry, ".py") { + args = []string{filepath.Join(folder, filepath.FromSlash(m.Entry))} + } + pythonPath := folder + if old := os.Getenv("PYTHONPATH"); old != "" { + pythonPath += string(os.PathListSeparator) + old + } + // UTF-8 auch unter Windows, wo stdin sonst in der Codepage gelesen wird + env := append(os.Environ(), "PYTHONPATH="+pythonPath, "PYTHONUTF8=1") + return VenvPython(folder), args, env +} + +// FindRepo sucht vom Ordner start aufwärts das Repo (hugin.lock oder .hugin.yaml); +// Werkzeuge starten lokale Server auch aus Unterordnern. +func FindRepo(start string) (string, error) { + folder, err := filepath.Abs(start) + if err != nil { + return "", err + } + for { + for _, marker := range []string{lock.Filename, ".hugin.yaml"} { + if _, err := os.Stat(filepath.Join(folder, marker)); err == nil { + return folder, nil + } + } + parent := filepath.Dir(folder) + if parent == folder { + return "", fmt.Errorf("kein %s in %s oder darüber; im Repo hugin sync ausführen", lock.Filename, start) + } + folder = parent + } +} + +// Register merkt sich das Repo, damit Prune weiß, welche Versionen noch gebraucht werden. +func Register(home, repo string) error { + abs, err := filepath.Abs(repo) + if err != nil { + return err + } + repos, err := registered(home) + if err != nil || slices.Contains(repos, abs) { + return err + } + if err := os.MkdirAll(home, 0o755); err != nil { + return err + } + return writeRegistry(home, append(repos, abs)) +} + +func registered(home string) ([]string, error) { + f, err := os.Open(filepath.Join(home, registryFile)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + var repos []string + s := bufio.NewScanner(f) + for s.Scan() { + if line := strings.TrimSpace(s.Text()); line != "" { + repos = append(repos, line) + } + } + return repos, s.Err() +} + +func writeRegistry(home string, repos []string) error { + var b strings.Builder + for _, r := range repos { + b.WriteString(r + "\n") + } + return os.WriteFile(filepath.Join(home, registryFile), []byte(b.String()), 0o644) +} + +// Prune löscht installierte Versionen, die kein bekanntes Repo laut hugin.lock mehr nutzt. +// Ordner ohne Markierung fasst es nicht an; sie gehören nicht Hugin. +func Prune(home string, dryRun bool) ([]string, error) { + repos, err := registered(home) + if err != nil { + return nil, err + } + used := map[string]bool{} + var still []string + for _, r := range repos { + known := false + for _, name := range []string{lock.Filename, TrialLock} { + path := filepath.Join(r, filepath.FromSlash(name)) + if _, err := os.Stat(path); err != nil { + continue + } + l, err := lock.LoadFrom(path) + if err != nil { + return nil, err + } + known = true + for id, p := range l.Packages { + used[id+"/"+p.Version] = true + } + } + if known { + still = append(still, r) // sonst: Repo weg oder ohne Hugin, vergessen + } + } + packages, err := os.ReadDir(home) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var removed []string + for _, p := range packages { + if !p.IsDir() { + continue + } + versions, err := os.ReadDir(filepath.Join(home, p.Name())) + if err != nil { + return removed, err + } + before := len(removed) + for _, v := range versions { + folder := filepath.Join(home, p.Name(), v.Name()) + m, err := Read(folder) + if err != nil || m.Package != p.Name() || m.Version != v.Name() || used[p.Name()+"/"+v.Name()] { + continue + } + removed = append(removed, p.Name()+" "+v.Name()) + if !dryRun { + if err := os.RemoveAll(folder); err != nil { + return removed, err + } + } + } + if !dryRun && len(removed) > before { + os.Remove(filepath.Join(home, p.Name())) // nur, wenn jetzt leer + } + } + if !dryRun { + return removed, writeRegistry(home, still) + } + return removed, nil +} diff --git a/cli/internal/localmcp/localmcp_test.go b/cli/internal/localmcp/localmcp_test.go new file mode 100644 index 0000000..cb74f16 --- /dev/null +++ b/cli/internal/localmcp/localmcp_test.go @@ -0,0 +1,186 @@ +package localmcp + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "hugin/cli/internal/bundle" +) + +const example = "../../../contract/examples/bundles/local-mcp" + +func TestHome(t *testing.T) { + t.Setenv("HUGIN_MCP_HOME", "") + t.Setenv("HOME", "/home/anna") + t.Setenv("USERPROFILE", `C:\Users\anna`) + home, err := Home() + if err != nil { + t.Fatal(err) + } + user, _ := os.UserHomeDir() + if home != filepath.Join(user, ".local", "share", "mcp") { + t.Errorf("Home = %s", home) + } + t.Setenv("HUGIN_MCP_HOME", t.TempDir()) + if home, _ := Home(); home != os.Getenv("HUGIN_MCP_HOME") { + t.Errorf("HUGIN_MCP_HOME nicht beachtet: %s", home) + } +} + +func TestFindRepoFromSubfolder(t *testing.T) { + repo := t.TempDir() + os.WriteFile(filepath.Join(repo, "hugin.lock"), []byte("packages: {}\n"), 0o644) + sub := filepath.Join(repo, "a", "b") + os.MkdirAll(sub, 0o755) + found, err := FindRepo(sub) + if err != nil || found != repo { + t.Errorf("FindRepo = %q, %v", found, err) + } + other := t.TempDir() + os.WriteFile(filepath.Join(other, ".hugin.yaml"), []byte("server: x\n"), 0o644) + if found, _ := FindRepo(other); found != other { + t.Errorf("Repo ohne hugin.lock nicht gefunden: %q", found) + } + if _, err := FindRepo(t.TempDir()); err == nil { + t.Error("ohne hugin.lock kein Fehler") + } +} + +func TestCommand(t *testing.T) { + folder := filepath.Join("srv", "1.0.0") + _, args, env := Command(folder, &Marker{Entry: "jira_mcp"}) + if !slices.Equal(args, []string{"-m", "jira_mcp"}) { + t.Errorf("Modul: %v", args) + } + _, args, _ = Command(folder, &Marker{Entry: "tools/server.py"}) + if !slices.Equal(args, []string{filepath.Join(folder, "tools", "server.py")}) { + t.Errorf("Skript: %v", args) + } + if !slices.Contains(env, "PYTHONUTF8=1") { + t.Error("PYTHONUTF8 fehlt") + } +} + +// Echte venv mit dem Python des Rechners; das Beispiel braucht keine Abhängigkeiten. +func TestInstallAndRun(t *testing.T) { + if _, err := FindPython(""); err != nil { + t.Skip(err) + } + b, err := bundle.Read(example) + if err != nil { + t.Fatal(err) + } + op := b.Installs()[0] + home := t.TempDir() + if Installed(home, op) { + t.Fatal("vor der Installation schon installiert") + } + if err := Install(home, b, op, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if !Installed(home, op) { + t.Fatal("nach der Installation nicht installiert") + } + folder := Folder(home, op.Package, op.Version) + m, _ := Read(folder) + program, args, env := Command(folder, m) + c := exec.Command(program, args...) + c.Env = env + c.Stdin = bytes.NewBufferString(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"zeit"}}` + "\n") + out, err := c.Output() + if err != nil || !bytes.Contains(out, []byte("UHR-4711")) { + t.Errorf("Server antwortet nicht: %s %v", out, err) + } + // Andere Fassung derselben Version gilt als nicht installiert + op.Checksum = "sha256:anders" + if Installed(home, op) { + t.Error("andere Prüfsumme gilt als installiert") + } +} + +func TestOlderThan(t *testing.T) { + for _, tt := range []struct { + version, minimum string + older bool + }{{"3.9", "3.10", true}, {"3.12", "3.10", false}, {"3.10", "3.10", false}, {"3.13", "", false}} { + if olderThan(tt.version, tt.minimum) != tt.older { + t.Errorf("olderThan(%s, %s) != %v", tt.version, tt.minimum, tt.older) + } + } +} + +func TestFindPythonRespectsMinimum(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("braucht sh") + } + fake := filepath.Join(t.TempDir(), "python") + os.WriteFile(fake, []byte("#!/bin/sh\necho 3.9\n"), 0o755) + t.Setenv("HUGIN_PYTHON", fake) + if _, err := FindPython("3.10"); err == nil || !strings.Contains(err.Error(), "3.9") { + t.Errorf("zu altes Python nicht gemeldet: %v", err) + } + if got, err := FindPython("3.9"); err != nil || got[0] != fake { + t.Errorf("passendes Python nicht genommen: %v %v", got, err) + } +} + +func TestPipGetsHashesAndNoIndex(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("braucht sh") + } + home, fake := t.TempDir(), t.TempDir() + log := filepath.Join(fake, "aufrufe") + // Stellvertreter für python: legt die venv-Struktur an und schreibt die Aufrufe mit + script := "#!/bin/sh\nif [ \"$1\" = -c ]; then echo 3.12; exit; fi\necho \"$@\" >> " + log + + "\nif [ \"$1\" = -m ] && [ \"$2\" = venv ]; then mkdir -p \"$3/bin\"; cp \"$0\" \"$3/bin/python\"; fi\n" + python := filepath.Join(fake, "python") + os.WriteFile(python, []byte(script), 0o755) + t.Setenv("HUGIN_PYTHON", python) + b, _ := bundle.Read(example) + op := b.Installs()[0] + op.Files = append(op.Files, bundle.InstallFile{Path: "requirements.txt", Source: op.Files[0].Source}) + if err := Install(home, b, op, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + want := "-m pip install --quiet --no-input --disable-pip-version-check --require-hashes -r requirements.txt" + if !bytes.Contains(calls, []byte(want)) { + t.Errorf("pip-Aufruf fehlt:\n%s", calls) + } +} + +func TestPruneKeepsUsedAndForeign(t *testing.T) { + home, repo := t.TempDir(), t.TempDir() + marker := func(pkg, version string) { + folder := Folder(home, pkg, version) + os.MkdirAll(folder, 0o755) + os.WriteFile(filepath.Join(folder, markerFile), []byte(`{"package":"`+pkg+`","version":"`+version+`"}`), 0o644) + } + marker("pub-a", "1.0.0") + marker("pub-a", "1.1.0") + marker("pub-a", "1.2.0") // gerade mit hugin try installiert + os.MkdirAll(filepath.Join(repo, ".hugin"), 0o755) + os.WriteFile(filepath.Join(repo, ".hugin", "trial.lock"), []byte("packages:\n pub-a:\n version: 1.2.0\n checksum: y\n"), 0o644) + // Ordner ohne Markierung gehört nicht Hugin, etwa ein eigener Server + os.MkdirAll(filepath.Join(home, "eigener-server", "bin"), 0o755) + os.MkdirAll(filepath.Join(home, "noch-leer"), 0o755) + os.WriteFile(filepath.Join(repo, "hugin.lock"), []byte("packages:\n pub-a:\n version: 1.1.0\n checksum: x\n"), 0o644) + if err := Register(home, repo); err != nil { + t.Fatal(err) + } + removed, err := Prune(home, false) + if err != nil || !slices.Equal(removed, []string{"pub-a 1.0.0"}) { + t.Fatalf("Prune = %v, %v", removed, err) + } + for _, keep := range []string{Folder(home, "pub-a", "1.1.0"), Folder(home, "pub-a", "1.2.0"), filepath.Join(home, "eigener-server", "bin"), + filepath.Join(home, "noch-leer")} { + if _, err := os.Stat(keep); err != nil { + t.Errorf("%s gelöscht", keep) + } + } +} diff --git a/cli/internal/lock/lock.go b/cli/internal/lock/lock.go new file mode 100644 index 0000000..5043aa4 --- /dev/null +++ b/cli/internal/lock/lock.go @@ -0,0 +1,134 @@ +// Package lock liest und schreibt hugin.lock (→ KONZEPT.md 11.3). +// +// Die Lock-Datei sagt, welche Dateien, JSON-Schlüssel und Blöcke Hugin gehören +// und welchen Stand sie hatten. Daran erkennt sync Handänderungen und weiß, was +// beim Abbestellen zu entfernen ist. +package lock + +import ( + "bytes" + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + + "gopkg.in/yaml.v3" +) + +const ( + Filename = "hugin.lock" + header = "# Von hugin sync erzeugt. Nicht von Hand ändern.\n" +) + +type Lock struct { + Packages map[string]*Package `yaml:"packages"` + Common Common `yaml:"common,omitempty"` +} + +type Package struct { + Version string `yaml:"version"` + Checksum string `yaml:"checksum"` + Files map[string]string `yaml:"files,omitempty"` + Keys []Key `yaml:"keys,omitempty"` +} + +// Key ist ein JSON-Schlüssel, den Hugin gesetzt hat. Bei Mode list_item +// gehört Hugin nur der Eintrag Value in der Liste. +type Key struct { + File string `yaml:"file"` + Path []string `yaml:"path,flow"` + Mode string `yaml:"mode,omitempty"` + Value any `yaml:"value,omitempty"` + Sha256 string `yaml:"sha256,omitempty"` +} + +// Common hält, was keinem einzelnen Paket gehört. +type Common struct { + Keys []Key `yaml:"keys,omitempty"` + // "#" → Prüfsumme des Blockinhalts + Blocks map[string]string `yaml:"blocks,omitempty"` + // Dateien, die Hugin angelegt hat, z. B. CLAUDE.md oder opencode.json + Created []string `yaml:"created,omitempty"` +} + +func New() *Lock { return &Lock{Packages: map[string]*Package{}} } + +// Load liest hugin.lock aus repo; fehlt die Datei, ist das Ergebnis leer. +func Load(repo string) (*Lock, error) { + return LoadFrom(filepath.Join(repo, Filename)) +} + +// LoadFrom liest eine Lock-Datei von path, z. B. .hugin/trial.lock. +func LoadFrom(path string) (*Lock, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return New(), nil + } + if err != nil { + return nil, err + } + l := New() + if err := yaml.Unmarshal(raw, l); err != nil { + return nil, err + } + if l.Packages == nil { + l.Packages = map[string]*Package{} + } + return l, nil +} + +func (l *Lock) Bytes() ([]byte, error) { + var buffer bytes.Buffer + buffer.WriteString(header) + enc := yaml.NewEncoder(&buffer) + enc.SetIndent(2) + if err := enc.Encode(l); err != nil { + return nil, err + } + return buffer.Bytes(), enc.Close() +} + +// Files liefert alle Dateien aller Pakete: Pfad → Prüfsumme. +func (l *Lock) Files() map[string]string { + all := map[string]string{} + for _, p := range l.Packages { + for path, sha := range p.Files { + all[path] = sha + } + } + return all +} + +// AllKeys liefert die Schlüssel aller Pakete und die gemeinsamen. +func (l *Lock) AllKeys() []Key { + var all []Key + for _, p := range l.Packages { + all = append(all, p.Keys...) + } + return append(all, l.Common.Keys...) +} + +// Find sucht einen Schlüssel; bei list_item muss auch der Wert passen. +func Find(items []Key, file string, path []string, mode string, value any) *Key { + for i, s := range items { + if s.File != file || !slices.Equal(s.Path, path) || s.Mode != mode { + continue + } + if mode == "list_item" && !equal(s.Value, value) { + continue + } + return &items[i] + } + return nil +} + +func equal(a, b any) bool { + ya, _ := yaml.Marshal(a) + yb, _ := yaml.Marshal(b) + return bytes.Equal(ya, yb) +} + +func (l *Lock) Created(file string) bool { + return slices.Contains(l.Common.Created, file) +} diff --git a/cli/internal/lock/lock_test.go b/cli/internal/lock/lock_test.go new file mode 100644 index 0000000..4e19093 --- /dev/null +++ b/cli/internal/lock/lock_test.go @@ -0,0 +1,83 @@ +package lock + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadMissingIsEmpty(t *testing.T) { + l, err := Load(t.TempDir()) + if err != nil || l.Packages == nil || len(l.Packages) != 0 { + t.Fatalf("leer erwartet: %+v, %v", l, err) + } +} + +func TestRoundTrip(t *testing.T) { + l := New() + l.Packages["pub-a"] = &Package{Version: "1.0.0", Checksum: "sha256:aa", + Files: map[string]string{".claude/skills/pub-a/SKILL.md": "sha256:01"}, + Keys: []Key{{File: ".mcp.json", Path: []string{"mcpServers", "munin"}, Sha256: "sha256:02"}}} + l.Packages["pub-b"] = &Package{Version: "2.0.0", Checksum: "sha256:bb", + Files: map[string]string{".hugin/rules/pub-b.md": "sha256:03"}} + l.Common = Common{ + Keys: []Key{{File: "opencode.json", Path: []string{"instructions"}, Mode: "list_item", Value: ".hugin/rules/*.md"}}, + Blocks: map[string]string{"CLAUDE.md#hugin": "sha256:04"}, + Created: []string{"opencode.json"}, + } + raw, err := l.Bytes() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(raw), header) { + t.Errorf("Kopfzeile fehlt:\n%s", raw) + } + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, Filename), raw, 0o644); err != nil { + t.Fatal(err) + } + back, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if len(back.Files()) != 2 || back.Files()[".hugin/rules/pub-b.md"] != "sha256:03" { + t.Errorf("Files: %v", back.Files()) + } + if len(back.AllKeys()) != 2 || !back.Created("opencode.json") || back.Created("CLAUDE.md") { + t.Errorf("Keys/Created: %+v", back) + } + if back.Common.Blocks["CLAUDE.md#hugin"] != "sha256:04" { + t.Errorf("Blocks: %v", back.Common.Blocks) + } +} + +func TestFind(t *testing.T) { + keys := []Key{ + {File: "opencode.json", Path: []string{"instructions"}, Mode: "list_item", Value: ".hugin/rules/*.md"}, + {File: ".mcp.json", Path: []string{"mcpServers", "munin"}}, + } + if Find(keys, ".mcp.json", []string{"mcpServers", "munin"}, "", nil) == nil { + t.Error("Schlüssel nicht gefunden") + } + if Find(keys, "opencode.json", []string{"instructions"}, "list_item", ".hugin/rules/*.md") == nil { + t.Error("Listeneintrag nicht gefunden") + } + // Bei list_item muss auch der Wert passen + if Find(keys, "opencode.json", []string{"instructions"}, "list_item", "andere.md") != nil { + t.Error("anderer Wert darf nicht passen") + } + if Find(keys, ".mcp.json", []string{"mcpServers"}, "", nil) != nil { + t.Error("kürzerer Pfad darf nicht passen") + } +} + +func TestBrokenFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "trial.lock") + if err := os.WriteFile(path, []byte("packages: [kaputt"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadFrom(path); err == nil { + t.Error("Fehler erwartet") + } +} diff --git a/cli/internal/paths/paths.go b/cli/internal/paths/paths.go new file mode 100644 index 0000000..80afb85 --- /dev/null +++ b/cli/internal/paths/paths.go @@ -0,0 +1,70 @@ +// Package pfade prüft Pfade aus Bundles, bevor das CLI sie anfasst +// (→ KONZEPT.md 16.2: nicht aus dem Repo ausbrechen). +package paths + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strings" +) + +// windowsForbidden: Zeichen, die Windows in Namen nicht erlaubt oder umdeutet. +const windowsForbidden = `<>:"|?*` + +// Validate meldet einen Fehler, wenn rel kein sauberer, relativer Pfad mit / +// ist: kein führender /, kein \, kein Laufwerk, keine Segmente "." oder "..". +// Dazu, was Windows umdeutet (Punkt oder Leerzeichen am Ende, Steuerzeichen), +// und nichts unter .git/ (Hooks dort führt git selbst aus). +func Validate(rel string) error { + switch { + case rel == "": + return errors.New("leerer Pfad") + case strings.HasPrefix(rel, "/"), strings.Contains(rel, `\`), strings.Contains(rel, ":"): + return fmt.Errorf("%q: nur relative Pfade mit / erlaubt", rel) + } + for _, part := range strings.Split(strings.TrimSuffix(rel, "/"), "/") { + if part == "" || part == "." || part == ".." { + return fmt.Errorf("%q: ungültiges Segment %q", rel, part) + } + if strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") || strings.ContainsAny(part, windowsForbidden) || + strings.IndexFunc(part, func(r rune) bool { return r < 32 }) >= 0 { + return fmt.Errorf("%q: Segment %q geht nicht auf allen Systemen", rel, part) + } + } + if erstes, _, _ := strings.Cut(rel, "/"); strings.EqualFold(erstes, ".git") { + return fmt.Errorf("%q: Hugin schreibt nicht unter .git/", rel) + } + if path.Clean(rel) != strings.TrimSuffix(rel, "/") { + return fmt.Errorf("%q: Pfad ist nicht normalisiert", rel) + } + return nil +} + +// InRepo prüft rel und liefert den absoluten Pfad unter repo. Symlinks auf dem +// Weg dorthin werden abgelehnt, damit kein Pfad über einen Link nach draußen führt. +func InRepo(repo, rel string) (string, error) { + if err := Validate(rel); err != nil { + return "", err + } + target := filepath.Join(repo, filepath.FromSlash(rel)) + parts := strings.Split(strings.TrimSuffix(rel, "/"), "/") + current := repo + for _, part := range parts { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if errors.Is(err, fs.ErrNotExist) { + break + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%q: Symlink im Pfad (%s)", rel, current) + } + } + return target, nil +} diff --git a/cli/internal/paths/paths_test.go b/cli/internal/paths/paths_test.go new file mode 100644 index 0000000..c441df4 --- /dev/null +++ b/cli/internal/paths/paths_test.go @@ -0,0 +1,34 @@ +package paths + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidate(t *testing.T) { + for _, good := range []string{".claude/skills/a/SKILL.md", "opencode.json", ".opencode/agents/x.md", ".github/x", "a/.gitkeep"} { + if err := Validate(good); err != nil { + t.Errorf("%s: %v", good, err) + } + } + for _, bad := range []string{"", "/etc/passwd", "../x", "a/../b", "a//b", "./a", `a\b`, "c:/x", "a/b.", + "a /b", "a/b ", "...", "a/", "a|b", "a?b", "a\x01b", ".git/hooks/pre-commit", ".GIT/config", ".git"} { + if err := Validate(bad); err == nil { + t.Errorf("%q sollte abgelehnt werden", bad) + } + } +} + +func TestInRepoWithoutSymlinks(t *testing.T) { + repo, outside := t.TempDir(), t.TempDir() + if err := os.Symlink(outside, filepath.Join(repo, ".claude")); err != nil { + t.Skip(err) + } + if _, err := InRepo(repo, ".claude/skills/a/SKILL.md"); err == nil { + t.Error("Symlink im Pfad muss abgelehnt werden") + } + if p, err := InRepo(repo, ".opencode/agents/a.md"); err != nil || p != filepath.Join(repo, ".opencode", "agents", "a.md") { + t.Errorf("%s, %v", p, err) + } +} diff --git a/cli/internal/pkgdir/pkgdir.go b/cli/internal/pkgdir/pkgdir.go new file mode 100644 index 0000000..fdb0fea --- /dev/null +++ b/cli/internal/pkgdir/pkgdir.go @@ -0,0 +1,168 @@ +// Package paketordner packt Paketordner für den Server und legt neue an +// (→ KONZEPT.md 6.1, 11.1: hugin new, validate, try, upload). +package pkgdir + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +const Manifest = "hugin.yaml" + +// Zip packt den Ordner wie der Server ihn erwartet: hugin.yaml in der Wurzel, +// versteckte Dateien weggelassen, Symlinks abgelehnt. +func Zip(folder string) ([]byte, error) { + if _, err := os.Stat(filepath.Join(folder, Manifest)); err != nil { + return nil, fmt.Errorf("%s fehlt in %s", Manifest, folder) + } + var buffer bytes.Buffer + z := zip.NewWriter(&buffer) + err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(folder, path) + if err != nil || rel == "." { + return err + } + if strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("Symlinks sind nicht erlaubt: %s", rel) + } + if d.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + w, err := z.Create(filepath.ToSlash(rel)) + if err != nil { + return err + } + _, err = w.Write(data) + return err + }) + if err != nil { + return nil, err + } + if err := z.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +var idPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +var contentFile = map[string]string{ + "skill": "SKILL.md", + "rules": "RULES.md", + "command": "COMMAND.md", + "subagent": "AGENT.md", + "mcp": "", + "plugin": "index.ts", +} + +var scaffolds = map[string]string{ + "skill": `# Titel + +Was der Agent tun soll, Schritt für Schritt. Werkzeuge neutral beschreiben +(„lies die Datei“, nicht „nutze Read“), damit es in Claude Code und OpenCode passt. +`, + "rules": `# Regeln + +- Eine Regel pro Zeile, kurz und prüfbar. +`, + "command": `Beschreibe die Aufgabe für {{input}}. + +Platzhalter: {{name}} für benannte Argumente (in hugin.yaml unter arguments), +$ARGUMENTS für alles zusammen. Keine $1, $2 …: Claude Code und OpenCode zählen verschieden. +`, + "subagent": `Du bist … Beschreibe Rolle, Vorgehen und Form der Antwort. +`, + "plugin": `import { tool } from "@opencode-ai/plugin" + +// OpenCode ruft jede exportierte Funktion beim Start auf. Erlaubte Importe: relative +// Dateien im Paket, node:…, bun und @opencode-ai/plugin; npm-Pakete noch nicht. +export const MeinPlugin = async ({ worktree }) => { + return { + tool: { + mein_werkzeug: tool({ + description: "Was das Werkzeug tut", + args: {}, + async execute() { + return "Ergebnis" + }, + }), + }, + } +} +`, +} + +// New legt folder/ mit Manifest-Gerüst und Inhaltsdatei an. +func New(folder, kind, id string) (string, error) { + file, ok := contentFile[kind] + if !ok { + return "", fmt.Errorf("kind %q: erwartet skill, rules, command, subagent, mcp oder plugin", kind) + } + if !idPattern.MatchString(id) || len(id) > 64 { + return "", fmt.Errorf("id %q: nur a–z, 0–9 und einzelne Bindestriche, 1–64 Zeichen", id) + } + target := filepath.Join(folder, id) + if _, err := os.Stat(target); err == nil { + return "", fmt.Errorf("%s gibt es schon", target) + } else if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + if err := os.MkdirAll(target, 0o755); err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(target, Manifest), []byte(manifest(kind, id)), 0o644); err != nil { + return "", err + } + if file != "" { + if err := os.WriteFile(filepath.Join(target, file), []byte(scaffolds[kind]), 0o644); err != nil { + return "", err + } + } + return target, nil +} + +func manifest(kind, id string) string { + scope := "tenant # public | tenant | project; das Präfix der id muss passen" + if strings.HasPrefix(id, "pub-") { + scope = "public # public | tenant | project" + } + var b strings.Builder + fmt.Fprintf(&b, "id: %s\nkind: %s\nversion: 0.1.0\nowner: \"\" # Projekt-Slug\nscope: %s\n", id, kind, scope) + b.WriteString("description: >\n Wann und wofür der Agent das nutzt. Die Werkzeuge wählen danach aus,\n also konkret schreiben (1–1.024 Zeichen).\n") + switch kind { + case "command": + b.WriteString("arguments: [input] # Reihenfolge = Position beim Aufruf\n") + case "subagent": + b.WriteString("capability: read # read | write | shell | web, auch als Liste\n") + b.WriteString("model: standard # fast | standard | thorough | inherit\n") + case "plugin": + b.WriteString("targets: [opencode] # Plugins gibt es nur für OpenCode\n") + case "mcp": + b.WriteString("server:\n name: name\n url: https://…/mcp\n headers:\n Authorization: \"Bearer {{env:NAME_KEY}}\"\n") + b.WriteString(" # Lokaler Server statt url und headers: Code (und requirements.txt mit Hashes) ins Paket\n" + + " # local:\n # runtime: python\n # entry: mein_server # Modul oder Skript.py\n" + + " # python: \"3.10\"\n # env: [NAME_KEY]\n") + } + b.WriteString("# extra: # nur, was ein Werkzeug allein kann\n# claude: {}\n# opencode: {}\n") + return b.String() +} diff --git a/cli/internal/pkgdir/pkgdir_test.go b/cli/internal/pkgdir/pkgdir_test.go new file mode 100644 index 0000000..778fb00 --- /dev/null +++ b/cli/internal/pkgdir/pkgdir_test.go @@ -0,0 +1,71 @@ +package pkgdir + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestNewAndZip(t *testing.T) { + folder := t.TempDir() + target, err := New(folder, "command", "pub-test") + if err != nil { + t.Fatal(err) + } + manifest, _ := os.ReadFile(filepath.Join(target, Manifest)) + for _, part := range []string{"id: pub-test", "kind: command", "scope: public", "arguments: [input]"} { + if !strings.Contains(string(manifest), part) { + t.Errorf("Manifest ohne %q:\n%s", part, manifest) + } + } + os.WriteFile(filepath.Join(target, ".DS_Store"), []byte("x"), 0o644) + os.MkdirAll(filepath.Join(target, ".git"), 0o755) + os.WriteFile(filepath.Join(target, ".git", "HEAD"), []byte("x"), 0o644) + + raw, err := Zip(target) + if err != nil { + t.Fatal(err) + } + z, _ := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + var names []string + for _, f := range z.File { + names = append(names, f.Name) + } + slices.Sort(names) + if !slices.Equal(names, []string{"COMMAND.md", "hugin.yaml"}) { + t.Errorf("ZIP enthält %v", names) + } +} + +func TestNewValidatesInputs(t *testing.T) { + folder := t.TempDir() + if _, err := New(folder, "hook", "pub-x"); err == nil { + t.Error("Art hook angenommen") + } + if _, err := New(folder, "skill", "Pub_X"); err == nil { + t.Error("ungültige id angenommen") + } + New(folder, "skill", "pub-x") + if _, err := New(folder, "skill", "pub-x"); err == nil { + t.Error("vorhandener Ordner überschrieben") + } +} + +func TestZipWithoutManifest(t *testing.T) { + if _, err := Zip(t.TempDir()); err == nil { + t.Error("Ordner ohne hugin.yaml angenommen") + } +} + +func TestZipRejectsSymlink(t *testing.T) { + folder := t.TempDir() + os.WriteFile(filepath.Join(folder, Manifest), []byte("id: x\n"), 0o644) + os.Symlink("/etc/hosts", filepath.Join(folder, "SKILL.md")) + if _, err := Zip(folder); err == nil { + t.Error("Symlink angenommen") + } +} diff --git a/cli/internal/reconcile/block.go b/cli/internal/reconcile/block.go new file mode 100644 index 0000000..5bad54f --- /dev/null +++ b/cli/internal/reconcile/block.go @@ -0,0 +1,93 @@ +package reconcile + +import ( + "strings" +) + +// Verwaltete Blöcke in Textdateien wie CLAUDE.md (→ KONZEPT.md 7.2, 11.4): +// +// +// @.hugin/rules/pub-hausregeln.md +// + +func beginMarker(block string) string { + return "" +} + +func endMarker(block string) string { return "" } + +// findBlock liefert die Zeilennummern von Anfangs- und Endmarke, -1 wenn es +// den Block nicht gibt. +func findBlock(lines []string, block string) (begin, end int) { + begin, end = -1, -1 + for i, z := range lines { + z = strings.TrimSpace(z) + if begin < 0 && strings.HasPrefix(z, "") { + t.Errorf("CLAUDE.md ohne Block:\n%s", claudeMd) + } + mcp := jsonValue(t, repo, ".mcp.json")["mcpServers"].(map[string]any)["munin"].(map[string]any) + if mcp["type"] != "http" { + t.Errorf(".mcp.json: %v", mcp) + } + oc := jsonValue(t, repo, "opencode.json") + if oc["instructions"].([]any)[0] != ".hugin/rules/*.md" { + t.Errorf("opencode.json: %v", oc) + } + + // Ein zweiter sync mit demselben Bundle ändert nichts + if plan := sync(t, repo, "first-install", Options{}); plan.Changed() { + t.Errorf("zweiter sync ändert etwas: schreiben %v, löschen %v", plan.Write, plan.Delete) + } +} + +func TestUpdateCleansUp(t *testing.T) { + repo := t.TempDir() + sync(t, repo, "first-install", Options{}) + plan := sync(t, repo, "update", Options{}) + if len(plan.Findings) > 0 { + t.Fatalf("Befunde: %v", plan.Findings) + } + for _, remove := range []string{ + ".claude/skills/pub-munin-nutzen/referenz", + ".hugin/rules/pub-hausregeln.md", + ".hugin", + "CLAUDE.md", // von Hugin angelegt und jetzt leer + } { + if exists(repo, remove) { + t.Errorf("%s sollte entfernt sein", remove) + } + } + if !strings.Contains(read(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md"), "pub-munin-nutzen 1.5.0") { + t.Error("SKILL.md nicht aktualisiert") + } + oc := jsonValue(t, repo, "opencode.json") + if _, ok := oc["mcp"]; !ok { + t.Error("MCP-Eintrag fehlt") + } + if _, ok := oc["instructions"]; ok { + t.Errorf("leere instructions nicht entfernt: %v", oc["instructions"]) + } + l, _ := lock.Load(repo) + if _, ok := l.Packages["pub-hausregeln"]; ok { + t.Error("pub-hausregeln noch in hugin.lock") + } + if l.Packages["pub-munin-nutzen"].Version != "1.5.0" { + t.Errorf("Version in hugin.lock: %v", l.Packages["pub-munin-nutzen"]) + } +} + +func TestTeamFilesArePreserved(t *testing.T) { + repo := t.TempDir() + write(t, repo, "CLAUDE.md", "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n") + write(t, repo, "opencode.json", "{\n // unser Modell\n \"model\": \"anthropic/claude-sonnet-5-5\",\n \"instructions\": [\"docs/stil.md\"]\n}\n") + + sync(t, repo, "first-install", Options{}) + claudeMd := read(t, repo, "CLAUDE.md") + if !strings.HasPrefix(claudeMd, "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n\r\n + +# Hausregeln + +- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits. +- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast. diff --git a/contract/examples/bundles/claude-only/hugin-bundle.json b/contract/examples/bundles/claude-only/hugin-bundle.json new file mode 100644 index 0000000..5516a04 --- /dev/null +++ b/contract/examples/bundles/claude-only/hugin-bundle.json @@ -0,0 +1,57 @@ +{ + "format": 1, + "generated": "2026-10-18T12:00:00Z", + "tools": [ + "claude" + ], + "packages": [ + { + "id": "pub-hausregeln", + "version": "1.0.0", + "checksum": "sha256:0e2983623cec5dfe509ffb5f62332a4ff54a79b2affe5c981165148cb09ce127", + "kind": "rules", + "risk": "text", + "compatibility": "translated", + "mandatory": false + }, + { + "id": "pub-munin-nutzen", + "version": "1.4.0", + "checksum": "sha256:9e50afeed46db30a6e651488caf175a82b763ce266943525e49a3c85cd05ca37", + "kind": "skill", + "risk": "text", + "compatibility": "portable", + "mandatory": false + } + ], + "operations": [ + { + "op": "file", + "package": "pub-munin-nutzen", + "path": ".claude/skills/pub-munin-nutzen/SKILL.md", + "source": "files/.claude/skills/pub-munin-nutzen/SKILL.md", + "sha256": "sha256:d3ad99d5e6f43d532917f1f57589259cf4812d292e1104f087d56820d5fbbe26" + }, + { + "op": "file", + "package": "pub-munin-nutzen", + "path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md", + "source": "files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md", + "sha256": "sha256:2746734c5c36045173fadbed75377a65817c8ca6fccb4e487773fc0ae75ec58a" + }, + { + "op": "file", + "package": "pub-hausregeln", + "path": ".hugin/rules/pub-hausregeln.md", + "source": "files/.hugin/rules/pub-hausregeln.md", + "sha256": "sha256:670a7621fa4b561ed580452fde7881d551d3c9cb83ae6adc581cb2e1b61c1bfd" + }, + { + "op": "block", + "file": "CLAUDE.md", + "block": "hugin", + "content": "@.hugin/rules/pub-hausregeln.md\n" + } + ], + "hints": [] +} diff --git a/contract/examples/bundles/commands-and-agents/files/.claude/agents/pub-code-review.md b/contract/examples/bundles/commands-and-agents/files/.claude/agents/pub-code-review.md new file mode 100644 index 0000000..aa852c6 --- /dev/null +++ b/contract/examples/bundles/commands-and-agents/files/.claude/agents/pub-code-review.md @@ -0,0 +1,12 @@ +--- +# Verwaltet von Hugin (pub-code-review 1.0.0). Änderungen über hugin.intern. +name: pub-code-review +description: Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen einsetzen, bevor ein Pull Request entsteht. +tools: Read, Grep, Glob +model: opus +effort: high +--- + +Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde. + +Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag. diff --git a/contract/examples/bundles/commands-and-agents/files/.claude/commands/pub-release-notiz.md b/contract/examples/bundles/commands-and-agents/files/.claude/commands/pub-release-notiz.md new file mode 100644 index 0000000..01cdf0a --- /dev/null +++ b/contract/examples/bundles/commands-and-agents/files/.claude/commands/pub-release-notiz.md @@ -0,0 +1,13 @@ +--- +# Verwaltet von Hugin (pub-release-notiz 1.0.0). Änderungen über hugin.intern. +description: Schreibt eine Release-Notiz für eine Version. +argument-hint: '[version] [zielgruppe]' +arguments: +- version +- zielgruppe +--- + +Schreibe eine Release-Notiz für Version $version, gedacht für $zielgruppe. +Lies dazu die Commits seit dem letzten Tag. + +Weitere Wünsche: $ARGUMENTS diff --git a/contract/examples/bundles/commands-and-agents/files/.opencode/agents/pub-code-review.md b/contract/examples/bundles/commands-and-agents/files/.opencode/agents/pub-code-review.md new file mode 100644 index 0000000..9b61b09 --- /dev/null +++ b/contract/examples/bundles/commands-and-agents/files/.opencode/agents/pub-code-review.md @@ -0,0 +1,16 @@ +--- +# Verwaltet von Hugin (pub-code-review 1.0.0). Änderungen über hugin.intern. +description: Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen einsetzen, bevor ein Pull Request entsteht. +mode: subagent +permission: + edit: deny + bash: deny + webfetch: deny + websearch: deny +model: anthropic/claude-opus-5-5 +temperature: 0.1 +--- + +Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde. + +Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag. diff --git a/contract/examples/bundles/commands-and-agents/files/.opencode/commands/pub-release-notiz.md b/contract/examples/bundles/commands-and-agents/files/.opencode/commands/pub-release-notiz.md new file mode 100644 index 0000000..e5e97be --- /dev/null +++ b/contract/examples/bundles/commands-and-agents/files/.opencode/commands/pub-release-notiz.md @@ -0,0 +1,9 @@ +--- +# Verwaltet von Hugin (pub-release-notiz 1.0.0). Änderungen über hugin.intern. +description: Schreibt eine Release-Notiz für eine Version. +--- + +Schreibe eine Release-Notiz für Version $1, gedacht für $2. +Lies dazu die Commits seit dem letzten Tag. + +Weitere Wünsche: $ARGUMENTS diff --git a/contract/examples/bundles/commands-and-agents/hugin-bundle.json b/contract/examples/bundles/commands-and-agents/hugin-bundle.json new file mode 100644 index 0000000..ce9d683 --- /dev/null +++ b/contract/examples/bundles/commands-and-agents/hugin-bundle.json @@ -0,0 +1,59 @@ +{ + "format": 1, + "generated": "2026-10-18T12:00:00Z", + "tools": [ + "claude", + "opencode@1" + ], + "packages": [ + { + "id": "pub-code-review", + "version": "1.0.0", + "checksum": "sha256:0b8f1233bb379c53fa4b6edf63993c78b3f8bfd9a83f7a7ccc604b10ff3e5a29", + "kind": "subagent", + "risk": "text", + "compatibility": "translated", + "mandatory": false + }, + { + "id": "pub-release-notiz", + "version": "1.0.0", + "checksum": "sha256:22643f328cc562b525974a0eb815aec3c0ef8fa5c79e3997e0d3e356ab327b16", + "kind": "command", + "risk": "text", + "compatibility": "translated", + "mandatory": false + } + ], + "operations": [ + { + "op": "file", + "package": "pub-code-review", + "path": ".claude/agents/pub-code-review.md", + "source": "files/.claude/agents/pub-code-review.md", + "sha256": "sha256:706a29ca50d481c7985ad5eb40e124d832b13124d56d42f2ed1d4f03111ae230" + }, + { + "op": "file", + "package": "pub-release-notiz", + "path": ".claude/commands/pub-release-notiz.md", + "source": "files/.claude/commands/pub-release-notiz.md", + "sha256": "sha256:11fea449fecf436eb7753f2769e7f1fed45ebcd95c77bb5b8cd15d755bec5d08" + }, + { + "op": "file", + "package": "pub-code-review", + "path": ".opencode/agents/pub-code-review.md", + "source": "files/.opencode/agents/pub-code-review.md", + "sha256": "sha256:96cab45a44f56db9fb3584323b14f5ddef4d2eb3f811806f95444d650b43ef3b" + }, + { + "op": "file", + "package": "pub-release-notiz", + "path": ".opencode/commands/pub-release-notiz.md", + "source": "files/.opencode/commands/pub-release-notiz.md", + "sha256": "sha256:01579f65b6407f1f89fa7728cc7f44b8157b147378717e3031c0898ea673e3bd" + } + ], + "hints": [] +} diff --git a/contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/SKILL.md b/contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/SKILL.md new file mode 100644 index 0000000..4905f14 --- /dev/null +++ b/contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/SKILL.md @@ -0,0 +1,17 @@ +--- +# Verwaltet von Hugin (pub-munin-nutzen 1.4.0). Änderungen über hugin.intern. +name: pub-munin-nutzen +description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.' +license: intern +--- + +# Munin nutzen + +Munin ist das gemeinsame Gedächtnis der Teams. + +1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach + bekannten Besonderheiten des Projekts. +2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest. +3. Mit `related` findest du verwandte Einträge. + +Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md). diff --git a/contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md b/contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md new file mode 100644 index 0000000..9e9263c --- /dev/null +++ b/contract/examples/bundles/first-install/files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md @@ -0,0 +1,4 @@ +# Beispiele + +- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“ +- Schlecht: „Ich habe heute die Tests laufen lassen.“ diff --git a/contract/examples/bundles/first-install/files/.hugin/rules/pub-hausregeln.md b/contract/examples/bundles/first-install/files/.hugin/rules/pub-hausregeln.md new file mode 100644 index 0000000..6c7d7ce --- /dev/null +++ b/contract/examples/bundles/first-install/files/.hugin/rules/pub-hausregeln.md @@ -0,0 +1,6 @@ + + +# Hausregeln + +- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits. +- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast. diff --git a/contract/examples/bundles/first-install/hugin-bundle.json b/contract/examples/bundles/first-install/hugin-bundle.json new file mode 100644 index 0000000..ccb8f80 --- /dev/null +++ b/contract/examples/bundles/first-install/hugin-bundle.json @@ -0,0 +1,115 @@ +{ + "format": 1, + "generated": "2026-10-18T12:00:00Z", + "tools": [ + "claude", + "opencode@1" + ], + "packages": [ + { + "id": "pub-hausregeln", + "version": "1.0.0", + "checksum": "sha256:0e2983623cec5dfe509ffb5f62332a4ff54a79b2affe5c981165148cb09ce127", + "kind": "rules", + "risk": "text", + "compatibility": "translated", + "mandatory": false + }, + { + "id": "pub-munin-anbindung", + "version": "1.0.0", + "checksum": "sha256:9c3bb85f6f4bc4596303f8dacf00a7ad2db9e2ef813e889ea7fca42db23f34a2", + "kind": "mcp", + "risk": "tools", + "compatibility": "translated", + "mandatory": true + }, + { + "id": "pub-munin-nutzen", + "version": "1.4.0", + "checksum": "sha256:9e50afeed46db30a6e651488caf175a82b763ce266943525e49a3c85cd05ca37", + "kind": "skill", + "risk": "text", + "compatibility": "portable", + "mandatory": false + } + ], + "operations": [ + { + "op": "file", + "package": "pub-munin-nutzen", + "path": ".claude/skills/pub-munin-nutzen/SKILL.md", + "source": "files/.claude/skills/pub-munin-nutzen/SKILL.md", + "sha256": "sha256:d3ad99d5e6f43d532917f1f57589259cf4812d292e1104f087d56820d5fbbe26" + }, + { + "op": "file", + "package": "pub-munin-nutzen", + "path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md", + "source": "files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md", + "sha256": "sha256:2746734c5c36045173fadbed75377a65817c8ca6fccb4e487773fc0ae75ec58a" + }, + { + "op": "file", + "package": "pub-hausregeln", + "path": ".hugin/rules/pub-hausregeln.md", + "source": "files/.hugin/rules/pub-hausregeln.md", + "sha256": "sha256:670a7621fa4b561ed580452fde7881d551d3c9cb83ae6adc581cb2e1b61c1bfd" + }, + { + "op": "json", + "package": "pub-munin-anbindung", + "file": ".mcp.json", + "key": [ + "mcpServers", + "munin" + ], + "value": { + "type": "http", + "url": "https://munin.intern/mcp", + "headers": { + "Authorization": "Bearer ${MUNIN_KEY}" + } + } + }, + { + "op": "json", + "file": "opencode.json", + "key": [ + "instructions" + ], + "mode": "list_item", + "value": ".hugin/rules/*.md" + }, + { + "op": "json", + "package": "pub-munin-anbindung", + "file": "opencode.json", + "key": [ + "mcp", + "munin" + ], + "value": { + "type": "remote", + "url": "https://munin.intern/mcp", + "enabled": true, + "headers": { + "Authorization": "Bearer {env:MUNIN_KEY}" + } + } + }, + { + "op": "block", + "file": "CLAUDE.md", + "block": "hugin", + "content": "@.hugin/rules/pub-hausregeln.md\n" + } + ], + "hints": [ + { + "kind": "env_variable", + "name": "MUNIN_KEY", + "package": "pub-munin-anbindung" + } + ] +} diff --git a/contract/examples/bundles/local-mcp/hugin-bundle.json b/contract/examples/bundles/local-mcp/hugin-bundle.json new file mode 100644 index 0000000..7e6b9ae --- /dev/null +++ b/contract/examples/bundles/local-mcp/hugin-bundle.json @@ -0,0 +1,86 @@ +{ + "format": 1, + "generated": "2026-10-18T12:00:00Z", + "tools": [ + "claude", + "opencode@1" + ], + "packages": [ + { + "id": "pub-zeit-lokal", + "version": "1.0.0", + "checksum": "sha256:29b47ccd830b4a4815746972bdcca964e23adc4e935e82ff7877ccb50ceb4584", + "kind": "mcp", + "risk": "scripts", + "compatibility": "translated", + "mandatory": false + } + ], + "operations": [ + { + "op": "json", + "package": "pub-zeit-lokal", + "file": ".mcp.json", + "key": [ + "mcpServers", + "zeit" + ], + "value": { + "type": "stdio", + "command": "hugin", + "args": [ + "mcp", + "run", + "pub-zeit-lokal" + ], + "env": { + "ZEIT_ZONE_HINWEIS": "${ZEIT_ZONE_HINWEIS}" + } + } + }, + { + "op": "json", + "package": "pub-zeit-lokal", + "file": "opencode.json", + "key": [ + "mcp", + "zeit" + ], + "value": { + "type": "local", + "command": [ + "hugin", + "mcp", + "run", + "pub-zeit-lokal" + ], + "enabled": true, + "environment": { + "ZEIT_ZONE_HINWEIS": "{env:ZEIT_ZONE_HINWEIS}" + } + } + }, + { + "op": "install", + "package": "pub-zeit-lokal", + "version": "1.0.0", + "checksum": "sha256:29b47ccd830b4a4815746972bdcca964e23adc4e935e82ff7877ccb50ceb4584", + "runtime": "python", + "entry": "zeit_server", + "files": [ + { + "path": "zeit_server.py", + "source": "local/pub-zeit-lokal/1.0.0/zeit_server.py", + "sha256": "sha256:45e3d0952541a5f887a3734492b150c68c75fbf27afcd173fe8153ae845323cc" + } + ] + } + ], + "hints": [ + { + "kind": "env_variable", + "name": "ZEIT_ZONE_HINWEIS", + "package": "pub-zeit-lokal" + } + ] +} diff --git a/contract/examples/bundles/local-mcp/local/pub-zeit-lokal/1.0.0/zeit_server.py b/contract/examples/bundles/local-mcp/local/pub-zeit-lokal/1.0.0/zeit_server.py new file mode 100644 index 0000000..1b8e673 --- /dev/null +++ b/contract/examples/bundles/local-mcp/local/pub-zeit-lokal/1.0.0/zeit_server.py @@ -0,0 +1,52 @@ +"""Minimaler MCP-Server über stdio, nur mit der Standardbibliothek. + +Ein JSON-Objekt je Zeile. Ausgabe als Bytes, damit Windows kein \r einfügt. +""" + +import json +import os +import sys +from datetime import datetime + +TOOL = { + "name": "zeit", + "description": "Liefert Datum und Uhrzeit dieses Rechners.", + "inputSchema": {"type": "object", "properties": {}}, +} + + +def answer(message): + method = message.get("method") + if method == "initialize": + return {"protocolVersion": message["params"]["protocolVersion"], + "capabilities": {"tools": {}}, "serverInfo": {"name": "zeit", "version": "1.0.0"}} + if method == "tools/list": + return {"tools": [TOOL]} + if method == "tools/call": + hint = os.environ.get("ZEIT_ZONE_HINWEIS", "") + text = f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z} UHR-4711" + (f" ({hint})" if hint else "") + return {"content": [{"type": "text", "text": text}]} + if method == "ping": + return {} + return None + + +def main(): + for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + if "id" not in message: + continue # Benachrichtigungen brauchen keine Antwort + result = answer(message) + if result is None: + reply = {"jsonrpc": "2.0", "id": message["id"], + "error": {"code": -32601, "message": f"unbekannt: {message.get('method')}"}} + else: + reply = {"jsonrpc": "2.0", "id": message["id"], "result": result} + sys.stdout.buffer.write(json.dumps(reply).encode("utf-8") + b"\n") + sys.stdout.buffer.flush() + + +if __name__ == "__main__": + main() diff --git a/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin.ts b/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin.ts new file mode 100644 index 0000000..aaf0ab5 --- /dev/null +++ b/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin.ts @@ -0,0 +1,2 @@ +// Verwaltet von Hugin (pub-specht-plugin 1.0.0). Änderungen über hugin.intern. +export * from "./pub-specht-plugin/index.ts" diff --git a/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/index.ts b/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/index.ts new file mode 100644 index 0000000..180f319 --- /dev/null +++ b/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/index.ts @@ -0,0 +1,17 @@ +import { tool } from "@opencode-ai/plugin" +import { marke } from "./lib/marke.ts" + +// OpenCode ruft jede exportierte Funktion beim Start auf +export const SpechtPlugin = async () => { + return { + tool: { + specht_marke: tool({ + description: "Liefert die Erkennungsmarke des Specht-Plugins", + args: {}, + async execute() { + return marke + }, + }), + }, + } +} diff --git a/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/lib/marke.ts b/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/lib/marke.ts new file mode 100644 index 0000000..c5cfcfe --- /dev/null +++ b/contract/examples/bundles/opencode-plugin/files/.opencode/plugins/pub-specht-plugin/lib/marke.ts @@ -0,0 +1 @@ +export const marke = "SPECHT-5127" diff --git a/contract/examples/bundles/opencode-plugin/hugin-bundle.json b/contract/examples/bundles/opencode-plugin/hugin-bundle.json new file mode 100644 index 0000000..9ac81c4 --- /dev/null +++ b/contract/examples/bundles/opencode-plugin/hugin-bundle.json @@ -0,0 +1,43 @@ +{ + "format": 1, + "generated": "2026-10-18T12:00:00Z", + "tools": [ + "claude", + "opencode@1" + ], + "packages": [ + { + "id": "pub-specht-plugin", + "version": "1.0.0", + "checksum": "sha256:fafb3e308847f4b491685ba223350e75c79aa2b6a7d7fab21189a695b0d47dcb", + "kind": "plugin", + "risk": "permissions", + "compatibility": "opencode-only", + "mandatory": false + } + ], + "operations": [ + { + "op": "file", + "package": "pub-specht-plugin", + "path": ".opencode/plugins/pub-specht-plugin.ts", + "source": "files/.opencode/plugins/pub-specht-plugin.ts", + "sha256": "sha256:b053f6c9332c7a3777ae4b6aae3303216853da473225b72d3edc66c1045e1c1e" + }, + { + "op": "file", + "package": "pub-specht-plugin", + "path": ".opencode/plugins/pub-specht-plugin/index.ts", + "source": "files/.opencode/plugins/pub-specht-plugin/index.ts", + "sha256": "sha256:c33979fe526c3b22b625bdc37f9e51bce083b9045ef6d6ab852e294295ecb8f8" + }, + { + "op": "file", + "package": "pub-specht-plugin", + "path": ".opencode/plugins/pub-specht-plugin/lib/marke.ts", + "source": "files/.opencode/plugins/pub-specht-plugin/lib/marke.ts", + "sha256": "sha256:53e52e0666b133f9ec0597554abc8da3a80899e0f3abae405f47c4fa7da7a06d" + } + ], + "hints": [] +} diff --git a/contract/examples/bundles/update/files/.claude/skills/pub-munin-nutzen/SKILL.md b/contract/examples/bundles/update/files/.claude/skills/pub-munin-nutzen/SKILL.md new file mode 100644 index 0000000..9b2dcae --- /dev/null +++ b/contract/examples/bundles/update/files/.claude/skills/pub-munin-nutzen/SKILL.md @@ -0,0 +1,18 @@ +--- +# Verwaltet von Hugin (pub-munin-nutzen 1.5.0). Änderungen über hugin.intern. +name: pub-munin-nutzen +description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.' +license: intern +--- + +# Munin nutzen + +Munin ist das gemeinsame Gedächtnis der Teams. + +1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach + bekannten Besonderheiten des Projekts. +2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest. +3. Mit `related` findest du verwandte Einträge. + +Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“ +Schlecht: „Ich habe heute die Tests laufen lassen.“ diff --git a/contract/examples/bundles/update/hugin-bundle.json b/contract/examples/bundles/update/hugin-bundle.json new file mode 100644 index 0000000..e5e2b43 --- /dev/null +++ b/contract/examples/bundles/update/hugin-bundle.json @@ -0,0 +1,102 @@ +{ + "format": 1, + "generated": "2026-10-18T12:00:00Z", + "tools": [ + "claude", + "opencode@1" + ], + "packages": [ + { + "id": "pub-munin-anbindung", + "version": "1.0.0", + "checksum": "sha256:9c3bb85f6f4bc4596303f8dacf00a7ad2db9e2ef813e889ea7fca42db23f34a2", + "kind": "mcp", + "risk": "tools", + "compatibility": "translated", + "mandatory": true + }, + { + "id": "pub-munin-nutzen", + "version": "1.5.0", + "checksum": "sha256:8a573c430c18265cb033dfdfba7daeb1d6366c7703ef0c00cb323554563a3f93", + "kind": "skill", + "risk": "text", + "compatibility": "portable", + "mandatory": false + } + ], + "operations": [ + { + "op": "remove", + "package": "pub-munin-nutzen", + "path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md" + }, + { + "op": "remove", + "package": "pub-hausregeln", + "path": ".hugin/rules/pub-hausregeln.md" + }, + { + "op": "json_remove", + "file": "opencode.json", + "key": [ + "instructions" + ], + "mode": "list_item", + "value": ".hugin/rules/*.md" + }, + { + "op": "file", + "package": "pub-munin-nutzen", + "path": ".claude/skills/pub-munin-nutzen/SKILL.md", + "source": "files/.claude/skills/pub-munin-nutzen/SKILL.md", + "sha256": "sha256:50430ecef44ca4944aed97dd02b086e987a8f52b588cfafa95347fef154b78f1" + }, + { + "op": "json", + "package": "pub-munin-anbindung", + "file": ".mcp.json", + "key": [ + "mcpServers", + "munin" + ], + "value": { + "type": "http", + "url": "https://munin.intern/mcp", + "headers": { + "Authorization": "Bearer ${MUNIN_KEY}" + } + } + }, + { + "op": "json", + "package": "pub-munin-anbindung", + "file": "opencode.json", + "key": [ + "mcp", + "munin" + ], + "value": { + "type": "remote", + "url": "https://munin.intern/mcp", + "enabled": true, + "headers": { + "Authorization": "Bearer {env:MUNIN_KEY}" + } + } + }, + { + "op": "block", + "file": "CLAUDE.md", + "block": "hugin", + "content": "" + } + ], + "hints": [ + { + "kind": "env_variable", + "name": "MUNIN_KEY", + "package": "pub-munin-anbindung" + } + ] +} diff --git a/contract/examples/packages/pub-code-review/1.0.0/AGENT.md b/contract/examples/packages/pub-code-review/1.0.0/AGENT.md new file mode 100644 index 0000000..1b14176 --- /dev/null +++ b/contract/examples/packages/pub-code-review/1.0.0/AGENT.md @@ -0,0 +1,3 @@ +Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde. + +Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag. diff --git a/contract/examples/packages/pub-code-review/1.0.0/hugin.yaml b/contract/examples/packages/pub-code-review/1.0.0/hugin.yaml new file mode 100644 index 0000000..f219089 --- /dev/null +++ b/contract/examples/packages/pub-code-review/1.0.0/hugin.yaml @@ -0,0 +1,15 @@ +id: pub-code-review +kind: subagent +version: 1.0.0 +owner: plattform +scope: public +description: > + Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen + einsetzen, bevor ein Pull Request entsteht. +capability: read +model: thorough +extra: + claude: + effort: high + opencode: + temperature: 0.1 diff --git a/contract/examples/packages/pub-hausregeln/1.0.0/RULES.md b/contract/examples/packages/pub-hausregeln/1.0.0/RULES.md new file mode 100644 index 0000000..d963597 --- /dev/null +++ b/contract/examples/packages/pub-hausregeln/1.0.0/RULES.md @@ -0,0 +1,4 @@ +# Hausregeln + +- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits. +- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast. diff --git a/contract/examples/packages/pub-hausregeln/1.0.0/hugin.yaml b/contract/examples/packages/pub-hausregeln/1.0.0/hugin.yaml new file mode 100644 index 0000000..7fcc5bc --- /dev/null +++ b/contract/examples/packages/pub-hausregeln/1.0.0/hugin.yaml @@ -0,0 +1,6 @@ +id: pub-hausregeln +kind: rules +version: 1.0.0 +owner: plattform +scope: public +description: Grundregeln für alle Agenten im Haus. diff --git a/contract/examples/packages/pub-munin-anbindung/1.0.0/hugin.yaml b/contract/examples/packages/pub-munin-anbindung/1.0.0/hugin.yaml new file mode 100644 index 0000000..8c1075b --- /dev/null +++ b/contract/examples/packages/pub-munin-anbindung/1.0.0/hugin.yaml @@ -0,0 +1,11 @@ +id: pub-munin-anbindung +kind: mcp +version: 1.0.0 +owner: plattform +scope: public +description: Bindet Munin als MCP-Server an. +server: + name: munin + url: https://munin.intern/mcp + headers: + Authorization: "Bearer {{env:MUNIN_KEY}}" diff --git a/contract/examples/packages/pub-munin-nutzen/1.4.0/SKILL.md b/contract/examples/packages/pub-munin-nutzen/1.4.0/SKILL.md new file mode 100644 index 0000000..1df6fe3 --- /dev/null +++ b/contract/examples/packages/pub-munin-nutzen/1.4.0/SKILL.md @@ -0,0 +1,10 @@ +# Munin nutzen + +Munin ist das gemeinsame Gedächtnis der Teams. + +1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach + bekannten Besonderheiten des Projekts. +2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest. +3. Mit `related` findest du verwandte Einträge. + +Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md). diff --git a/contract/examples/packages/pub-munin-nutzen/1.4.0/hugin.yaml b/contract/examples/packages/pub-munin-nutzen/1.4.0/hugin.yaml new file mode 100644 index 0000000..385570e --- /dev/null +++ b/contract/examples/packages/pub-munin-nutzen/1.4.0/hugin.yaml @@ -0,0 +1,11 @@ +id: pub-munin-nutzen +kind: skill +version: 1.4.0 +owner: plattform +scope: public +description: > + Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen + remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest. +license: intern +changes: > + Hinweis auf das neue Werkzeug „related“ ergänzt. diff --git a/contract/examples/packages/pub-munin-nutzen/1.4.0/referenz/beispiele.md b/contract/examples/packages/pub-munin-nutzen/1.4.0/referenz/beispiele.md new file mode 100644 index 0000000..9e9263c --- /dev/null +++ b/contract/examples/packages/pub-munin-nutzen/1.4.0/referenz/beispiele.md @@ -0,0 +1,4 @@ +# Beispiele + +- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“ +- Schlecht: „Ich habe heute die Tests laufen lassen.“ diff --git a/contract/examples/packages/pub-munin-nutzen/1.5.0/SKILL.md b/contract/examples/packages/pub-munin-nutzen/1.5.0/SKILL.md new file mode 100644 index 0000000..917ebf5 --- /dev/null +++ b/contract/examples/packages/pub-munin-nutzen/1.5.0/SKILL.md @@ -0,0 +1,11 @@ +# Munin nutzen + +Munin ist das gemeinsame Gedächtnis der Teams. + +1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach + bekannten Besonderheiten des Projekts. +2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest. +3. Mit `related` findest du verwandte Einträge. + +Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“ +Schlecht: „Ich habe heute die Tests laufen lassen.“ diff --git a/contract/examples/packages/pub-munin-nutzen/1.5.0/hugin.yaml b/contract/examples/packages/pub-munin-nutzen/1.5.0/hugin.yaml new file mode 100644 index 0000000..47be0ec --- /dev/null +++ b/contract/examples/packages/pub-munin-nutzen/1.5.0/hugin.yaml @@ -0,0 +1,11 @@ +id: pub-munin-nutzen +kind: skill +version: 1.5.0 +owner: plattform +scope: public +description: > + Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen + remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest. +license: intern +changes: > + Beispiele direkt in den Skill übernommen; referenz/ entfällt. diff --git a/contract/examples/packages/pub-release-notiz/1.0.0/COMMAND.md b/contract/examples/packages/pub-release-notiz/1.0.0/COMMAND.md new file mode 100644 index 0000000..3a7edef --- /dev/null +++ b/contract/examples/packages/pub-release-notiz/1.0.0/COMMAND.md @@ -0,0 +1,4 @@ +Schreibe eine Release-Notiz für Version {{version}}, gedacht für {{zielgruppe}}. +Lies dazu die Commits seit dem letzten Tag. + +Weitere Wünsche: $ARGUMENTS diff --git a/contract/examples/packages/pub-release-notiz/1.0.0/hugin.yaml b/contract/examples/packages/pub-release-notiz/1.0.0/hugin.yaml new file mode 100644 index 0000000..2bd8a3d --- /dev/null +++ b/contract/examples/packages/pub-release-notiz/1.0.0/hugin.yaml @@ -0,0 +1,7 @@ +id: pub-release-notiz +kind: command +version: 1.0.0 +owner: plattform +scope: public +description: Schreibt eine Release-Notiz für eine Version. +arguments: [version, zielgruppe] diff --git a/contract/examples/packages/pub-specht-plugin/1.0.0/hugin.yaml b/contract/examples/packages/pub-specht-plugin/1.0.0/hugin.yaml new file mode 100644 index 0000000..7d1d77f --- /dev/null +++ b/contract/examples/packages/pub-specht-plugin/1.0.0/hugin.yaml @@ -0,0 +1,9 @@ +id: pub-specht-plugin +kind: plugin +version: 1.0.0 +owner: kern +scope: public +targets: [opencode] +description: > + OpenCode-Plugin mit dem Werkzeug specht_marke, das eine feste Erkennungsmarke + liefert. Beispiel für ein Plugin, das Hugin verteilt. diff --git a/contract/examples/packages/pub-specht-plugin/1.0.0/index.ts b/contract/examples/packages/pub-specht-plugin/1.0.0/index.ts new file mode 100644 index 0000000..180f319 --- /dev/null +++ b/contract/examples/packages/pub-specht-plugin/1.0.0/index.ts @@ -0,0 +1,17 @@ +import { tool } from "@opencode-ai/plugin" +import { marke } from "./lib/marke.ts" + +// OpenCode ruft jede exportierte Funktion beim Start auf +export const SpechtPlugin = async () => { + return { + tool: { + specht_marke: tool({ + description: "Liefert die Erkennungsmarke des Specht-Plugins", + args: {}, + async execute() { + return marke + }, + }), + }, + } +} diff --git a/contract/examples/packages/pub-specht-plugin/1.0.0/lib/marke.ts b/contract/examples/packages/pub-specht-plugin/1.0.0/lib/marke.ts new file mode 100644 index 0000000..c5cfcfe --- /dev/null +++ b/contract/examples/packages/pub-specht-plugin/1.0.0/lib/marke.ts @@ -0,0 +1 @@ +export const marke = "SPECHT-5127" diff --git a/contract/examples/packages/pub-zeit-lokal/1.0.0/hugin.yaml b/contract/examples/packages/pub-zeit-lokal/1.0.0/hugin.yaml new file mode 100644 index 0000000..b54adaa --- /dev/null +++ b/contract/examples/packages/pub-zeit-lokal/1.0.0/hugin.yaml @@ -0,0 +1,14 @@ +id: pub-zeit-lokal +kind: mcp +version: 1.0.0 +owner: kern +scope: public +description: > + Lokaler MCP-Server mit dem Werkzeug zeit, das Datum und Uhrzeit des Rechners + liefert. Beispiel für einen Server, den Hugin samt Code verteilt. +server: + name: zeit + local: + runtime: python + entry: zeit_server + env: [ZEIT_ZONE_HINWEIS] diff --git a/contract/examples/packages/pub-zeit-lokal/1.0.0/zeit_server.py b/contract/examples/packages/pub-zeit-lokal/1.0.0/zeit_server.py new file mode 100644 index 0000000..1b8e673 --- /dev/null +++ b/contract/examples/packages/pub-zeit-lokal/1.0.0/zeit_server.py @@ -0,0 +1,52 @@ +"""Minimaler MCP-Server über stdio, nur mit der Standardbibliothek. + +Ein JSON-Objekt je Zeile. Ausgabe als Bytes, damit Windows kein \r einfügt. +""" + +import json +import os +import sys +from datetime import datetime + +TOOL = { + "name": "zeit", + "description": "Liefert Datum und Uhrzeit dieses Rechners.", + "inputSchema": {"type": "object", "properties": {}}, +} + + +def answer(message): + method = message.get("method") + if method == "initialize": + return {"protocolVersion": message["params"]["protocolVersion"], + "capabilities": {"tools": {}}, "serverInfo": {"name": "zeit", "version": "1.0.0"}} + if method == "tools/list": + return {"tools": [TOOL]} + if method == "tools/call": + hint = os.environ.get("ZEIT_ZONE_HINWEIS", "") + text = f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z} UHR-4711" + (f" ({hint})" if hint else "") + return {"content": [{"type": "text", "text": text}]} + if method == "ping": + return {} + return None + + +def main(): + for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + if "id" not in message: + continue # Benachrichtigungen brauchen keine Antwort + result = answer(message) + if result is None: + reply = {"jsonrpc": "2.0", "id": message["id"], + "error": {"code": -32601, "message": f"unbekannt: {message.get('method')}"}} + else: + reply = {"jsonrpc": "2.0", "id": message["id"], "result": result} + sys.stdout.buffer.write(json.dumps(reply).encode("utf-8") + b"\n") + sys.stdout.buffer.flush() + + +if __name__ == "__main__": + main() diff --git a/contract/examples/scenarios.yaml b/contract/examples/scenarios.yaml new file mode 100644 index 0000000..62a19ba --- /dev/null +++ b/contract/examples/scenarios.yaml @@ -0,0 +1,29 @@ +# Szenarien für goldene Bundles. Server erzeugt sie (pytest --update), +# CLI wendet sie in seinen Tests an. Pfade relativ zu packages/. +- name: first-install + tools: [claude, opencode@1] + packages: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0, pub-munin-anbindung/1.0.0] + mandatory: [pub-munin-anbindung] + +- name: update + tools: [claude, opencode@1] + packages: [pub-munin-nutzen/1.5.0, pub-munin-anbindung/1.0.0] + mandatory: [pub-munin-anbindung] + installed: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0, pub-munin-anbindung/1.0.0] + +- name: claude-only + tools: [claude] + packages: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0] + +- name: commands-and-agents + tools: [claude, opencode@1] + packages: [pub-release-notiz/1.0.0, pub-code-review/1.0.0] + +- name: local-mcp + tools: [claude, opencode@1] + packages: [pub-zeit-lokal/1.0.0] + +# Plugin nur für OpenCode: Claude bekommt nichts +- name: opencode-plugin + tools: [claude, opencode@1] + packages: [pub-specht-plugin/1.0.0] diff --git a/contract/hugin-bundle.schema.json b/contract/hugin-bundle.schema.json new file mode 100644 index 0000000..f15aea5 --- /dev/null +++ b/contract/hugin-bundle.schema.json @@ -0,0 +1,473 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hugin.intern/schema/hugin-bundle-1.json", + "title": "hugin-bundle.json", + "description": "Installationsanleitung in einem Hugin-Bundle (ZIP). Beschreibt den vollständigen Soll-Zustand der angefragten Pakete im Repo. Clients wenden die Operationen der Reihe nach an.", + "type": "object", + "required": [ + "format", + "generated", + "tools", + "packages", + "operations", + "hints" + ], + "additionalProperties": false, + "properties": { + "format": { + "const": 1 + }, + "generated": { + "type": "string", + "format": "date-time" + }, + "tools": { + "description": "Für welche Werkzeuge übersetzt wurde, mit Hauptversion.", + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "claude", + "opencode@1", + "opencode@2" + ] + }, + "uniqueItems": true + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/$defs/package" + } + }, + "operations": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/op_file" + }, + { + "$ref": "#/$defs/op_block" + }, + { + "$ref": "#/$defs/op_json" + }, + { + "$ref": "#/$defs/op_remove" + }, + { + "$ref": "#/$defs/op_json_remove" + }, + { + "$ref": "#/$defs/op_install" + } + ] + } + }, + "hints": { + "type": "array", + "items": { + "$ref": "#/$defs/hint" + } + } + }, + "$defs": { + "package_id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 1, + "maxLength": 64 + }, + "checksum": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "repo_path": { + "description": "Pfad relativ zur Repo-Wurzel, mit /, ohne führenden /. Segmente '.' und '..' sind verboten; Clients prüfen das zusätzlich selbst (das Muster kommt ohne Lookahead aus, damit es auch mit RE2 funktioniert).", + "type": "string", + "minLength": 1, + "pattern": "^[^/\\\\:*?\"<>|][^\\\\:*?\"<>|]*$" + }, + "zip_path": { + "description": "Pfad einer Datei im ZIP, immer unter files/. Segmente '..' sind verboten.", + "type": "string", + "pattern": "^files/[^\\\\]+$" + }, + "json_path": { + "description": "Schlüsselpfad in einer JSON-Datei, als Liste von Schlüsseln (Punkte in Namen sind erlaubt).", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "package": { + "type": "object", + "required": [ + "id", + "version", + "checksum", + "kind", + "risk", + "compatibility", + "mandatory" + ], + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/package_id" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "checksum": { + "$ref": "#/$defs/checksum" + }, + "kind": { + "enum": [ + "skill", + "rules", + "command", + "subagent", + "mcp", + "plugin", + "hook" + ] + }, + "risk": { + "enum": [ + "text", + "tools", + "scripts", + "permissions" + ] + }, + "compatibility": { + "enum": [ + "portable", + "translated", + "claude-only", + "opencode-only" + ] + }, + "mandatory": { + "type": "boolean" + } + } + }, + "op_file": { + "description": "Datei anlegen oder ersetzen. Inhalt liegt im ZIP unter quelle.", + "type": "object", + "required": [ + "op", + "package", + "path", + "source", + "sha256" + ], + "additionalProperties": false, + "properties": { + "op": { + "const": "file" + }, + "package": { + "$ref": "#/$defs/package_id" + }, + "path": { + "$ref": "#/$defs/repo_path" + }, + "source": { + "$ref": "#/$defs/zip_path" + }, + "sha256": { + "$ref": "#/$defs/checksum" + } + } + }, + "op_block": { + "description": "Verwalteten Block in einer Textdatei setzen. Der Block steht zwischen den Zeilen ''. Leerer inhalt entfernt den Block. Fehlt die Datei, wird sie aus anlegen erzeugt (falls gesetzt), sonst nur mit dem Block.", + "type": "object", + "required": [ + "op", + "file", + "block", + "content" + ], + "additionalProperties": false, + "properties": { + "op": { + "const": "block" + }, + "file": { + "$ref": "#/$defs/repo_path" + }, + "block": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "content": { + "type": "string" + }, + "create": { + "$ref": "#/$defs/zip_path" + } + } + }, + "op_json": { + "description": "Schlüssel in einer JSON- oder JSONC-Datei setzen. modus 'setzen' ersetzt den Wert; 'listeneintrag' stellt sicher, dass wert in der Liste unter schluessel vorkommt. Andere Inhalte, Reihenfolge und Kommentare bleiben erhalten. Fehlt die Datei, wird sie aus anlegen erzeugt (falls gesetzt), sonst als leeres Objekt.", + "type": "object", + "required": [ + "op", + "file", + "key", + "value" + ], + "additionalProperties": false, + "properties": { + "op": { + "const": "json" + }, + "package": { + "$ref": "#/$defs/package_id", + "description": "Fehlt bei gemeinsamen Einträgen, die Hugin für mehrere Pakete pflegt (z. B. instructions in opencode.json)." + }, + "file": { + "$ref": "#/$defs/repo_path" + }, + "key": { + "$ref": "#/$defs/json_path" + }, + "mode": { + "enum": [ + "set", + "list_item" + ], + "default": "setzen" + }, + "value": {}, + "create": { + "$ref": "#/$defs/zip_path" + } + } + }, + "op_remove": { + "description": "Datei oder Ordner entfernen, den eine frühere Version angelegt hat. Fehlt das Ziel, ist nichts zu tun.", + "type": "object", + "required": [ + "op", + "package", + "path" + ], + "additionalProperties": false, + "properties": { + "op": { + "const": "remove" + }, + "package": { + "$ref": "#/$defs/package_id" + }, + "path": { + "$ref": "#/$defs/repo_path" + } + } + }, + "op_json_remove": { + "description": "Schlüssel entfernen ('setzen') bzw. Eintrag aus der Liste entfernen ('listeneintrag').", + "type": "object", + "required": [ + "op", + "file", + "key" + ], + "additionalProperties": false, + "properties": { + "op": { + "const": "json_remove" + }, + "package": { + "$ref": "#/$defs/package_id", + "description": "Fehlt bei gemeinsamen Einträgen, die Hugin für mehrere Pakete pflegt (z. B. instructions in opencode.json)." + }, + "file": { + "$ref": "#/$defs/repo_path" + }, + "key": { + "$ref": "#/$defs/json_path" + }, + "mode": { + "enum": [ + "set", + "list_item" + ], + "default": "setzen" + }, + "value": {} + } + }, + "hint": { + "type": "object", + "required": [ + "kind" + ], + "oneOf": [ + { + "properties": { + "kind": { + "const": "env_variable" + }, + "name": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "package": { + "$ref": "#/$defs/package_id" + } + }, + "required": [ + "kind", + "name", + "package" + ], + "additionalProperties": false + }, + { + "properties": { + "kind": { + "const": "min_version" + }, + "tool": { + "enum": [ + "claude", + "opencode" + ] + }, + "version": { + "type": "string" + }, + "package": { + "$ref": "#/$defs/package_id" + } + }, + "required": [ + "kind", + "tool", + "version" + ], + "additionalProperties": false + }, + { + "properties": { + "kind": { + "const": "deprecated" + }, + "package": { + "$ref": "#/$defs/package_id" + }, + "successor": { + "$ref": "#/$defs/package_id" + } + }, + "required": [ + "kind", + "package" + ], + "additionalProperties": false + }, + { + "properties": { + "kind": { + "const": "major_available" + }, + "package": { + "$ref": "#/$defs/package_id" + }, + "version": { + "type": "string" + } + }, + "required": [ + "kind", + "package", + "version" + ], + "additionalProperties": false + } + ] + }, + "local_path": { + "description": "Pfad einer Datei im ZIP für lokale Server, immer unter local///. Segmente '..' sind verboten.", + "type": "string", + "pattern": "^local/[^\\\\]+$" + }, + "op_install": { + "description": "Lokalen MCP-Server außerhalb des Repos installieren (→ KONZEPT.md 20, Stufe 5b). Ablage: ///. Gestartet wird mit hugin mcp run ; Clients ohne diesen Befehl überspringen die Operation mit einer Warnung.", + "type": "object", + "required": [ + "op", + "package", + "version", + "checksum", + "runtime", + "entry", + "files" + ], + "additionalProperties": false, + "properties": { + "op": { + "const": "install" + }, + "package": { + "$ref": "#/$defs/package_id" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "checksum": { + "$ref": "#/$defs/checksum" + }, + "runtime": { + "enum": [ + "python" + ] + }, + "entry": { + "description": "python: Modul (python -m) oder Skript im Paket (.py)", + "type": "string", + "minLength": 1 + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "path", + "source", + "sha256" + ], + "additionalProperties": false, + "properties": { + "path": { + "$ref": "#/$defs/repo_path" + }, + "source": { + "$ref": "#/$defs/local_path" + }, + "sha256": { + "$ref": "#/$defs/checksum" + } + } + } + }, + "python": { + "description": "kleinste nötige Python-Version, z. B. 3.10", + "type": "string", + "pattern": "^3\\.\\d{1,2}$" + } + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0e9d36f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +# PostgreSQL zum Entwickeln und Testen, dauerhaft (Daten im Volume hugin-postgres). +# +# docker compose up -d # starten +# docker compose down # anhalten, Daten bleiben +# docker compose down -v # anhalten und Daten löschen +# +# Datenbanken: hugin (für den Server, z. B. scripts/demo.sh) und die Verwaltungs- +# datenbank postgres (für die Tests; jeder Test legt dort eine eigene an und +# räumt sie wieder ab). Zugang nur von diesem Rechner (127.0.0.1:55432). +# +# HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh +# scripts/postgres-test.sh --compose + +name: hugin + +services: + postgres: + image: postgres:17.6 + environment: + POSTGRES_USER: hugin + POSTGRES_PASSWORD: hugin + POSTGRES_DB: hugin + ports: + - "127.0.0.1:55432:5432" + volumes: + - hugin-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hugin -d hugin"] + interval: 2s + timeout: 3s + retries: 30 + restart: unless-stopped + +volumes: + hugin-postgres: diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh new file mode 100755 index 0000000..87359cb --- /dev/null +++ b/scripts/build-cli.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Baut das CLI für alle unterstützten Systeme (→ KONZEPT.md 11, 18). +# +# scripts/build-cli.sh 0.2.0 # nach cli/dist/0.2.0/ +# scripts/build-cli.sh 0.2.0 /srv/hugin-downloads # zusätzlich in den Download-Ordner (HUGIN_DOWNLOAD_DIR) +# +# Ergebnis: hugin--[.exe] und SHA256SUMS. Ohne CGO, also ohne +# Abhängigkeiten auf dem Zielrechner. +set -euo pipefail + +VERSION="${1:?Version angeben, z. B. scripts/build-cli.sh 0.2.0}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version im Format x.y.z"; exit 2; } +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET="$ROOT/cli/dist/$VERSION" +rm -rf "$TARGET" && mkdir -p "$TARGET" + +for platform in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64; do + os="${platform%/*}"; arch="${platform#*/}" + file="hugin-$os-$arch"; [[ "$os" == windows ]] && file+=".exe" + (cd "$ROOT/cli" && CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \ + go build -trimpath -ldflags "-s -w -X main.Version=$VERSION" -o "$TARGET/$file" ./cmd/hugin) + echo " $file" +done +(cd "$TARGET" && shasum -a 256 hugin-* > SHA256SUMS) +echo "Gebaut: $TARGET" + +if [[ -n "${2:-}" ]]; then + mkdir -p "$2/$VERSION" + cp "$TARGET"/* "$2/$VERSION/" + echo "Kopiert nach $2/$VERSION (für die Seite „Programm hugin“)" +fi diff --git a/scripts/csp-test.sh b/scripts/csp-test.sh new file mode 100755 index 0000000..160a4cd --- /dev/null +++ b/scripts/csp-test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Prüft das CSS-Framework unter der strengen CSP von Hugin (→ KONZEPT.md 16.4): +# zählt CSP-Verstöße der Demo und klickt alle data-action-Aktionen durch. +# Braucht Google Chrome (headless). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CH="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" +PORT="${PORT:-8813}" +PROFILE="$(mktemp -d)" +python3 -I "$ROOT/scripts/csp/server.py" "$ROOT/static/css-framework/${FOLDER:-.}" "$PORT" 1 "$ROOT/scripts/csp/actions.js" & +SERVER=$! +# Rückgabewert des Tests bewahren; wait auf den beendeten Server würde ihn überschreiben +trap 'rc=$?; kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true; rm -rf "$PROFILE"; exit $rc' EXIT +sleep 1 +# Chrome endet nach dem Zeitbudget mit 143; bewertet wird nur die ausgegebene Seite +"$CH" --headless=new --disable-gpu --user-data-dir="$PROFILE" --virtual-time-budget=6000 \ + --dump-dom "http://127.0.0.1:$PORT/index.html" >"$PROFILE/page.html" 2>/dev/null || true +python3 -I -c ' +import html, re, sys +t = sys.stdin.read() +failed = 0 +for part in ("actions", "csp-result"): + m = re.search(r"

(.*?)
", t, re.S) + text = html.unescape(m.group(1)) if m else part + ": kein Ergebnis" + print(text) + failed += (not m) or ("FEHL" in text) or (part == "csp-result" and not text.startswith("VIOLATIONS 0")) +sys.exit(1 if failed else 0)' <"$PROFILE/page.html" diff --git a/scripts/csp/actions.js b/scripts/csp/actions.js new file mode 100644 index 0000000..8d6ee8e --- /dev/null +++ b/scripts/csp/actions.js @@ -0,0 +1,22 @@ +window.addEventListener('load', function () { setTimeout(function () { + var r = []; + function check(name, f) { try { r.push((f() ? 'OK ' : 'FEHLT') + ' ' + name); } catch (e) { r.push('FEHLER ' + name + ': ' + e); } } + function click(el) { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); } + var q = function (s) { return document.querySelector(s); }; + check('modal-open', function () { var b = q('[data-action="modal-open"]'); click(b); return q('#' + b.dataset.target).classList.contains('open') && document.body.classList.contains('modal-open'); }); + check('modal-close', function () { var m = q('.modal-backdrop.open'); click(m.querySelector('[data-action="modal-close"]')); return !m.classList.contains('open') && !document.body.classList.contains('modal-open'); }); + check('modal-backdrop', function () { var b = q('[data-action="modal-open"]'); click(b); var m = q('#' + b.dataset.target); click(m); return !m.classList.contains('open'); }); + check('tab', function () { var t = document.querySelectorAll('[data-action="tab"]')[1]; click(t); return t.classList.contains('active') && q('#' + t.dataset.target).classList.contains('active'); }); + check('dropdown', function () { var d = q('[data-action="dropdown"]'); click(d); return q('#' + d.dataset.target).classList.contains('open'); }); + check('dropdown schließt bei Klick daneben', function () { click(document.body); return !q('.dropdown.open'); }); + check('nav-dropdown', function () { var d = q('[data-action="nav-dropdown"]'); click(d); return d.closest('li').classList.contains('open'); }); + check('toggle-class', function () { var e = q('[data-action="toggle-class"]'); var before = e.classList.contains(e.dataset.class); click(e); return e.classList.contains(e.dataset.class) !== before; }); + check('sort', function () { var th = q('[data-action="sort"]'); click(th); return th.dataset.sort === 'asc'; }); + check('toast + XSS-fest', function () { showToast('', 'success'); var t = q('.toast:last-child'); return t && t.querySelector('.toast-title').textContent === '' && !t.querySelector('img') && t.querySelector('.toast-icon-success'); }); + check('demo-toast', function () { var n = document.querySelectorAll('.toast').length; click(q('[data-action="demo-toast"]')); return document.querySelectorAll('.toast').length === n + 1; }); + check('toast-close', function () { var t = q('.toast'); click(t.querySelector('[data-action="toast-close"]')); return t.classList.contains('is-leaving'); }); + check('theme-toggle', function () { var before = document.documentElement.dataset.theme; click(q('[data-action="theme-toggle"]')); return document.documentElement.dataset.theme !== before && getComputedStyle(q('#icon-sun')).display !== getComputedStyle(q('#icon-moon')).display; }); + check('alert-dismiss', function () { var a = q('[data-action="alert-dismiss"]'); click(a); return a.closest('.alert').classList.contains('is-dismissing'); }); + check('data-progress', function () { var b = q('[data-progress="80"]'); return b.style.width === '80%'; }); + var m = document.createElement('pre'); m.id = 'actions'; m.textContent = r.join('\n'); document.body.appendChild(m); +}, 300); }); diff --git a/scripts/csp/server.py b/scripts/csp/server.py new file mode 100644 index 0000000..de7d5d3 --- /dev/null +++ b/scripts/csp/server.py @@ -0,0 +1,28 @@ +# Testserver für scripts/csp-test.sh: liefert Dateien mit strenger CSP aus und +# schiebt Prüfskripte ein (Zählen von CSP-Verstößen, Klicktest der Aktionen). +import http.server, sys, functools, os +CSP = ("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " + "connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'") +TRIAL = b"""window.__csp=[];document.addEventListener('securitypolicyviolation',function(e){ +window.__csp.push(e.violatedDirective+' '+(e.sample||'').slice(0,40)+' @'+e.lineNumber);}); +window.addEventListener('load',function(){setTimeout(function(){var m=document.createElement('pre'); +m.id='csp-result';m.textContent='VIOLATIONS '+window.__csp.length+'\\n'+window.__csp.join('\\n'); +document.body.appendChild(m);},500);});""" +class H(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + if self.server.csp: self.send_header("Content-Security-Policy", CSP) + super().end_headers() + def do_GET(self): + if self.path == "/__actions.js": + self.send_response(200); self.send_header("Content-Type","text/javascript"); self.end_headers(); self.wfile.write(open(sys.argv[4],"rb").read()); return + if self.path == "/__probe.js": + self.send_response(200); self.send_header("Content-Type","text/javascript"); self.end_headers(); self.wfile.write(TRIAL); return + if self.path.endswith(".html"): + raw = open(os.path.join(self.server.folder, self.path.lstrip("/")), "rb").read() + raw = raw.replace(b"", b'', 1).replace(b"", b'', 1) + self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8"); self.end_headers(); self.wfile.write(raw); return + super().do_GET() + def log_message(self, *a): pass +folder, port, csp = sys.argv[1], int(sys.argv[2]), sys.argv[3] == "1" +s = http.server.ThreadingHTTPServer(("127.0.0.1", port), functools.partial(H, directory=folder)); s.csp = csp; s.folder = folder +s.serve_forever() diff --git a/scripts/csp/web_actions.py b/scripts/csp/web_actions.py new file mode 100644 index 0000000..ff7b125 --- /dev/null +++ b/scripts/csp/web_actions.py @@ -0,0 +1,203 @@ +"""Weboberfläche im echten Chrome bedienen: Live-Suche, Tabs, Kopieren, Rückfragen, Formulare. + +Ergänzt web_browser.py (CSP und Bildschirmfotos) um das, was nur mit JavaScript im Browser +prüfbar ist: htmx-Trigger, hugin.js und die Aktionen des Frameworks auf den echten Seiten. + + uv run --project server --with websockets --with httpx python scripts/csp/web_actions.py +""" + +import asyncio +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +import httpx +import websockets + +sys.path.insert(0, str(Path(__file__).parent)) +from web_browser import ADMIN, CHROME, ROOT, cdp, cookie_for, create_data, free_port # noqa: E402 + + +async def js(ws, expression): + result = await cdp(ws, "Runtime.evaluate", expression=expression, returnByValue=True, awaitPromise=True) + return result["result"].get("value") + + +async def press(ws, key, code, key_code): + for kind in ("keyDown", "keyUp"): + await cdp(ws, "Input.dispatchKeyEvent", type=kind, key=key, code=code, windowsVirtualKeyCode=key_code) + + +async def run(port_chrome: int, url: str, cookies: dict[str, str]) -> bool: + target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json() + results = [] + + def check(name, ok): + results.append(bool(ok)) + print(("OK " if ok else "FEHLT ") + name) + + async with websockets.connect(target["webSocketDebuggerUrl"], max_size=50_000_000) as ws: + await cdp(ws, "Page.enable") + await cdp(ws, "Network.enable") + + async def go(path, who): + await cdp(ws, "Network.clearBrowserCookies") + await cdp(ws, "Network.setCookie", name="hugin_session", value=cookies[who], url=url) + await cdp(ws, "Page.navigate", url=url + path) + await asyncio.sleep(1.2) + + # Topbar: Abmelden bleibt sichtbar, auch unten auf langen Seiten und auf dem Handy + logout_visible = ("(function () { var b = [...document.querySelectorAll('button')]" + ".find(b => b.textContent.trim() == 'Abmelden'); var r = b.getBoundingClientRect();" + "return r.width > 0 && r.top >= 0 && r.bottom <= innerHeight && r.left >= 0" + " && r.right <= document.documentElement.clientWidth; })()") + await cdp(ws, "Emulation.setDeviceMetricsOverride", width=1280, height=800, deviceScaleFactor=1, mobile=False) + await go("/manage?tenant=shop", "Admin") + await js(ws, "scrollTo(0, document.body.scrollHeight)") + await asyncio.sleep(0.3) + check("Topbar: Abmelden unten auf langer Seite sichtbar", await js(ws, logout_visible)) + await cdp(ws, "Emulation.setDeviceMetricsOverride", width=390, height=844, deviceScaleFactor=1, mobile=True) + await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI") + check("Topbar: auf dem Handy nichts abgeschnitten", await js(ws, logout_visible)) + await js(ws, "document.querySelector('[data-action=sidebar-open]').click()") + await asyncio.sleep(0.5) + check("Topbar: Menü öffnet die Sidebar", + await js(ws, "document.getElementById('sidebar').getBoundingClientRect().left >= 0")) + await cdp(ws, "Emulation.clearDeviceMetricsOverride") + + # Katalog: Live-Suche über htmx + await go("/catalog", "Shop CI") + await js(ws, "var i = document.getElementById('search'); i.value = 'haus';" + "i.dispatchEvent(new Event('input', {bubbles: true}))") + await asyncio.sleep(1.2) + text = await js(ws, "document.getElementById('catalog-list').textContent") + check("Katalog: Live-Suche", "pub-hausregeln" in text and "pub-munin-nutzen" not in text) + check("Katalog: Suche steht in der URL", "q=haus" in await js(ws, "location.href")) + + # Versionsseite: zurück zum ersten Tab + await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI") + await js(ws, "var t = document.querySelectorAll('[data-action=tab]'); t[1].click(); t[0].click()") + check("Version: Tab zurück zu Inhalt", + await js(ws, "document.getElementById('tab-content').classList.contains('active')")) + await js(ws, "document.getElementById('tabbtn-content').focus()") + await press(ws, "ArrowRight", "ArrowRight", 39) + check("Version: Pfeiltaste wechselt Tab samt aria-selected", + await js(ws, "document.activeElement.id == 'tabbtn-diff' && " + "document.getElementById('tabbtn-diff').getAttribute('aria-selected') == 'true' && " + "document.getElementById('tabbtn-content').getAttribute('aria-selected') == 'false' && " + "document.getElementById('tab-diff').classList.contains('active')")) + await press(ws, "Home", "Home", 36) + check("Version: Pos1 springt zum ersten Tab", + await js(ws, "document.getElementById('tab-content').classList.contains('active')")) + + # Paketseite: Kopieren, erst ohne Fokus (kein Zugriff auf die Zwischenablage), dann mit + await go("/packages/pub-munin-nutzen", "Shop CI") + await js(ws, "document.querySelector('[data-copy=subscribe]').click()") + await asyncio.sleep(0.6) + check("Kopieren ohne Zugriff: Text markiert, Hinweis auf Tastenkürzel", + "Markiert" in await js(ws, "document.getElementById('toast-container').textContent") + and "pub-munin-nutzen" in await js(ws, "getSelection().toString()")) + await cdp(ws, "Emulation.setFocusEmulationEnabled", enabled=True) + await cdp(ws, "Browser.grantPermissions", origin=url, + permissions=["clipboardReadWrite", "clipboardSanitizedWrite"]) + await js(ws, "document.querySelector('[data-copy=subscribe]').click()") + await asyncio.sleep(0.6) + check("Kopieren: Toast", "Kopiert" in await js(ws, "document.getElementById('toast-container').textContent")) + check("Kopieren: Abo in der Zwischenablage", + await js(ws, "navigator.clipboard.readText()") == "packages:\n - pub-munin-nutzen") + + # Verwaltung: Rückfrage und Felder je Key-Art + await go("/manage?tenant=shop", "Admin") + await js(ws, "window.__asked = []; window.confirm = function (q) { __asked.push(q); return false; }") + await js(ws, "[...document.querySelectorAll('button')].find(b => b.textContent.trim() == 'Widerrufen').click()") + await asyncio.sleep(0.8) + asked = await js(ws, "JSON.stringify(window.__asked || null)") + check("Verwaltung: Rückfrage vor Widerrufen, Abbrechen bleibt auf der Seite", + asked and "widerrufen?" in asked and await js(ws, "Array.isArray(window.__asked)")) + + def fields(): + return js(ws, "JSON.stringify(['key-project', 'key-projects'].map(id => {" + "var f = document.getElementById(id); return [f.disabled, f.closest('[data-when]').hidden]; }))") + + check("Key-Art Projekt: nur Feld Projekt", await fields() == "[[false,false],[true,true]]") + await js(ws, "var s = document.getElementById('key-kind'); s.value = 'integration';" + "s.dispatchEvent(new Event('change', {bubbles: true}))") + check("Key-Art Integration: nur Projektauswahl", await fields() == "[[true,true],[false,false]]") + await js(ws, "var s = document.getElementById('key-kind'); s.value = 'tenant';" + "s.dispatchEvent(new Event('change', {bubbles: true}))") + check("Key-Art Tenant: keins von beiden", await fields() == "[[true,true],[true,true]]") + check("Ausgeblendetes Feld ist wirklich unsichtbar", + await js(ws, "document.getElementById('key-project').offsetParent === null")) + + # Audit: Live-Filter über htmx + await go("/audit", "Admin") + await js(ws, "var s = document.getElementById('action'); s.value = 'key.';" + "s.dispatchEvent(new Event('change', {bubbles: true}))") + await asyncio.sleep(1.2) + actions = await js(ws, "[...document.querySelectorAll('#audit-list tbody tr td:nth-child(4) code')]" + ".map(c => c.textContent)") + check("Audit: Live-Filter", actions and all(x.startswith("key.") for x in actions)) + + # Prüfung: Zurückgeben verlangt einen Kommentar, Freigeben nicht + await go("/packages/pub-munin-nutzen/versions/1.5.0", "Prüfer") + await js(ws, "document.querySelector('[data-target=decide]').click()") + await js(ws, "document.querySelector('button[value=return]').click()") + await asyncio.sleep(0.8) + check("Prüfung: Zurückgeben ohne Kommentar bleibt im Dialog", + await js(ws, "location.pathname.endsWith('/versions/1.5.0') && " + "document.getElementById('comment').validity.valueMissing")) + await js(ws, "document.querySelector('button[value=approve]').click()") + await asyncio.sleep(1.2) + check("Prüfung: Freigeben ohne Kommentar geht", await js(ws, "location.pathname") == "/reviews") + # Editor zuletzt: Nach der Änderung hält die Warnung beim Verlassen jede weitere Navigation auf + # Editor: Tab rückt ein, Esc + Tab verlässt das Feld, Warnung bei ungespeicherten Änderungen + await go("/packages/pub-munin-nutzen/versions/1.4.0/edit", "Autorin") + check("Editor: ohne Änderung keine Warnung beim Verlassen", + not await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented")) + await js(ws, "var t = document.querySelector('textarea[name=manifest]'); t.focus(); t.setSelectionRange(0, 0)") + await press(ws, "Tab", "Tab", 9) + check("Editor: Tab rückt ein", + await js(ws, "document.querySelector('textarea[name=manifest]').value.startsWith(' id:')")) + check("Editor: nach Änderung Warnung beim Verlassen", + await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented")) + await press(ws, "Escape", "Escape", 27) + await press(ws, "Tab", "Tab", 9) + check("Editor: Esc und Tab springt weiter", + await js(ws, "document.activeElement.name") != "manifest") + + return all(results) + + +def main() -> None: + work = Path(tempfile.mkdtemp()) + port, port_chrome = free_port(), free_port() + url = f"http://127.0.0.1:{port}" + env = os.environ | {"HUGIN_DATABASE_URL": f"sqlite:///{work}/hugin.db", "HUGIN_ADMIN_TOKEN": ADMIN} + server_folder = ROOT / "server" + subprocess.run(["uv", "run", "-q", "hugin-server", "migrate"], cwd=server_folder, env=env, check=True) + server = subprocess.Popen(["uv", "run", "-q", "hugin-server", "run", "--host", "127.0.0.1", "--port", str(port)], + cwd=server_folder, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", f"--remote-debugging-port={port_chrome}", + f"--user-data-dir={work}/chrome", "about:blank"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(100): + try: + httpx.get(f"{url}/readyz").raise_for_status() + httpx.get(f"http://127.0.0.1:{port_chrome}/json/version").raise_for_status() + break + except httpx.HTTPError: + time.sleep(0.2) + keys = create_data(url) + cookies = {name: cookie_for(url, key) for name, key in keys.items()} + sys.exit(0 if asyncio.run(run(port_chrome, url, cookies)) else 1) + finally: + server.terminate() + chrome.terminate() + + +if __name__ == "__main__": + main() diff --git a/scripts/csp/web_browser.py b/scripts/csp/web_browser.py new file mode 100644 index 0000000..04fb1b6 --- /dev/null +++ b/scripts/csp/web_browser.py @@ -0,0 +1,200 @@ +"""Weboberfläche im echten Chrome prüfen: CSP-Verstöße zählen, Bildschirmfotos machen. + +Startet einen Hugin-Server mit Testdaten, meldet sich mit verschiedenen Keys an +und ruft jede Seite über das DevTools-Protokoll auf. Ein vor dem Laden +eingefügtes Skript (CDP, nicht von der CSP betroffen) zählt Verstöße. + + uv run --with websockets --with httpx python scripts/csp/web_browser.py +""" + +import asyncio +import base64 +import io +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +import zipfile +from pathlib import Path + +import httpx +import websockets + +ROOT = Path(__file__).resolve().parents[2] +CHROME = os.environ.get("CHROME", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") +ADMIN = "web-test-admin" +COUNTER = """window.__csp = []; document.addEventListener('securitypolicyviolation', + e => window.__csp.push(e.violatedDirective + ' ' + (e.sample || '').slice(0, 40)));""" + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def zip_folder(folder: Path) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as z: + for d in sorted(folder.rglob("*")): + if d.is_file(): + z.write(d, d.relative_to(folder).as_posix()) + return buffer.getvalue() + + +def create_data(url: str) -> dict[str, str]: + a = {"Authorization": f"Bearer {ADMIN}"} + c = httpx.Client(base_url=url) + keys = {} + for tenant, project in [("plattform", "kern"), ("shop", "api")]: + c.post("/api/v1/admin/tenants", headers=a, json={"slug": tenant, "name": tenant.title()}).raise_for_status() + c.post(f"/api/v1/admin/tenants/{tenant}/projects", headers=a, json={"slug": project, "name": project}) + for name, tenant, project, roles in [("Autorin", "plattform", "kern", ["pub_author"]), + ("Prüfer", "plattform", "kern", ["pub_reviewer"]), + ("Shop CI", "shop", "api", [])]: + response = c.post(f"/api/v1/admin/tenants/{tenant}/keys", headers=a, + json={"kind": "project", "name": name, "project": project, "roles": roles}) + keys[name] = response.json()["key"] + packages = ROOT / "contract/examples/packages" + for name in ["pub-munin-nutzen/1.4.0", "pub-hausregeln/1.0.0", "pub-munin-anbindung/1.0.0", + "pub-munin-nutzen/1.5.0"]: + c.post("/api/v1/drafts", headers={"Authorization": f"Bearer {keys['Autorin']}", + "Content-Type": "application/zip"}, + content=zip_folder(packages / name)).raise_for_status() + package, number = name.split("/") + base = f"/api/v1/packages/{package}/versions/{number}" + c.post(f"{base}/submit", headers={"Authorization": f"Bearer {keys['Autorin']}"}).raise_for_status() + if name != "pub-munin-nutzen/1.5.0": # 1.5.0 bleibt in Prüfung + c.post(f"{base}/approve", headers={"Authorization": f"Bearer {keys['Prüfer']}"}, + json={"comment": "passt"}).raise_for_status() + c.post("/api/v1/usage", headers={"Authorization": f"Bearer {keys['Shop CI']}"}, + json={"repo": "git.intern/shop/api", "tools": {"claude": "2.1.302"}, + "installed": {"pub-munin-nutzen": "1.4.0", "pub-hausregeln": "1.0.0"}}) + keys["Admin"] = ADMIN + return keys + + +def cookie_for(url: str, key: str) -> str: + response = httpx.post(f"{url}/login", data={"key": key, "next": ""}) + return response.cookies["hugin_session"] + + +async def cdp(ws, method, **params): + cdp.n += 1 + await ws.send(json.dumps({"id": cdp.n, "method": method, "params": params})) + while True: + response = json.loads(await ws.recv()) + if response.get("id") == cdp.n: + return response.get("result", {}) + + +cdp.n = 0 + + +async def validate_pages(port_chrome: int, url: str, pages, output: Path, dunkel: bool) -> int: + target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json() + error = 0 + async with websockets.connect(target["webSocketDebuggerUrl"], max_size=50_000_000) as ws: + await cdp(ws, "Page.enable") + await cdp(ws, "Network.enable") + await cdp(ws, "Page.addScriptToEvaluateOnNewDocument", source=COUNTER) + await cdp(ws, "Emulation.setDeviceMetricsOverride", width=1280, height=900, deviceScaleFactor=1, mobile=False) + if dunkel: + await cdp(ws, "Emulation.setEmulatedMedia", features=[{"name": "prefers-color-scheme", "value": "dark"}]) + for name, path, cookie in pages: + await cdp(ws, "Network.clearBrowserCookies") + if cookie: + await cdp(ws, "Network.setCookie", name="hugin_session", value=cookie, url=url) + await cdp(ws, "Page.navigate", url=url + path) + await asyncio.sleep(1.2) + result = await cdp(ws, "Runtime.evaluate", expression="JSON.stringify(window.__csp)", returnByValue=True) + verstoesse = json.loads(result["result"]["value"]) + error += len(verstoesse) + hoehe = (await cdp(ws, "Runtime.evaluate", expression="document.documentElement.scrollHeight", + returnByValue=True))["result"]["value"] + image = await cdp(ws, "Page.captureScreenshot", format="png", captureBeyondViewport=True, + clip={"x": 0, "y": 0, "width": 1280, "height": min(hoehe, 4000), "scale": 1}) + file = output / f"{'dunkel-' if dunkel else ''}{name}.png" + file.write_bytes(base64.b64decode(image["data"])) + print(f"{'✓' if not verstoesse else '✗'} {name:<12} {path:<45} Verstöße: {len(verstoesse)} {verstoesse[:3]}") + return error + + +async def gegenprobe(port_chrome: int, url: str, cookie: str) -> bool: + """Fügt ein style-Attribut ein; die Messung muss den Verstoß sehen.""" + target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json() + async with websockets.connect(target["webSocketDebuggerUrl"]) as ws: + await cdp(ws, "Page.enable") + await cdp(ws, "Network.enable") + await cdp(ws, "Page.addScriptToEvaluateOnNewDocument", source=COUNTER) + await cdp(ws, "Network.setCookie", name="hugin_session", value=cookie, url=url) + await cdp(ws, "Page.navigate", url=url + "/help") + await asyncio.sleep(1) + await cdp(ws, "Runtime.evaluate", expression="document.body.insertAdjacentHTML('beforeend', " + "'
x
')") + await asyncio.sleep(0.5) + anzahl = (await cdp(ws, "Runtime.evaluate", expression="window.__csp.length", returnByValue=True)) + return anzahl["result"]["value"] > 0 + + +def main() -> None: + output = Path(sys.argv[1]) + output.mkdir(parents=True, exist_ok=True) + arbeit = Path(tempfile.mkdtemp()) + port, port_chrome = free_port(), free_port() + url = f"http://127.0.0.1:{port}" + env = os.environ | {"HUGIN_DATABASE_URL": f"sqlite:///{arbeit}/hugin.db", "HUGIN_ADMIN_TOKEN": ADMIN} + server_folder = ROOT / "server" + subprocess.run(["uv", "run", "-q", "hugin-server", "migrate"], cwd=server_folder, env=env, check=True) + server = subprocess.Popen(["uv", "run", "-q", "hugin-server", "run", "--host", "127.0.0.1", "--port", str(port)], + cwd=server_folder, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", f"--remote-debugging-port={port_chrome}", + f"--user-data-dir={arbeit}/chrome", "about:blank"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(100): + try: + httpx.get(f"{url}/readyz").raise_for_status() + httpx.get(f"http://127.0.0.1:{port_chrome}/json/version").raise_for_status() + break + except httpx.HTTPError: + time.sleep(0.2) + keys = create_data(url) + c = {name: cookie_for(url, key) for name, key in keys.items()} + pages = [ + ("login", "/login", None), + ("catalog", "/catalog", c["Shop CI"]), + ("package", "/packages/pub-munin-nutzen", c["Shop CI"]), + ("version", "/packages/pub-munin-nutzen/versions/1.4.0", c["Shop CI"]), + ("pruefung", "/packages/pub-munin-nutzen/versions/1.5.0", c["Prüfer"]), + ("reviews", "/reviews", c["Prüfer"]), + ("upload", "/upload", c["Autorin"]), + ("project", "/project", c["Shop CI"]), + ("manage", "/manage?tenant=shop", c["Admin"]), + ("overview", "/overview", c["Shop CI"]), + ("system", "/system", c["Admin"]), + ("audit", "/audit", c["Admin"]), + ("new", "/new?kind=skill&id=pub-neu", c["Autorin"]), + ("editor", "/packages/pub-munin-nutzen/versions/1.4.0/edit", c["Autorin"]), + ("download", "/download", c["Shop CI"]), + ("help", "/help/package-format", c["Shop CI"]), + ("hilfe-ohne", "/help/teams", None), + ] + if not asyncio.run(gegenprobe(port_chrome, url, c["Shop CI"])): + print("Gegenprobe gescheitert: ein absichtlicher Verstoß wurde nicht gezählt") + sys.exit(2) + print("Gegenprobe ok: absichtlicher Verstoß wird gezählt") + error = asyncio.run(validate_pages(port_chrome, url, pages, output, dunkel=False)) + error += asyncio.run(validate_pages(port_chrome, url, pages[1:4], output, dunkel=True)) + print("Verstöße gesamt:", error) + sys.exit(1 if error else 0) + finally: + server.terminate() + chrome.terminate() + + +if __name__ == "__main__": + main() diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..1e4ea17 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Startet Hugin lokal mit Beispieldaten zum Ausprobieren. +# +# scripts/demo.sh # Server auf http://127.0.0.1:8080, Daten in .demo/ +# scripts/demo.sh --neu # Beispieldaten verwerfen und neu anlegen +# HOST=0.0.0.0 scripts/demo.sh # auch von anderen Rechnern erreichbar (z. B. Windows-Test) +# HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh +# # mit der PostgreSQL aus docker-compose.yml +# +# Beim ersten Start legt das Skript Tenants, Projekte, Personen und Keys an, +# veröffentlicht die Beispielpakete und schreibt alle Keys nach .demo/keys.txt. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEMO="$ROOT/.demo" +PORT="${PORT:-8080}" +URL="http://127.0.0.1:$PORT" +mkdir -p "$DEMO" +if [[ -n "${HUGIN_DATABASE_URL:-}" ]]; then + # Eigene Datenbank (z. B. PostgreSQL): die Keys gehören zu dieser Datenbank + KEYS="$DEMO/keys-$(printf '%s' "$HUGIN_DATABASE_URL" | shasum | cut -c1-8).txt" + if [[ "${1:-}" == "--neu" ]]; then + echo "--neu mit eigener Datenbank: Daten dort selbst löschen, z. B. docker compose down -v"; exit 2 + fi +else + # Nur die SQLite-Demo verwerfen; Keys anderer Datenbanken (keys-*.txt) bleiben + [[ "${1:-}" == "--neu" ]] && rm -f "$DEMO"/hugin.db* "$DEMO/keys.txt" "$DEMO/server.log" + export HUGIN_DATABASE_URL="sqlite:///$DEMO/hugin.db" + KEYS="$DEMO/keys.txt" +fi +export HUGIN_ADMIN_TOKEN="${HUGIN_ADMIN_TOKEN:-demo-admin}" +ADMIN=(-H "Authorization: Bearer $HUGIN_ADMIN_TOKEN") + +if ! (cd "$ROOT/server" && uv run -q hugin-server migrate 2>"$DEMO/migrate.log"); then + tail -1 "$DEMO/migrate.log" + echo "Migration gescheitert; die Datenbank stammt vermutlich von einem älteren Stand (ganzer Fehler: $DEMO/migrate.log)." + echo "Neu anlegen: bei SQLite 'scripts/demo.sh --neu'," + echo "bei PostgreSQL aus docker-compose.yml 'docker compose down -v && docker compose up -d'." + exit 1 +fi + +if [[ -f "$KEYS" ]]; then + echo "Beispieldaten vorhanden (.demo/). Keys: $KEYS" +else + (cd "$ROOT/server" && exec uv run -q hugin-server run --host "${HOST:-127.0.0.1}" --port "$PORT") >"$DEMO/server.log" 2>&1 & + SERVER=$! + trap 'kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true' EXIT + for _ in $(seq 60); do curl -fsS "$URL/readyz" >/dev/null 2>&1 && break; sleep 0.25; done + if curl -fsS "${ADMIN[@]}" "$URL/api/v1/admin/tenants" 2>/dev/null | grep -q '"plattform"'; then + echo "Die Datenbank enthält schon Demo-Daten, aber die Keys dazu fehlen ($KEYS)." + echo "Neu anlegen: bei PostgreSQL aus docker-compose.yml 'docker compose down -v && docker compose up -d'," + echo "bei SQLite 'scripts/demo.sh --neu'." + exit 1 + fi + + api() { curl -fsS -H "Content-Type: application/json" "$@"; } + field() { python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; } + for t in plattform:kern shop:api shop:web; do + api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d "{\"slug\":\"${t%%:*}\",\"name\":\"${t%%:*}\"}" >/dev/null 2>&1 || true + api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/${t%%:*}/projects" -d "{\"slug\":\"${t##*:}\",\"name\":\"${t##*:}\"}" >/dev/null + done + person() { api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/$1/people" -d "$2" | field key; } + key() { api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/$1/keys" -d "$2" | field key; } + ANNA=$(person plattform '{"name":"Anna Autorin","mail":"anna@firma.de","project":"kern","roles":["pub_author"]}') + BERT=$(person plattform '{"name":"Bert Prüfer","mail":"bert@firma.de","roles":["pub_reviewer"]}') + CARLA=$(person plattform '{"name":"Carla Admin","mail":"carla@firma.de","roles":["pub_reviewer","tenant_admin"]}') + SHOP_ADMIN=$(key shop '{"kind":"tenant","name":"Shop-Admin"}') + SHOP_CI=$(key shop '{"kind":"project","name":"Shop CI","project":"api"}') + SHOP_INT=$(key shop '{"kind":"integration","name":"Softwareverteilung"}') + + publish() { + local zip="$DEMO/package.zip" + rm -f "$zip"; (cd "$ROOT/contract/examples/packages/$1" && zip -qr "$zip" .) + curl -fsS -H "Authorization: Bearer $ANNA" -H "Content-Type: application/zip" --data-binary "@$zip" "$URL/api/v1/drafts" >/dev/null + local base="$URL/api/v1/packages/${1%/*}/versions/${1#*/}" + api -H "Authorization: Bearer $ANNA" -X POST "$base/submit" >/dev/null + [[ "${2:-}" == "open" ]] || api -H "Authorization: Bearer $BERT" "$base/approve" -d '{"comment":"Demo"}' >/dev/null + } + for p in pub-munin-nutzen/1.4.0 pub-hausregeln/1.0.0 pub-munin-anbindung/1.0.0 pub-release-notiz/1.0.0 pub-code-review/1.0.0; do + publish "$p" + done + publish pub-munin-nutzen/1.5.0 open # liegt bei den Prüfungen + curl -fsS "${ADMIN[@]}" -X PUT -H "Content-Type: application/json" "$URL/api/v1/admin/mandatory/pub-munin-anbindung" -d '{"tenant":null}' + api -H "Authorization: Bearer $SHOP_CI" "$URL/api/v1/usage" \ + -d '{"repo":"git.intern/shop/api","tools":{"claude":"2.1.302","opencode":"1.15.4"},"installed":{"pub-munin-nutzen":"1.4.0","pub-hausregeln":"1.0.0"}}' >/dev/null + + cat > "$KEYS" </dev/null; wait $SERVER 2>/dev/null || true + trap - EXIT +fi + +cat "$KEYS" +echo +echo "Weboberfläche: $URL API-Doku: $URL/api/docs MCP: $URL/mcp" +echo "Beenden mit Strg+C." +cd "$ROOT/server" && exec uv run -q hugin-server run --host "${HOST:-127.0.0.1}" --port "$PORT" diff --git a/scripts/integration-test.ps1 b/scripts/integration-test.ps1 new file mode 100644 index 0000000..7f3a164 --- /dev/null +++ b/scripts/integration-test.ps1 @@ -0,0 +1,125 @@ +<# +.SYNOPSIS + Prüft hugin-install.ps1 unter Windows PowerShell 5.1 gegen einen laufenden Hugin. + +.DESCRIPTION + Gegenstück zu scripts/integration-test.sh für Windows. Braucht einen Hugin mit + den Beispielpaketen, z. B. scripts/demo.sh auf einem anderen Rechner: + + HOST=0.0.0.0 scripts/demo.sh # auf dem Mac/Linux-Rechner + + Dann unter Windows (PowerShell 5.1, nicht pwsh): + + powershell -ExecutionPolicy Bypass -File integration-test.ps1 ` + -Server http://:8080 -Key + + Ändert nichts außerhalb eines Temp-Ordners. Rückgabewert 0, wenn alles passt. +#> +param( + [Parameter(Mandatory = $true)][string]$Server, + [Parameter(Mandatory = $true)][string]$Key, + # Nur zum Vorabtest auf anderen Systemen, z. B. -PowerShell pwsh; unter Windows weglassen + [string]$PowerShell = 'powershell.exe' +) + +Set-StrictMode -Version 2 +$ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$Utf8 = New-Object System.Text.UTF8Encoding($false) +$failures = 0 + +function Check([string]$Name, [bool]$Ok, [string]$Info = '') { + if ($Ok) { Write-Host "OK $Name" } else { Write-Host "FEHLER $Name $Info" -ForegroundColor Red; $script:failures++ } +} + +Write-Host "PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))" +if ($PSVersionTable.PSVersion.Major -ne 5) { Write-Warning 'Dieser Test ist für Windows PowerShell 5.1 gedacht.' } + +$work = Join-Path ([IO.Path]::GetTempPath()) ("hugin-test-" + [Guid]::NewGuid().ToString('N').Substring(0, 8)) +$repo = Join-Path $work 'repo' +New-Item -ItemType Directory -Path $repo -Force | Out-Null +try { + # Skript vom Server laden, wie eine Softwareverteilung es tun würde + $script = Join-Path $work 'hugin-install.ps1' + Invoke-WebRequest -UseBasicParsing -Uri ($Server.TrimEnd('/') + '/api/v1/delivery/client.ps1') ` + -Headers @{ Authorization = "Bearer $Key" } -OutFile $script + $raw = [IO.File]::ReadAllBytes($script) + Check 'client.ps1 mit BOM (für PowerShell 5.1)' ($raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF) + + [IO.File]::WriteAllText((Join-Path $repo 'CLAUDE.md'), "# Werk`r`n`r`nUnser Projekt.`r`n", $Utf8) + [IO.File]::WriteAllText((Join-Path $repo 'opencode.json'), "{`n `"model`": `"anthropic/claude-sonnet-5-5`"`n}`n", $Utf8) + + function Run([string[]]$More) { + $output = & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $script -Server $Server -Key $Key ` + -Repo $repo -Tools 'claude=2.1.302,opencode=1.19.3' -Client 'windows-test' @More 2>&1 | Out-String + return @{ Text = $output; Rc = $LASTEXITCODE } + } + + $first = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln,pub-code-review') + Check 'Erstinstallation' ($first.Rc -eq 0) $first.Text + foreach ($rel in '.claude\skills\pub-munin-nutzen\SKILL.md', '.claude\agents\pub-code-review.md', + '.opencode\agents\pub-code-review.md', '.hugin\rules\pub-hausregeln.md', '.mcp.json', 'hugin.lock.json') { + Check "Datei $rel" (Test-Path -LiteralPath (Join-Path $repo $rel)) + } + $skill = [IO.File]::ReadAllBytes((Join-Path $repo '.claude\skills\pub-munin-nutzen\SKILL.md')) + Check 'SKILL.md ohne BOM' (-not ($skill[0] -eq 0xEF -and $skill[1] -eq 0xBB)) + Check 'SKILL.md mit LF, ohne CRLF' (-not $Utf8.GetString($skill).Contains("`r`n")) + $claude = [IO.File]::ReadAllText((Join-Path $repo 'CLAUDE.md'), $Utf8) + Check 'CLAUDE.md behält Inhalt und CRLF' ($claude.StartsWith("# Werk`r`n`r`nUnser Projekt.`r`n`r`n") { $end = $i; break } + } + $new = @() + if ($Content -ne '') { + $new = @("") + @($Content.TrimEnd("`n") -split "`n") + @("") + } + if ($begin -ge 0 -and $end -ge 0) { + $rest = @(); if ($end + 1 -lt $lines.Count) { $rest = @($lines[($end + 1)..($lines.Count - 1)]) } + if ($new.Count -eq 0 -and $rest.Count -gt 0 -and $rest[0].Trim() -eq '') { $rest = @($rest | Select-Object -Skip 1) } + $linesBefore = @(); if ($begin -gt 0) { $linesBefore = @($lines[0..($begin - 1)]) } + $lines = @($linesBefore) + $new + $rest + } elseif ($new.Count -gt 0) { + if ($lines.Count -gt 0 -and $lines[$lines.Count - 1].Trim() -ne '') { [void]$lines.Add('') } + $lines = @($lines) + $new + } + $lines = @($lines) + while ($lines.Count -gt 0 -and $lines[$lines.Count - 1].Trim() -eq '') { $lines = @($lines | Select-Object -First ($lines.Count - 1)) } + if ($lines.Count -eq 0) { return '' } + return ($lines -join $nl) + $nl +} + +function Get-Block([string]$Text, [string]$Block) { + $m = [regex]::Match($Text, "(?s)") + if ($m.Success) { return ($m.Groups[1].Value -replace "`r`n", "`n") } + return $null +} + +function Invoke-Block($op) { + $d = Get-RepoFile $op.file + $text = if ($null -eq $d.Current) { '' } else { $Utf8.GetString($d.Current) } + if ($null -eq $d.Before -and $op.content -ne '' -and -not ($Lock.created -contains $op.file)) { $Lock.created += $op.file } + $key = "$($op.file)#$($op.block)" + $existing = Get-Block $text $op.block + $oldSha = Get-JsonProperty $Lock.blocks $key + if ($null -ne $existing -and $oldSha -and (Get-Sha $Utf8.GetBytes($existing)) -ne $oldSha.Value -and $existing -ne $op.content) { + [void]$Findings.Add("${key}: Block von Hand geändert") + } + $d.Current = $Utf8.GetBytes((Set-Block $text $op.block $op.content)) + if ($op.content -ne '') { $NewBlocks[$key] = Get-Sha $Utf8.GetBytes($op.content) } +} + +foreach ($op in @($Instructions.operations)) { + switch ($op.op) { + 'file' { Invoke-FileOp $op } + 'remove' { Invoke-RemoveOp $op.path } + 'json' { Invoke-JsonOp $op } + 'json_remove' { + $mode = if ($op.PSObject.Properties['mode']) { $op.mode } else { 'set' } + $value = if ($op.PSObject.Properties['value']) { $op.value } else { $null } + Invoke-JsonRemoveOp $op.file ([string[]]@($op.key)) $mode $value + } + 'block' { Invoke-Block $op } + # Lokale MCP-Server startet das Programm hugin (hugin mcp run); ohne es laufen sie nicht + 'install' { Write-Warning "$($op.package) $($op.version) ist ein lokaler MCP-Server; installieren mit dem Programm hugin (hugin sync), dieses Skript kann das nicht" } + default { Fail "unbekannte Operation '$($op.op)'; Skript aktualisieren ($base/client.ps1)" } + } +} +# Verwaistes aus der alten Lock aufräumen, auch ohne entfernen-Operation +foreach ($rel in @($OldFiles.Keys)) { if (-not $NewFiles.ContainsKey($rel)) { Invoke-RemoveOp $rel } } +foreach ($s in @($Lock.keys)) { + $value = if ($s.mode -eq 'list_item') { $s.value } else { $null } + if (-not (Find-Key $NewKeys $s.file ([string[]]@($s.path)) $s.mode $value)) { + Invoke-JsonRemoveOp $s.file ([string[]]@($s.path)) $s.mode $value + } +} + +# ---- Ergebnis -------------------------------------------------------------- + +foreach ($h in @($Instructions.hints)) { + if ($h.kind -eq 'env_variable') { Write-Host "Hinweis: $($h.package) braucht die Umgebungsvariable $($h.name)" } + elseif ($h.kind -eq 'major_available') { Write-Host "Hinweis: $($h.package) $($h.version) ist verfügbar (neue Hauptversion)" } + elseif ($h.kind -eq 'deprecated') { Write-Host "Hinweis: $($h.package) ist veraltet" } +} +if ($Findings.Count -gt 0) { + $Findings | ForEach-Object { Write-Warning $_ } + if (-not $Overwrite) { Fail 'nichts geändert; Befunde klären oder mit -Overwrite zurücksetzen' } +} + +$toWrite = @{}; $toDelete = @() +foreach ($rel in @($Files.Keys)) { + $d = $Files[$rel] + if ($null -ne $d.Json) { + $empty = @($d.Json.PSObject.Properties).Count -eq 0 + if ($empty -and ($Lock.created -contains $rel)) { $d.Current = $null } + else { $d.Current = $Utf8.GetBytes((ConvertTo-Json -InputObject $d.Json -Depth 20) + "`n") } + } + if ($null -ne $d.Current -and $d.Current.Length -eq 0 -and ($Lock.created -contains $rel)) { $d.Current = $null } + if ($null -eq $d.Current) { + if ($null -ne $d.Before) { $toDelete += $rel } + } elseif ($null -eq $d.Before -or (Get-Sha $d.Current) -ne (Get-Sha $d.Before)) { $toWrite[$rel] = $d.Current } +} + +foreach ($p in @($Instructions.packages)) { + $old = Get-JsonProperty $Lock.packages $p.id + if (-not $old) { Write-Host " neu $($p.id) $($p.version)" } + elseif ($old.Value.version -ne $p.version) { Write-Host " aktualisiert $($p.id) $($old.Value.version) -> $($p.version)" } +} +Write-Host ("Dateien: {0} schreiben, {1} löschen" -f $toWrite.Count, $toDelete.Count) +if ($DryRun) { Write-Host 'Trockenlauf: nichts geschrieben.'; return } + +# ---- Schreiben: erst Hilfsdateien, dann umbenennen, dann löschen ------------- + +$helper = @() +try { + foreach ($rel in $toWrite.Keys) { + $abs = Get-AbsPath $rel + $folder = [IO.Path]::GetDirectoryName($abs) + if (-not (Test-Path -LiteralPath $folder)) { [void](New-Item -ItemType Directory -Path $folder -Force) } + [IO.File]::WriteAllBytes("$abs.hugin-new", $toWrite[$rel]) + $helper += , @("$abs.hugin-new", $abs) + } +} catch { + foreach ($h in $helper) { Remove-Item -LiteralPath $h[0] -ErrorAction SilentlyContinue } + throw +} +foreach ($h in $helper) { + if (Test-Path -LiteralPath $h[1]) { Remove-Item -LiteralPath $h[1] -Force } + Move-Item -LiteralPath $h[0] -Destination $h[1] +} +foreach ($rel in $toDelete) { + $abs = Get-AbsPath $rel + Remove-Item -LiteralPath $abs -Force -ErrorAction SilentlyContinue + $folder = [IO.Path]::GetDirectoryName($abs) + $root = [IO.Path]::GetFullPath($Repo).TrimEnd([IO.Path]::DirectorySeparatorChar) + while ($folder -and $folder.Length -gt $root.Length -and (Test-Path -LiteralPath $folder) -and + -not (Get-ChildItem -LiteralPath $folder -Force | Select-Object -First 1)) { + Remove-Item -LiteralPath $folder -Force + $folder = [IO.Path]::GetDirectoryName($folder) + } + $Lock.created = @($Lock.created | Where-Object { $_ -ne $rel }) +} + +# ---- Lock und Nutzung ------------------------------------------------------ + +$newLock = New-Object PSObject -Property @{ + note = 'Von hugin-install.ps1 erzeugt. Nicht von Hand ändern.' + packages = (New-Object PSObject); files = (New-Object PSObject) + keys = @($NewKeys); blocks = (New-Object PSObject); created = @($Lock.created) +} +foreach ($p in @($Instructions.packages)) { Set-JsonProperty $newLock.packages $p.id (New-Object PSObject -Property @{ version = $p.version; checksum = $p.checksum }) } +foreach ($rel in $NewFiles.Keys) { Set-JsonProperty $newLock.files $rel $NewFiles[$rel] } +foreach ($s in $NewBlocks.Keys) { Set-JsonProperty $newLock.blocks $s $NewBlocks[$s] } +[IO.File]::WriteAllBytes($LockPath, $Utf8.GetBytes((ConvertTo-Json -InputObject $newLock -Depth 20) + "`n")) + +try { + $message = @{ repo = $Client; tools = $ToolVersions; installed = @{} } + foreach ($p in @($Instructions.packages)) { $message.installed[$p.id] = $p.version } + Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$base/usage" -Headers $header ` + -Body $Utf8.GetBytes((ConvertTo-Json -InputObject $message -Depth 20)) -ContentType 'application/json; charset=utf-8' | Out-Null +} catch { Write-Warning "Nutzung nicht gemeldet: $($_.Exception.Message)" } +Write-Host 'Fertig.' diff --git a/server/src/hugin_app/static/js/framework.min.js b/server/src/hugin_app/static/js/framework.min.js new file mode 100644 index 0000000..aa15792 --- /dev/null +++ b/server/src/hugin_app/static/js/framework.min.js @@ -0,0 +1,133 @@ +function toggleSidebar(){document.body.classList.add('sidebar-open');} +function closeSidebar(){document.body.classList.remove('sidebar-open');} +function openModal(id){ + document.getElementById(id).classList.add('open'); + document.body.classList.add('modal-open'); +} +function closeModal(id){ + document.getElementById(id).classList.remove('open'); + if(!document.querySelector('.modal-backdrop.open'))document.body.classList.remove('modal-open'); +} +function closeOnBackdrop(e,id){ + if(e.target===e.currentTarget)closeModal(id); +} +document.addEventListener('keydown',e=>{ + if(e.key==='Escape'){ + document.querySelectorAll('.modal-backdrop.open').forEach(m=>m.classList.remove('open')); + document.body.classList.remove('modal-open'); +} +}); +function switchTab(btn,panelId){ + const tabList=btn.closest('.tab-list'); + tabList.querySelectorAll('button').forEach(b=>b.classList.remove('active')); + btn.classList.add('active'); + const tabs=btn.closest('.tabs'); + tabs.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); + document.getElementById(panelId).classList.add('active'); +} +function toggleNavDd(btn){ + const li=btn.closest('li'); + const isOpen=li.classList.contains('open'); + document.querySelectorAll('.topnav-links li.open').forEach(l=>l.classList.remove('open')); + if(!isOpen)li.classList.add('open'); +} +function toggleDropdown(id){ + const dd=document.getElementById(id); + const isOpen=dd.classList.contains('open'); + document.querySelectorAll('.dropdown.open').forEach(d=>d.classList.remove('open')); + if(!isOpen)dd.classList.add('open'); +} +function dismissAlert(btn){ + const alert=btn.closest('.alert'); + alert.classList.add('is-dismissing'); + alert.addEventListener('transitionend',()=>alert.remove(),{once:true}); +} +function sortTable(th){ + const table=th.closest('table'); + const col=[...th.parentElement.children].indexOf(th); + const dir=th.dataset.sort==='asc'?'desc':'asc'; + table.querySelectorAll('th[data-sort]').forEach(t=>{ + if(t!==th)t.dataset.sort=''; +}); + th.dataset.sort=dir; + const tbody=table.querySelector('tbody'); +[...tbody.querySelectorAll('tr')] + .sort((a,b)=>{ + const aVal=a.cells[col].textContent.trim(); + const bVal=b.cells[col].textContent.trim(); + const aNum=parseFloat(aVal.replace(/[^0-9.-]/g,'')); + const bNum=parseFloat(bVal.replace(/[^0-9.-]/g,'')); + if(!isNaN(aNum)&&!isNaN(bNum)) + return dir==='asc'?aNum-bNum:bNum-aNum; + return dir==='asc' +?aVal.localeCompare(bVal,'de') +:bVal.localeCompare(aVal,'de'); +}) + .forEach(r=>tbody.appendChild(r)); +} +function showToast(message,type='info'){ + const icons={success:'✓',warning:'⚠',danger:'✕',info:'ℹ'}; + if(!(type in icons))type='info'; + const id='toast-'+Date.now()+'-'+Math.random().toString(36).slice(2,7); + const toast=document.createElement('div'); + toast.className='toast'; + toast.id=id; + toast.setAttribute('role','status'); + const icon=document.createElement('span'); + icon.className='toast-icon toast-icon-'+type; + icon.textContent=icons[type]; + const content=document.createElement('div'); + content.className='toast-content'; + const title=document.createElement('p'); + title.className='toast-title'; + title.textContent=message; + content.appendChild(title); + const close=document.createElement('button'); + close.className='toast-close'; + close.type='button'; + close.setAttribute('aria-label','Schließen'); + close.dataset.action='toast-close'; + close.textContent='×'; + toast.append(icon,content,close); + document.getElementById('toast-container').appendChild(toast); + setTimeout(()=>removeToast(id),4000); +} +function removeToast(id){ + const el=document.getElementById(id); + if(el){ + el.classList.add('is-leaving'); + setTimeout(()=>el.remove(),200); +} +} +function applyProgress(root){ +(root||document).querySelectorAll('[data-progress]').forEach(el=>{ + const wert=Math.max(0,Math.min(100,parseFloat(el.dataset.progress)||0)); + el.style.width=wert+'%'; +}); +} +document.addEventListener('DOMContentLoaded',()=>applyProgress()); +document.addEventListener('htmx:load',e=>applyProgress(e.target)); +const frameworkActions={ + 'sidebar-open':()=>toggleSidebar(), + 'sidebar-close':()=>closeSidebar(), + 'modal-open':el=>openModal(el.dataset.target), + 'modal-close':el=>closeModal(el.dataset.target||el.closest('.modal-backdrop').id), + 'modal-backdrop':(el,e)=>{if(e.target===el)closeModal(el.id);}, + 'tab':el=>switchTab(el,el.dataset.target), + 'nav-dropdown':el=>toggleNavDd(el), + 'dropdown':el=>toggleDropdown(el.dataset.target||el.closest('.dropdown').id), + 'alert-dismiss':el=>dismissAlert(el), + 'sort':el=>sortTable(el), + 'toggle-class':el=>el.classList.toggle(el.dataset.class), + 'toast':el=>showToast(el.dataset.message,el.dataset.type), + 'toast-close':el=>removeToast(el.closest('.toast').id), +}; +document.addEventListener('click',e=>{ + const el=e.target.closest('[data-action]'); + const action=el&&frameworkActions[el.dataset.action]; + if(action)action(el,e); + if(!e.target.closest('.topnav-links li')) + document.querySelectorAll('.topnav-links li.open').forEach(l=>l.classList.remove('open')); + if(!e.target.closest('.dropdown')) + document.querySelectorAll('.dropdown.open').forEach(d=>d.classList.remove('open')); +}); \ No newline at end of file diff --git a/server/src/hugin_app/static/js/htmx.min.js b/server/src/hugin_app/static/js/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/server/src/hugin_app/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/server/src/hugin_app/static/js/hugin.js b/server/src/hugin_app/static/js/hugin.js new file mode 100644 index 0000000..747ebd7 --- /dev/null +++ b/server/src/hugin_app/static/js/hugin.js @@ -0,0 +1,112 @@ +/* Was nur Hugin braucht: Kopieren und Rückfrage vor folgenreichen Aktionen. + Eigene Attribute statt data-action, damit es neben framework.min.js läuft (CSP: kein Inline-Skript). */ + +// + Hugin + + + +
+ {% if message %} +
+

{{ message }}

+ +
+ {% endif %} + {% block content %}{% endblock %} +
+ + +{% else %} +
+ {% block einzeln %}{% endblock %} +
+{% endif %} +
+ + diff --git a/server/src/hugin_app/templates/catalog.html b/server/src/hugin_app/templates/catalog.html new file mode 100644 index 0000000..d23a025 --- /dev/null +++ b/server/src/hugin_app/templates/catalog.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Katalog{% endblock %} +{% block content %} +
+

Katalog

+

Freigegebene Pakete: öffentliche und die deines Tenants. So kommen sie in dein Repo · Was die Arten bedeuten

+
+
+ + + +
+
{% include "_catalog_list.html" %}
+{% endblock %} diff --git a/server/src/hugin_app/templates/download.html b/server/src/hugin_app/templates/download.html new file mode 100644 index 0000000..53570d1 --- /dev/null +++ b/server/src/hugin_app/templates/download.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Programm{% endblock %} +{% block content %} +
+

Programm hugin

+

Das Kommandozeilenprogramm für Teams und Autoren: sync, check, search, validate, upload …

+
+{% if programs %} +
+

Version {{ version }}

+
+ + {% for p in programs %} + + + + {% endfor %} +
SystemArchitekturDateiSHA-256
{{ p.system }}{{ p.architecture }}{{ p.file }} {{ (p.size / 1048576) | round(1) }} MB
{{ p.sha256 or '–' }} + {% if p.sha256 %}{% endif %}
+
+
+

Nach dem Herunterladen unter macOS und Linux ausführbar machen und in den Pfad legen:

+
chmod +x hugin-darwin-arm64 && mv hugin-darwin-arm64 /usr/local/bin/hugin
+hugin --version
+hugin init --server {{ base }} --project <projekt-slug>
+

hugin check meldet, wenn ein neueres oder nötiges Update vorliegt. Alle Befehle

+
+{% else %} +
+

Noch keine Programme hinterlegt

+

Bitte beim Plattform-Admin melden.

+
+{% endif %} +{% endblock %} diff --git a/server/src/hugin_app/templates/editor.html b/server/src/hugin_app/templates/editor.html new file mode 100644 index 0000000..c599159 --- /dev/null +++ b/server/src/hugin_app/templates/editor.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Editor{% endblock %} +{% block content %} +
+

{{ title }}

+ Hilfe: Quellformat · Linter-Regeln +

Speichern legt einen Entwurf an (oder ersetzt ihn). Danach auf der Versionsseite prüfen und einreichen.

+
+{% if error %}

{{ error }}

{% endif %} +{% if findings %} +
+

Der Linter hat den Entwurf abgelehnt

+
    {% for b in findings %}
  • {{ b.code }} {{ b.text }}{% if b.file %} ({{ b.file }}){% endif %}
  • {% endfor %}
+
+{% endif %} +
+ + +

Tab rückt ein; Esc und dann Tab springt zum nächsten Feld.

+
+

hugin.yaml

+
+
+ {% for path, text in texts.items() %} +
+
+

{{ path }}

+ +
+
+
+ {% endfor %} + {% if binary %} +

Unverändert übernommen (Binärdateien): {% for path in binary %}{{ path }} {% endfor %}

+ {% endif %} +
+ Datei hinzufügen +
+
+
+
+
+
+
+
+ + {% if base %}Abbrechen{% endif %} +
+
+{% endblock %} diff --git a/server/src/hugin_app/templates/error.html b/server/src/hugin_app/templates/error.html new file mode 100644 index 0000000..28bddc8 --- /dev/null +++ b/server/src/hugin_app/templates/error.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Fehler{% endblock %} +{% block content %} +
+
+

{% if status_code == 404 %}Nicht gefunden{% else %}Das hat nicht geklappt{% endif %}

+ {% if error.findings %} +
    {% for b in error.findings %}
  • {{ b.code }} {{ b.text }}
  • {% endfor %}
+ {% else %} +

{{ error.text }}

+ {% endif %} +
+
+

Zum Katalog

+{% endblock %} diff --git a/server/src/hugin_app/templates/help.html b/server/src/hugin_app/templates/help.html new file mode 100644 index 0000000..062fe49 --- /dev/null +++ b/server/src/hugin_app/templates/help.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}{{ topic.title if topic else 'Hilfe' }}{% endblock %} +{% macro help_content() %} +
+ +
+ {% if q %} +

Suche: {{ q }}

+ {% if matches %} +
+ {% for t in matches %} + +
+

{{ t.topic.title }}

+

{{ t.excerpt }}

+
+
+ {% endfor %} +
+ {% else %} +

Nichts gefunden

Anderes Wort versuchen, z. B. „sync“, „Key“ oder „Freigabe“.

+ {% endif %} + {% else %} + {{ content | safe }} + {% endif %} +
+
+{% endmacro %} +{% block content %}{{ help_content() }}{% endblock %} +{% block einzeln %} +

Anmelden · Hugin-Hilfe

+{{ help_content() }} +{% endblock %} diff --git a/server/src/hugin_app/templates/login.html b/server/src/hugin_app/templates/login.html new file mode 100644 index 0000000..21d2380 --- /dev/null +++ b/server/src/hugin_app/templates/login.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Anmelden{% endblock %} +{% block einzeln %} +
+

Hugin

+

Register für Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen.

+
+
+
+ +
+ + +

Projekt-Key, persönlicher Key, Tenant-Key oder Admin-Token. Noch keinen? Hilfe lesen

+ {% if error %}

{{ error }}

{% endif %} +
+ +
+
+{% endblock %} diff --git a/server/src/hugin_app/templates/manage.html b/server/src/hugin_app/templates/manage.html new file mode 100644 index 0000000..1010220 --- /dev/null +++ b/server/src/hugin_app/templates/manage.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} +{% block title %}Verwaltung{% endblock %} +{% block content %} +
+

Verwaltung

+ Hilfe: Tenants, Projekte, Keys, Personen +

{% if a.access.is_platform %}Tenants, Projekte und Keys aller Tenants.{% else %}Projekte und Keys deines Tenants.{% endif %}

+
+ +{% if new_key %} + +{% endif %} + +{% if a.access.is_platform %} +
+

Tenants

+
+
+ {% for x in tenants %}{{ x.slug }}{% if x.locked_since %} (gesperrt){% endif %}{% endfor %} +
+ {% if t %} +
+ + {% if t.locked_since %} + {{ t.slug }} gesperrt seit {{ t.locked_since.strftime('%d.%m.%Y') }} + + {% else %} + + {% endif %} +
+ {% endif %} +
+ + + + +
+
+
+{% endif %} + +{% if t %} +
+
+

Projekte in {{ t.slug }}

+
+ {% if projects %} +
    {% for p in projects %}
  • {{ p.slug }} {{ p.name }}
  • {% endfor %}
+ {% else %}

Noch keine Projekte.

{% endif %} +
+ + + + +
+
+
+ +
+

Neuer Key

+
+
+ +
+
+ + +
+
+ + +
+
+
+ + +

Nichts ausgewählt: alle Projekte des Tenants.

+
+
+ + +
+
+ Rollen (lesen haben alle) +
+ {% for role in roles %} + {% if a.access.is_platform or role not in ('pub_author', 'pub_reviewer', 'tenant_admin') %} + + {% endif %} + {% endfor %} +
+
+ +
+
+
+
+ +
+

Pflichtpakete in {{ t.slug }}

+
+

Pflichtpakete kommen bei jedem hugin sync in jedes Repo des Tenants, auch ohne Eintrag in .hugin.yaml, und lassen sich nicht abbestellen. Für wenige Dinge, die überall gelten müssen. Hilfe

+ {% if mandatory %} +
+ + {% for x in mandatory %} + + + + + + + {% endfor %} +
PaketGilt fürSeit
{{ x.package }}{{ 'alle Tenants' if x.for_all else t.slug }}{{ x.set_at.strftime('%d.%m.%Y') }}{% if a.access.is_platform or not x.for_all %} +
+ + + +
{% endif %}
+ {% else %}

Keine Pflichtpakete.

{% endif %} + {% if candidates %} +
+ + + {% if a.access.is_platform %} + + {% endif %} + +
+ {% endif %} +
+
+ +
+

Personen in {{ t.slug }}

+
+

Autoren und Prüfer als Personen mit persönlichem Key (hp_…, 180 Tage). Nur so sind vier Augen nachweisbar; Pakete mit Skripten oder Rechten gehen nur damit.

+ {% if people %} +
+ + {% for x in people %} + {% set aktiv = x.keys | selectattr('revoked_since', 'none') | list %} + + + + + + + {% endfor %} +
NameMailProjektRollenKey läuft ab
{{ x.person.name }}{{ x.person.mail }}{{ x.project or '–' }}{{ x.roles | map('label') | join(', ') or '–' }}{{ aktiv[-1].expires_at.strftime('%d.%m.%Y') if aktiv else '–' }}{% if x.person.locked_since %}gesperrt{% else %} +
+
+
+
+
+
{% endif %}
+ {% endif %} +
+ Person anlegen +
+ +
+
+
+
+
+
+
+ {% for role in roles %} + {% if a.access.is_platform or role not in ('pub_author', 'pub_reviewer', 'tenant_admin') %} + + {% endif %} + {% endfor %} +
+ +
+
+
+
+ +
+

Keys in {{ t.slug }}

+
+ + + + {% for k in keys %} + + + + + + + + + + {% endfor %} + +
NameArtProjektRollenPräfixZuletzt genutzt
{{ k.name }}{{ k.kind | label }}{% if k.kind == 'integration' %}{% for pid in k.projects %}{{ project_names.get(pid, '?') }}{% if not loop.last %}, {% endif %}{% else %}alle{% endfor %}{% else %}{{ project_names.get(k.project_id, '–') }}{% endif %}{{ k.roles | map('label') | join(', ') or '–' }}{{ k.prefix }}…{{ k.last_used | when if k.last_used else 'nie' }} + {% if k.revoked_since %}widerrufen + {% elif a.access.is_platform or k.kind != 'tenant' %} +
+ + +
+ {% endif %} +
+
+
+{% endif %} +{% endblock %} diff --git a/server/src/hugin_app/templates/new.html b/server/src/hugin_app/templates/new.html new file mode 100644 index 0000000..4f9772d --- /dev/null +++ b/server/src/hugin_app/templates/new.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Neues Paket{% endblock %} +{% block content %} +
+

Neues Paket

+ Hilfe: Welche Art passt? · Quellformat +

Legt ein Gerüst im Editor an. Gespeichert wird erst als Entwurf, nach Prüfung durch den Linter.

+
+{% if error %}

{{ error }}

{% endif %} +
+
+
+
+
+
+ {% set prefix = 'pub-' if a.access.has('pub_author') else (a.access.project_slug ~ '-' if a.access.project_slug else '') %} + +

Präfix der Geltung: pub- öffentlich, sonst Tenant- oder Projekt-Slug.

+
+ +
+
+{% endblock %} diff --git a/server/src/hugin_app/templates/overview.html b/server/src/hugin_app/templates/overview.html new file mode 100644 index 0000000..30cac76 --- /dev/null +++ b/server/src/hugin_app/templates/overview.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Überblick{% endblock %} +{% block content %} +
+

Überblick

+

Was zu tun ist{% if not a.access.is_platform %} in deinem Tenant{% endif %}.

+
+ + + +{% if u.deprecated %} +
+

Repos mit veralteten Versionen

+
+ + {% for v in u.deprecated %} + + + {% endfor %} +
RepoPaketInstalliertNeueste
{{ v.repo }}{{ v.package }}{{ v.version }}{{ v.newest }}
+
+{% endif %} + +{% if u.without_usage %} +
+

Eigene Pakete ohne Nutzung

+
{% for p in u.without_usage %}{{ p }}{% endfor %}
+
+{% endif %} + +{% if u.keys_expiring %} +
+
+

Keys laufen in den nächsten 30 Tagen ab

+
    {% for k in u.keys_expiring %}
  • {{ k.name }} ({{ k.prefix }}…) am {{ k.expires_at.strftime('%d.%m.%Y') }}
  • {% endfor %}
+
+
+{% endif %} + +{% if not u.deprecated and not u.without_usage and not u.keys_expiring and not u.open_reviews %} +

Nichts zu tun

Alle Repos sind aktuell.

+{% endif %} +{% endblock %} diff --git a/server/src/hugin_app/templates/package.html b/server/src/hugin_app/templates/package.html new file mode 100644 index 0000000..afef5dc --- /dev/null +++ b/server/src/hugin_app/templates/package.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% block title %}{{ package.id }}{% endblock %} +{% block content %} + + +
+

{{ package.id }}

+
+ {{ KIND_NAMES.get(package.kind, package.kind) }} + {{ package.scope | label }} + {% if package.deprecated_since %}veraltet{% if package.successor %} → {{ package.successor }}{% endif %}{% endif %} + {% if is_mandatory %}Pflicht{% endif %} +
+ {% if newest %}

{{ newest.manifest.description | trim }}

{% endif %} +
+ +{% if newest and not is_mandatory and not a.access.is_platform %} +
+
+

Im Repo nutzen

+ +
+
+

In .hugin.yaml eintragen, dann hugin sync. Mit @test am Ende kommen auch Versionen aus dem Testkanal.

+
packages:
+  - {{ package.id }}
+
+
+{% endif %} + +
+
+

Versionen

+
+ + + + {% for v in versions %} + + + + + + + {% endfor %} + +
VersionStatusKanalRisiko
{{ v.version }}{{ STATUS_NAMES.get(v.status, v.status) }}{{ v.channel | label }}{{ v.risk | label }}
+
+
+
+

Nutzung

+
+ {% if usage %} +
+ {% for number, anzahl in usage.items() %} +
Version {{ number }}
{{ anzahl }} Repo{{ 's' if anzahl != 1 }}
+ {% endfor %} +
+

Laut letzter Meldung von hugin sync{% if not a.access.is_platform %}, nur dein Tenant{% endif %}.

+ {% else %} +

Noch keine Meldung von hugin sync.

+ {% endif %} +
+
+
+ +{% if can_maintain %} +
+ {% if package.deprecated_since %}Veraltet seit {{ package.deprecated_since.strftime('%d.%m.%Y') }}{% else %}Paket veralten{% endif %} +
+
+ + {% if package.deprecated_since %} +

hugin sync weist Abonnenten darauf hin{% if package.successor %} und nennt {{ package.successor }} als Nachfolger{% endif %}.

+ + {% else %} +

Installierte Versionen bleiben abrufbar; hugin sync meldet das Paket als veraltet.

+
+
+ + {% endif %} +
+
+
+{% endif %} + +
+

Rückmeldungen

+
+ {% if feedback_list %} +
+ {% for r in feedback_list %} +
+
+
+ {{ r.kind | label }} + {{ r.version }} · {{ 'Agent' if r.source == 'agent' else 'Mensch' }}{% if r.tool %} ({{ r.tool }}){% endif %} · {{ r.time | when }}{% if r.origin %} · {{ r.origin }}{% endif %} + {% if r.status == 'resolved' %}erledigt{% endif %} +
+

{{ r.text }}

+
+ {% if is_owner and r.status != 'resolved' %} +
+ + +
+ {% endif %} +
+ {% endfor %} +
+ {% else %}

Noch keine Rückmeldungen.

{% endif %} + + {% if newest and not a.access.is_platform %} +
+ Rückmeldung geben +
+ +
+
+
+
+
+
+
+
+ +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/server/src/hugin_app/templates/project.html b/server/src/hugin_app/templates/project.html new file mode 100644 index 0000000..90c3029 --- /dev/null +++ b/server/src/hugin_app/templates/project.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Mein Projekt{% endblock %} +{% block content %} +
+

Mein Projekt

+

Was die Repos dieses Projekts laut letzter Meldung von hugin sync installiert haben.

+
+{% if total %} + +{% endif %} +{% if lines %} +
+ + + + {% for n in lines %} + {% set current = newest.get(n.package_id) %} + {% set new_repo = loop.changed(n.repo) %} + + + + + + + + {% endfor %} + +
RepoPaketVersionWerkzeugeGemeldet
{% if new_repo %}{{ n.repo }}{% endif %}{{ n.package_id }}{{ n.version }} + {% if current and current != n.version %}neu: {{ current }}{% endif %}{% for w, ver in n.tools.items() %}{{ w }} {{ ver }}{% if not loop.last %}, {% endif %}{% endfor %}{{ n.last | when }}
+
+{% elif outdated %} +

Alles aktuell

Kein Repo hat eine ältere Version als die neueste freigegebene.

+{% else %} +

Noch keine Meldung

Sobald ein Repo hugin sync ausführt, erscheint es hier.

+{% endif %} +{% endblock %} diff --git a/server/src/hugin_app/templates/reviews.html b/server/src/hugin_app/templates/reviews.html new file mode 100644 index 0000000..544bdad --- /dev/null +++ b/server/src/hugin_app/templates/reviews.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Prüfungen{% endblock %} +{% block content %} +
+

Prüfungen

+ Hilfe: Was prüfen? +

Eingereichte Versionen, die du freigeben oder zurückgeben kannst. Eigene Versionen gibt eine zweite Person frei.

+
+{% if versions %} + +{% set target, params, htmx = 'reviews', '', '' %} +{% include "_pagination.html" %} +{% else %} +

Nichts zu prüfen

Alle eingereichten Versionen sind bearbeitet.

+{% endif %} +{% endblock %} diff --git a/server/src/hugin_app/templates/system.html b/server/src/hugin_app/templates/system.html new file mode 100644 index 0000000..82f66dc --- /dev/null +++ b/server/src/hugin_app/templates/system.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}System{% endblock %} +{% block content %} +
+

System

+

Einstellungen der Plattform. Änderungen gelten ab dem nächsten Bundle und stehen im Audit.

+
+ +
+
+

Untergrenzen der Werkzeuge

+
+
+ +
+
+
+
+
+
+

hugin sync warnt bei älteren Versionen; mit werkzeugpruefung: streng bricht es ab.

+ +
+
+
+ +
+

Werkzeugversionen in den Repos

+
+ {% if tools %} +
+ {% for name, versions in tools.items() %} +
{{ 'Claude Code' if name == 'claude' else 'OpenCode' }}
+
{% for v, n in versions.items() | sort %} + {{ v }} × {{ n }} + {% endfor %}
+ {% endfor %} +
+ {% else %}

Noch keine Meldung von hugin sync.

{% endif %} +
+
+
+ +
+

Export

+
+

Alle freigegebenen Pakete im Quellformat, mit INDEX.md und PACKAGE.md je Paket.

+

Export als ZIP herunterladen

+
+
+ +
+

Modellzuordnung

+
+
+ +
+ + {% for cap in ('fast', 'standard', 'thorough') %} + + + + {% endfor %} +
KlasseClaude CodeOpenCode
{{ cap }}
+

Neue Modellgeneration: hier umstellen, Teams rufen hugin sync auf. An den Paketen ändert sich nichts (→ Konzept 7.4).

+ +
+
+
+{% endblock %} diff --git a/server/src/hugin_app/templates/upload.html b/server/src/hugin_app/templates/upload.html new file mode 100644 index 0000000..97dda55 --- /dev/null +++ b/server/src/hugin_app/templates/upload.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Hochladen{% endblock %} +{% block content %} +
+

Entwurf hochladen

+ Hilfe: Pakete schreiben · Quellformat +

Paketordner als ZIP: hugin.yaml und die Inhaltsdatei, z. B. SKILL.md. Bequemer lokal mit hugin validate und hugin upload — oder direkt im Browser anlegen.

+
+{% if error %} +

{{ error }}

+{% endif %} +{% if findings %} +
+
+

Der Linter hat das Paket abgelehnt

+
    {% for b in findings %}
  • {{ b.code }} {{ b.text }}{% if b.file %} ({{ b.file }}){% endif %}
  • {% endfor %}
+
+
+{% endif %} +
+
+ +
+ + +

Ein Entwurf derselben Version wird ersetzt. Eingereichte Versionen sind unveränderlich.

+
+ +
+
+{% endblock %} diff --git a/server/src/hugin_app/templates/version.html b/server/src/hugin_app/templates/version.html new file mode 100644 index 0000000..9772890 --- /dev/null +++ b/server/src/hugin_app/templates/version.html @@ -0,0 +1,189 @@ +{% extends "base.html" %} +{% block title %}{{ package.id }} {{ v.version }}{% endblock %} +{% block content %} + + +
+
+

{{ package.id }} {{ v.version }}

+
+ {{ STATUS_NAMES.get(v.status, v.status) }} + Risiko: {{ v.risk | label }} + {{ v.compatibility | label }} + {% if v.status == 'approved' %}Kanal {{ v.channel | label }}{% endif %} + {% if v.status == 'in_review' %}Freigaben {{ review_round }} von {{ needed }}{% endif %} +
+
+
+ {% if can_edit %} + {{ 'Bearbeiten' if v.status == 'draft' else 'Neue Version auf dieser Basis' }} + {% endif %} + {% if can_submit %} +
+ + +
+ {% endif %} + {% if can_decide %} + + {% endif %} + {% if can_maintain and v.status in ('approved', 'deprecated') %} +
+ + {% if v.status == 'approved' %} + + {% else %} + + {% endif %} +
+ {% endif %} + {% if can_promote %} +
+ + +
+ {% endif %} +
+
+ +
+
+
+
Beschreibung
{{ core.manifest.description_text }}
+ {% if core.manifest.changes %}
Änderung
{{ core.manifest.changes | trim }}
{% endif %} +
Ziele
{{ core.manifest.targets | join(', ') }}
+ {% set local = core.manifest.server.local if core.manifest.server else none %} + {% if local %} +
Lokaler Server
+ {{ local.runtime | capitalize }}{% if local.python %} ≥ {{ local.python }}{% endif %}, Start: {{ local.entry }}{% if local.env %}, Umgebung: {{ local.env | join(', ') }}{% endif %}. + Läuft auf jedem Rechner mit den Rechten der Person; installiert nach ~/.local/share/mcp/{{ package.id }}/{{ v.version }}/{% if 'requirements.txt' in core.files %}, Abhängigkeiten aus requirements.txt über pip{% endif %}.
+ {% endif %} + {% if v.min_version_claude or v.min_version_opencode %} +
Mindestversion
+ {% if v.min_version_claude %}Claude Code ≥ {{ v.min_version_claude }} {% endif %} + {% if v.min_version_opencode %}OpenCode ≥ {{ v.min_version_opencode }}{% endif %}
+ {% endif %} +
Prüfsumme
{{ v.checksum }}
+
+
+
+

Prüfung

+
+ {% set hints = v.linter | selectattr('severity', 'equalto', 'hint') | list %} + {% if hints %} +
    {% for b in hints %}
  • {{ b.code }} {{ b.text }}{% if b.file %} ({{ b.file }}){% endif %}
  • {% endfor %}
+ {% else %} +

Linter ohne Hinweise.

+ {% endif %} + {% if approvals %} +
+ {% for f in approvals %} +
+
+
+

{{ 'Freigegeben' if f.decision == 'approve' else 'Zurückgegeben' }}

+

{{ f.time.strftime('%d.%m.%Y %H:%M') }} · {{ reviewer.get(f.reviewer_key, '') }}

+ {% if f.comment %}

{{ f.comment }}

{% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+
+
+ +
+
    +
  • +
  • + {% for tool in preview %} + {% set tab = tool | replace('@', '-') %} +
  • + {% endfor %} +
+ +
+ {% for path, text in content %} +
+ {{ path }} +
{{ text }}
+
+ {% endfor %} +
+ +
+ {% if not predecessor %}

Erste Version: alles ist neu.

{% endif %} + {% for d in differences %} + {% if d.kind != 'equal' %} +
+ {{ d.path }} {{ {'new': 'neu', 'removed': 'entfernt', 'changed': 'geändert'}.get(d.kind, d.kind) }} +
{% for z in d.lines %}{{ z }}
+{% endfor %}
+
+ {% endif %} + {% endfor %} + {% set equal = differences | selectattr('kind', 'equalto', 'equal') | list %} + {% if predecessor and equal %}

Unverändert: {{ equal | map(attribute='path') | join(', ') }}

{% endif %} +
+ + {% for tool, entries in preview.items() %} +
+

So kommt diese Version im Repo an ({{ tool }}).

+ {% for e in entries %} +
+ {{ e.title }} +
{{ e.content }}
+
+ {% endfor %} +
+ {% endfor %} +
+ +{% if can_decide %} + +{% endif %} +{% endblock %} diff --git a/server/src/hugin_app/web/__init__.py b/server/src/hugin_app/web/__init__.py new file mode 100644 index 0000000..643ba44 --- /dev/null +++ b/server/src/hugin_app/web/__init__.py @@ -0,0 +1,665 @@ +"""Weboberfläche: Jinja2 + HTMX + CSS-Framework (→ KONZEPT.md 13). + +Alle Pfade in Templates über url_for, damit Hugin unter einem Präfix in einem +anderen Projekt laufen kann (→ 14.3). Kein Inline-Code: Die Seiten laufen +unter der strengen CSP aus 16.4. +""" + +import hmac +import re +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Annotated, Any +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, status +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates +from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape +from markupsafe import Markup, escape +from sqlalchemy.orm import Session + +from hugin_app import services, export, docs, platform, download, management +from hugin_app.auth import ROLES, Access +from hugin_app.hugin import Hugin, NoWebAccess +from hugin_app.store import packages as store +from hugin_app.store.models import Key, PackageRow, Tenant, WebSession +from hugin_core import scaffolds +from hugin_core.package import Kind + +COOKIE = "hugin_session" +PER_PAGE = 25 # Einträge je Seite in Katalog und Prüfungen +MESSAGE = "hugin_message" +TEMPLATES = Path(__file__).parent.parent / "templates" +KIND_NAMES = {"skill": "Skill", "rules": "Regeln", "command": "Befehl", "subagent": "Subagent", + "mcp": "MCP", "plugin": "Plugin", "hook": "Hook"} +STATUS_BADGE = {"draft": "default", "in_review": "warning", "approved": "success", "deprecated": "outline"} +STATUS_NAMES = {"draft": "Entwurf", "in_review": "in Prüfung", "approved": "freigegeben", "deprecated": "veraltet"} +RISK_BADGE = {"text": "default", "tools": "info", "scripts": "warning", "permissions": "danger"} +# Deutsche Beschriftungen für Werte, die die Oberfläche anzeigt (Filter label) +LABELS = { + # Risiko + "text": "Text", "tools": "Werkzeuge", "scripts": "Skripte", "permissions": "Rechte", + # Kompatibilität + "portable": "portabel", "translated": "übersetzt", "claude-only": "nur Claude Code", "opencode-only": "nur OpenCode", + # Kanal + "stable": "stabil", "test": "test", + # Geltung und Key-Arten + "public": "öffentlich", "tenant": "Tenant", "project": "Projekt", "integration": "Integration", "person": "Person", + # Rückmeldungen + "unclear": "unklar", "wrong": "falsch", "deprecated": "veraltet", "suggestion": "Vorschlag", + "open": "offen", "resolved": "erledigt", "agent": "Agent", "human": "Mensch", + # Rollen + "read": "lesen", "author": "Autor", "reviewer": "Prüfer", "pub_author": "Autor (öffentlich)", + "pub_reviewer": "Prüfer (öffentlich)", "tenant_admin": "Tenant-Admin", "delivery": "Auslieferung", +} +# Was eine Rolle darf, als Hinweis in den Formularen (→ auth/access.py) +ROLE_HINTS = { + "author": "Pakete des eigenen Projekts hochladen und einreichen", + "reviewer": "Pakete des eigenen Tenants prüfen und freigeben", + "pub_author": "zusätzlich öffentliche Pakete (pub-…) hochladen", + "pub_reviewer": "zusätzlich öffentliche Pakete prüfen und freigeben", + "tenant_admin": "Projekte, Keys, Personen und Pflichtpakete im Tenant verwalten", +} + + +def relative(t: datetime, now: datetime | None = None) -> str: + """Vergangene Zeit grob in Worten; nach 30 Tagen das Datum.""" + t = t if t.tzinfo else t.replace(tzinfo=UTC) + seconds = ((now or datetime.now(UTC)) - t).total_seconds() + if seconds < 60: + return "gerade eben" + if seconds < 3600: + return f"vor {int(seconds // 60)} Min." + if seconds < 86400: + return f"vor {int(seconds // 3600)} Std." + days = int(seconds // 86400) + if days == 1: + return "gestern" + return f"vor {days} Tagen" if days < 30 else t.strftime("%d.%m.%Y") + + +def when(t: datetime) -> Markup: + """