mirror of
https://github.com/Monadical-SAS/cubbi.git
synced 2025-12-21 04:39:07 +00:00
Compare commits
47 Commits
v0.4.0
...
doc-mcp-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36269d6356 | ||
| 7421500eb0 | |||
| 5b1afa05da | |||
| c869c1117f | |||
| 488715c88b | |||
| 623edeac76 | |||
| 638e2b2a93 | |||
| 5987585b2d | |||
| 1201eb2d3d | |||
| cf31c7c25d | |||
| a7630ace07 | |||
| 150a6fe39c | |||
| c29af828f0 | |||
| cbf2fadc4d | |||
| dd5b9ec213 | |||
| f8b8639bb0 | |||
| 21101320a1 | |||
| 83f6ab9a73 | |||
| 4423f750d5 | |||
| 4b7ae39bba | |||
| dd321d4773 | |||
| 909ba03a7a | |||
| a50336487a | |||
| 0e2e0acada | |||
| b215080052 | |||
| 36dbb83df6 | |||
| 30e34cb544 | |||
| c870eed844 | |||
| 2f84bc1e27 | |||
| 65b0fcfd10 | |||
| 924166d643 | |||
| b120bbaf98 | |||
| a4f1c882c8 | |||
| 429aee2784 | |||
| 0611dc930a | |||
| ac2d310ac7 | |||
| 96a44ef567 | |||
| 2c9d14385a | |||
| 476529c5ef | |||
| 179e4fc939 | |||
| 7e2f807c47 | |||
| 00cee60fe6 | |||
| c56b4b35f5 | |||
| 167d73a964 | |||
| 63734c59ab | |||
| 5218784905 | |||
| dd053dee80 |
@@ -1,21 +0,0 @@
|
|||||||
name: "Lint PR"
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
main:
|
|
||||||
name: Validate PR title
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
14
.github/workflows/pre_commit.yml
vendored
14
.github/workflows/pre_commit.yml
vendored
@@ -1,14 +0,0 @@
|
|||||||
name: pre-commit
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pre-commit:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-python@v3
|
|
||||||
- uses: pre-commit/action@v3.0.1
|
|
||||||
41
.github/workflows/pytests.yml
vendored
41
.github/workflows/pytests.yml
vendored
@@ -1,41 +0,0 @@
|
|||||||
name: Pytests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
checks: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pytest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
|
||||||
python-version: ["3.12"]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
- name: Install all dependencies
|
|
||||||
run: uv sync --frozen --all-extras --all-groups
|
|
||||||
|
|
||||||
- name: Build required images
|
|
||||||
run: |
|
|
||||||
uv tool install --with-editable . .
|
|
||||||
cubbi image build goose
|
|
||||||
cubbi image build aider
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: |
|
|
||||||
uv run --frozen -m pytest -v
|
|
||||||
127
.github/workflows/release.yml
vendored
127
.github/workflows/release.yml
vendored
@@ -1,127 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
release_force:
|
|
||||||
# see https://python-semantic-release.readthedocs.io/en/latest/github-action.html#command-line-options
|
|
||||||
description: |
|
|
||||||
Force release be one of: [major | minor | patch]
|
|
||||||
Leave empty for auto-detect based on commit messages.
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- "" # auto - no force
|
|
||||||
- major # force major
|
|
||||||
- minor # force minor
|
|
||||||
- patch # force patch
|
|
||||||
default: ""
|
|
||||||
required: false
|
|
||||||
prerelease_token:
|
|
||||||
description: 'The "prerelease identifier" to use as a prefix for the "prerelease" part of a semver. Like the rc in `1.2.0-rc.8`.'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- rc
|
|
||||||
- beta
|
|
||||||
- alpha
|
|
||||||
default: rc
|
|
||||||
required: false
|
|
||||||
prerelease:
|
|
||||||
description: "Is a pre-release"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
required: false
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: false # prevent hickups with semantic-release
|
|
||||||
|
|
||||||
env:
|
|
||||||
PYTHON_VERSION_DEFAULT: "3.12"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
concurrency: release
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Note: we need to checkout the repository at the workflow sha in case during the workflow
|
|
||||||
# the branch was updated. To keep PSR working with the configured release branches,
|
|
||||||
# we force a checkout of the desired release branch but at the workflow sha HEAD.
|
|
||||||
- name: Setup | Checkout Repository at workflow sha
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.sha }}
|
|
||||||
ssh-key: ${{ secrets.DEPLOY_KEY }}
|
|
||||||
|
|
||||||
- name: Setup | Force correct release branch on workflow sha
|
|
||||||
run: |
|
|
||||||
git checkout -B ${{ github.ref_name }} ${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
python-version: ${{ env.PYTHON_VERSION_DEFAULT }}
|
|
||||||
|
|
||||||
- name: Install all dependencies
|
|
||||||
run: uv sync --frozen --all-extras --all-groups
|
|
||||||
|
|
||||||
# 2 steps to prevent uv.lock out of sync
|
|
||||||
# CF https://github.com/python-semantic-release/python-semantic-release/issues/1125
|
|
||||||
- name: Action | Semantic Version Release (stamp only)
|
|
||||||
uses: python-semantic-release/python-semantic-release@v9.21.1
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
git_committer_name: "github-actions"
|
|
||||||
git_committer_email: "actions@users.noreply.github.com"
|
|
||||||
force: ${{ github.event.inputs.release_force }}
|
|
||||||
prerelease: ${{ github.event.inputs.prerelease }}
|
|
||||||
prerelease_token: ${{ github.event.inputs.prerelease_token }}
|
|
||||||
ssh_public_signing_key: ${{ secrets.DEPLOY_KEY_PUB }}
|
|
||||||
ssh_private_signing_key: ${{ secrets.DEPLOY_KEY }}
|
|
||||||
push: false
|
|
||||||
commit: false
|
|
||||||
tag: false
|
|
||||||
changelog: false
|
|
||||||
|
|
||||||
- name: Push and tags
|
|
||||||
run: |
|
|
||||||
uv lock
|
|
||||||
git add uv.lock pyproject.toml
|
|
||||||
|
|
||||||
- name: Action | Semantic Version Release (fully to create release)
|
|
||||||
id: release
|
|
||||||
uses: python-semantic-release/python-semantic-release@v9.21.1
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
git_committer_name: "github-actions"
|
|
||||||
git_committer_email: "actions@users.noreply.github.com"
|
|
||||||
force: ${{ github.event.inputs.release_force }}
|
|
||||||
prerelease: ${{ github.event.inputs.prerelease }}
|
|
||||||
prerelease_token: ${{ github.event.inputs.prerelease_token }}
|
|
||||||
ssh_public_signing_key: ${{ secrets.DEPLOY_KEY_PUB }}
|
|
||||||
ssh_private_signing_key: ${{ secrets.DEPLOY_KEY }}
|
|
||||||
push: false
|
|
||||||
|
|
||||||
- name: Push and tags
|
|
||||||
run: |
|
|
||||||
git push --set-upstream --follow-tags origin ${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
run: uv build
|
|
||||||
|
|
||||||
- name: Publish | Upload package to PyPI
|
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
|
||||||
if: steps.release.outputs.released == 'true'
|
|
||||||
|
|
||||||
- name: Publish | Upload to GitHub Release Assets
|
|
||||||
uses: python-semantic-release/publish-action@v9.8.9
|
|
||||||
if: steps.release.outputs.released == 'true'
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
tag: ${{ steps.release.outputs.tag }}
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,4 +12,3 @@ wheels/
|
|||||||
# Aider
|
# Aider
|
||||||
.aider*
|
.aider*
|
||||||
.goose
|
.goose
|
||||||
.claude/settings.local.json
|
|
||||||
|
|||||||
430
CHANGELOG.md
430
CHANGELOG.md
@@ -1,430 +0,0 @@
|
|||||||
# CHANGELOG
|
|
||||||
|
|
||||||
|
|
||||||
## v0.4.0 (2025-08-06)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Update readme ([#25](https://github.com/Monadical-SAS/cubbi/pull/25),
|
|
||||||
[`9dc1158`](https://github.com/Monadical-SAS/cubbi/commit/9dc11582a21371a069d407390308340a87358a9f))
|
|
||||||
|
|
||||||
doc: update readme
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- Add user port support ([#26](https://github.com/Monadical-SAS/cubbi/pull/26),
|
|
||||||
[`75c9849`](https://github.com/Monadical-SAS/cubbi/commit/75c9849315aebb41ffbd5ac942c7eb3c4a151663))
|
|
||||||
|
|
||||||
* feat: add user port support
|
|
||||||
|
|
||||||
* fix: fix unit test and improve isolation
|
|
||||||
|
|
||||||
* refactor: remove some fixture
|
|
||||||
|
|
||||||
- Make opencode beautiful by default ([#24](https://github.com/Monadical-SAS/cubbi/pull/24),
|
|
||||||
[`b8ecad6`](https://github.com/Monadical-SAS/cubbi/commit/b8ecad6227f6a328517edfc442cd9bcf4d3361dc))
|
|
||||||
|
|
||||||
opencode: try having compatible default theme
|
|
||||||
|
|
||||||
- Support for crush ([#23](https://github.com/Monadical-SAS/cubbi/pull/23),
|
|
||||||
[`472f030`](https://github.com/Monadical-SAS/cubbi/commit/472f030924e58973dea0a41188950540550c125d))
|
|
||||||
|
|
||||||
|
|
||||||
## v0.3.0 (2025-07-31)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Claudecode and opencode arm64 images ([#21](https://github.com/Monadical-SAS/cubbi/pull/21),
|
|
||||||
[`dba7a7c`](https://github.com/Monadical-SAS/cubbi/commit/dba7a7c1efcc04570a92ecbc4eee39eb6353aaea))
|
|
||||||
|
|
||||||
- Update readme
|
|
||||||
([`4958b07`](https://github.com/Monadical-SAS/cubbi/commit/4958b07401550fb5a6751b99a257eda6c4558ea4))
|
|
||||||
|
|
||||||
### Continuous Integration
|
|
||||||
|
|
||||||
- Remove conventional commit, as only PR is required
|
|
||||||
([`afae8a1`](https://github.com/Monadical-SAS/cubbi/commit/afae8a13e1ea02801b2e5c9d5c84aa65a32d637c))
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- Add --mcp-type option for remote MCP servers
|
|
||||||
([`d41faf6`](https://github.com/Monadical-SAS/cubbi/commit/d41faf6b3072d4f8bdb2adc896125c7fd0d6117d))
|
|
||||||
|
|
||||||
Auto-detects connection type from URL (/sse -> sse, /mcp -> streamable_http) or allows manual
|
|
||||||
specification. Updates goose plugin to use actual MCP type instead of hardcoded sse.
|
|
||||||
|
|
||||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
|
||||||
|
|
||||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
||||||
|
|
||||||
- Add Claude Code image support ([#16](https://github.com/Monadical-SAS/cubbi/pull/16),
|
|
||||||
[`b28c2bd`](https://github.com/Monadical-SAS/cubbi/commit/b28c2bd63e324f875b2d862be9e0afa4a7a17ffc))
|
|
||||||
|
|
||||||
* feat: add Claude Code image support
|
|
||||||
|
|
||||||
Add a new Cubbi image for Claude Code (Anthropic's official CLI) with: - Full Claude Code CLI
|
|
||||||
functionality via NPM package - Secure API key management with multiple authentication options -
|
|
||||||
Enterprise support (Bedrock, Vertex AI, proxy configuration) - Persistent configuration and cache
|
|
||||||
directories - Comprehensive test suite and documentation
|
|
||||||
|
|
||||||
The image allows users to run Claude Code in containers with proper isolation, persistent settings,
|
|
||||||
and seamless Cubbi integration. It gracefully handles missing API keys to allow flexible
|
|
||||||
authentication.
|
|
||||||
|
|
||||||
Also adds optional Claude Code API keys to container.py for enterprise deployments.
|
|
||||||
|
|
||||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
|
||||||
|
|
||||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
||||||
|
|
||||||
* Pre-commit fixes
|
|
||||||
|
|
||||||
---------
|
|
||||||
|
|
||||||
Co-authored-by: Claude <noreply@anthropic.com>
|
|
||||||
|
|
||||||
Co-authored-by: Your Name <you@example.com>
|
|
||||||
|
|
||||||
- Add configuration override in session create with --config/-c
|
|
||||||
([`672b8a8`](https://github.com/Monadical-SAS/cubbi/commit/672b8a8e315598d98f40d269dfcfbde6203cbb57))
|
|
||||||
|
|
||||||
- Add MCP tracking to sessions ([#19](https://github.com/Monadical-SAS/cubbi/pull/19),
|
|
||||||
[`d750e64`](https://github.com/Monadical-SAS/cubbi/commit/d750e64608998f6f3a03928bba18428f576b412f))
|
|
||||||
|
|
||||||
Add mcps field to Session model to track active MCP servers and populate it from container labels in
|
|
||||||
ContainerManager. Enhance MCP remove command to warn when removing servers used by active
|
|
||||||
sessions.
|
|
||||||
|
|
||||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
|
||||||
|
|
||||||
Co-authored-by: Claude <noreply@anthropic.com>
|
|
||||||
|
|
||||||
- Add network filtering with domain restrictions
|
|
||||||
([#22](https://github.com/Monadical-SAS/cubbi/pull/22),
|
|
||||||
[`2eb15a3`](https://github.com/Monadical-SAS/cubbi/commit/2eb15a31f8bb97f93461bea5e567cc2ccde3f86c))
|
|
||||||
|
|
||||||
* fix: remove config override logging to prevent API key exposure
|
|
||||||
|
|
||||||
* feat: add network filtering with domain restrictions
|
|
||||||
|
|
||||||
- Add --domains flag to restrict container network access to specific domains/ports - Integrate
|
|
||||||
monadicalsas/network-filter container for network isolation - Support domain patterns like
|
|
||||||
'example.com:443', '*.api.com' - Add defaults.domains configuration option - Automatically handle
|
|
||||||
network-filter container lifecycle - Prevent conflicts between --domains and --network options
|
|
||||||
|
|
||||||
* docs: add --domains option to README usage examples
|
|
||||||
|
|
||||||
* docs: remove wildcard domain example from --domains help
|
|
||||||
|
|
||||||
Wildcard domains are not currently supported by network-filter
|
|
||||||
|
|
||||||
- Add ripgrep and openssh-client in images ([#15](https://github.com/Monadical-SAS/cubbi/pull/15),
|
|
||||||
[`e70ec35`](https://github.com/Monadical-SAS/cubbi/commit/e70ec3538ba4e02a60afedca583da1c35b7b6d7a))
|
|
||||||
|
|
||||||
- Add sudo and sudoers ([#20](https://github.com/Monadical-SAS/cubbi/pull/20),
|
|
||||||
[`9c8ddbb`](https://github.com/Monadical-SAS/cubbi/commit/9c8ddbb3f3f2fc97db9283898b6a85aee7235fae))
|
|
||||||
|
|
||||||
* feat: add sudo and sudoers
|
|
||||||
|
|
||||||
* Update cubbi/images/cubbi_init.py
|
|
||||||
|
|
||||||
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
|
|
||||||
|
|
||||||
---------
|
|
||||||
|
|
||||||
- Implement Aider AI pair programming support
|
|
||||||
([#17](https://github.com/Monadical-SAS/cubbi/pull/17),
|
|
||||||
[`fc0d6b5`](https://github.com/Monadical-SAS/cubbi/commit/fc0d6b51af12ddb0bd8655309209dd88e7e4d6f1))
|
|
||||||
|
|
||||||
* feat: implement Aider AI pair programming support
|
|
||||||
|
|
||||||
- Add comprehensive Aider Docker image with Python 3.12 and system pip installation - Implement
|
|
||||||
aider_plugin.py for secure API key management and environment configuration - Support multiple LLM
|
|
||||||
providers: OpenAI, Anthropic, DeepSeek, Gemini, OpenRouter - Add persistent configuration for
|
|
||||||
~/.aider/ and ~/.cache/aider/ directories - Create comprehensive documentation with usage examples
|
|
||||||
and troubleshooting - Include automated test suite with 6 test categories covering all
|
|
||||||
functionality - Update container.py to support DEEPSEEK_API_KEY and GEMINI_API_KEY - Integrate
|
|
||||||
with Cubbi CLI for seamless session management
|
|
||||||
|
|
||||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
|
||||||
|
|
||||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
||||||
|
|
||||||
* Fix pytest for aider
|
|
||||||
|
|
||||||
* Fix pre-commit
|
|
||||||
|
|
||||||
---------
|
|
||||||
|
|
||||||
Co-authored-by: Your Name <you@example.com>
|
|
||||||
|
|
||||||
- Include new image opencode ([#14](https://github.com/Monadical-SAS/cubbi/pull/14),
|
|
||||||
[`5fca51e`](https://github.com/Monadical-SAS/cubbi/commit/5fca51e5152dcf7503781eb707fa04414cf33c05))
|
|
||||||
|
|
||||||
* feat: include new image opencode
|
|
||||||
|
|
||||||
* docs: update readme
|
|
||||||
|
|
||||||
- Support config `openai.url` for goose/opencode/aider
|
|
||||||
([`da5937e`](https://github.com/Monadical-SAS/cubbi/commit/da5937e70829b88a66f96c3ce7be7dacfc98facb))
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- New image layout and organization ([#13](https://github.com/Monadical-SAS/cubbi/pull/13),
|
|
||||||
[`e5121dd`](https://github.com/Monadical-SAS/cubbi/commit/e5121ddea4230e78a05a85c4ce668e0c169b5ace))
|
|
||||||
|
|
||||||
* refactor: rework how image are defined, in order to create others wrapper for others tools
|
|
||||||
|
|
||||||
* refactor: fix issues with ownership
|
|
||||||
|
|
||||||
* refactor: image share now information with others images type
|
|
||||||
|
|
||||||
* fix: update readme
|
|
||||||
|
|
||||||
|
|
||||||
## v0.2.0 (2025-05-21)
|
|
||||||
|
|
||||||
### Continuous Integration
|
|
||||||
|
|
||||||
- Add semantic release configuration (and use pyproject version)
|
|
||||||
([`fbba8b7`](https://github.com/Monadical-SAS/cubbi/commit/fbba8b7613c76c6a1ae21c81d9f07697320f6d10))
|
|
||||||
|
|
||||||
- Try fixing the dynamic_import issue
|
|
||||||
([`252d8be`](https://github.com/Monadical-SAS/cubbi/commit/252d8be735e6d18761c42e9c138ccafde89fd6ee))
|
|
||||||
|
|
||||||
- Try fixing the dynamic_import issue (2, force adding pyproject.toml)
|
|
||||||
([`31e09bc`](https://github.com/Monadical-SAS/cubbi/commit/31e09bc7ba8446508a90f5a9423271ac386498fe))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Add information for uvx
|
|
||||||
([`ba852d5`](https://github.com/Monadical-SAS/cubbi/commit/ba852d502eea4fc558c0f96d9015436101d5ef43))
|
|
||||||
|
|
||||||
- Add mit license
|
|
||||||
([`13c896a`](https://github.com/Monadical-SAS/cubbi/commit/13c896a58d9bc6f25b0688f9ae7117ae868ae705))
|
|
||||||
|
|
||||||
- Update classifiers
|
|
||||||
([`5218bb1`](https://github.com/Monadical-SAS/cubbi/commit/5218bb121804c440dc69c9d932787ed6d54b90f5))
|
|
||||||
|
|
||||||
- Update README
|
|
||||||
([`15d86d2`](https://github.com/Monadical-SAS/cubbi/commit/15d86d25e74162153c26d6c254059f24d46c4095))
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **cubbix**: Add --no-shell in combination with --run to not drop a shell and exit when the command
|
|
||||||
is done
|
|
||||||
([`75daccb`](https://github.com/Monadical-SAS/cubbi/commit/75daccb3662d059d178fd0f12026bb97f29f2452))
|
|
||||||
|
|
||||||
|
|
||||||
## v0.1.0-rc.1 (2025-04-18)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Mcp tests
|
|
||||||
([`3799f04`](https://github.com/Monadical-SAS/cubbi/commit/3799f04c1395d3b018f371db0c0cb8714e6fb8b3))
|
|
||||||
|
|
||||||
- Osx tests on volume
|
|
||||||
([`7fc9cfd`](https://github.com/Monadical-SAS/cubbi/commit/7fc9cfd8e1babfa069691d3b7997449535069674))
|
|
||||||
|
|
||||||
- Remove double connecting to message
|
|
||||||
([`e36f454`](https://github.com/Monadical-SAS/cubbi/commit/e36f4540bfe3794ab2d065f552cfb9528489de71))
|
|
||||||
|
|
||||||
- Remove the "mc stop" meant to be in the container, but not implemented
|
|
||||||
([`4f54c0f`](https://github.com/Monadical-SAS/cubbi/commit/4f54c0fbe7886c8551368b4b35be3ad8c7ae49ab))
|
|
||||||
|
|
||||||
- **cli**: Rename MAI->MC
|
|
||||||
([`354834f`](https://github.com/Monadical-SAS/cubbi/commit/354834fff733c37202b01a6fc49ebdf5003390c1))
|
|
||||||
|
|
||||||
- **goose**: Add ping, nano and vim to the default image
|
|
||||||
([`028bd26`](https://github.com/Monadical-SAS/cubbi/commit/028bd26cf12e181541e006650b58d97e1d568a45))
|
|
||||||
|
|
||||||
- **goose**: Always update the file
|
|
||||||
([`b1aa415`](https://github.com/Monadical-SAS/cubbi/commit/b1aa415ddee981dc1278cd24f7509363b9c54a54))
|
|
||||||
|
|
||||||
- **goose**: Ensure configuration is run as user
|
|
||||||
([`cfa7dd6`](https://github.com/Monadical-SAS/cubbi/commit/cfa7dd647d1e4055bf9159be2ee9c2280f2d908e))
|
|
||||||
|
|
||||||
- **goose**: Install latest goose version, do not use pip
|
|
||||||
([`7649173`](https://github.com/Monadical-SAS/cubbi/commit/7649173d6c8a82ac236d0f89263591eaa6e21a20))
|
|
||||||
|
|
||||||
- **goose**: Remove MCP_HOST and such, this is not how mcp works
|
|
||||||
([`d42af87`](https://github.com/Monadical-SAS/cubbi/commit/d42af870ff56112b4503f2568b8a5b0f385c435c))
|
|
||||||
|
|
||||||
- **goose**: Rename mai to mc, add initialization status
|
|
||||||
([`74c723d`](https://github.com/Monadical-SAS/cubbi/commit/74c723db7b6b7dd57c4ca32a804436a990e5260c))
|
|
||||||
|
|
||||||
- **langfuse**: Fix goose langfuse integration (wrong env variables)
|
|
||||||
([`e36eef4`](https://github.com/Monadical-SAS/cubbi/commit/e36eef4ef7c2d0cbdef31704afb45c50c4293986))
|
|
||||||
|
|
||||||
- **mc**: Fix runtime issue when starting mc
|
|
||||||
([`6f08e2b`](https://github.com/Monadical-SAS/cubbi/commit/6f08e2b274b67001694123b5bb977401df0810c6))
|
|
||||||
|
|
||||||
- **mcp**: Fix UnboundLocalError: cannot access local variable 'container_name' where it is not
|
|
||||||
associated with a value
|
|
||||||
([`deff036`](https://github.com/Monadical-SAS/cubbi/commit/deff036406d72d55659da40520a3a09599d65f07))
|
|
||||||
|
|
||||||
- **session**: Ensure a session connect only to the mcp server passed in --mcp
|
|
||||||
([`5d674f7`](https://github.com/Monadical-SAS/cubbi/commit/5d674f750878f0895dc1544620e8b1da4da29752))
|
|
||||||
|
|
||||||
- **session**: Fix session status display
|
|
||||||
([`092f497`](https://github.com/Monadical-SAS/cubbi/commit/092f497ecc19938d4917a18441995170d1f68704))
|
|
||||||
|
|
||||||
- **ssh**: Do not enable ssh automatically
|
|
||||||
([`f32b3dd`](https://github.com/Monadical-SAS/cubbi/commit/f32b3dd269d1a3d6ebaa2e7b2893f267b5175b20))
|
|
||||||
|
|
||||||
- **uid**: Correctly pass uid/gid to project
|
|
||||||
([`e25e30e`](https://github.com/Monadical-SAS/cubbi/commit/e25e30e7492c6b0a03017440a18bb2708927fc19))
|
|
||||||
|
|
||||||
- **uid**: Use symlink instead of volume for persistent volume in the container
|
|
||||||
([`a74251b`](https://github.com/Monadical-SAS/cubbi/commit/a74251b119d24714c7cc1eaadeea851008006137))
|
|
||||||
|
|
||||||
### Chores
|
|
||||||
|
|
||||||
- Remove unnecessary output
|
|
||||||
([`30c6b99`](https://github.com/Monadical-SAS/cubbi/commit/30c6b995cbb5bdf3dc7adf2e79d8836660d4f295))
|
|
||||||
|
|
||||||
- Update doc and add pre-commit
|
|
||||||
([`958d87b`](https://github.com/Monadical-SAS/cubbi/commit/958d87bcaeed16210a7c22574b5e63f2422af098))
|
|
||||||
|
|
||||||
### Continuous Integration
|
|
||||||
|
|
||||||
- Add ci files ([#11](https://github.com/Monadical-SAS/cubbi/pull/11),
|
|
||||||
[`3850bc3`](https://github.com/Monadical-SAS/cubbi/commit/3850bc32129da539f53b69427ddca85f8c5f390a))
|
|
||||||
|
|
||||||
* ci: add ci files
|
|
||||||
|
|
||||||
* fix: add goose image build
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Add --run option examples to README
|
|
||||||
([`6b2c1eb`](https://github.com/Monadical-SAS/cubbi/commit/6b2c1ebf1cd7a5d9970234112f32fe7a231303f9))
|
|
||||||
|
|
||||||
- Prefer mcx alias in README examples
|
|
||||||
([`9c21611`](https://github.com/Monadical-SAS/cubbi/commit/9c21611a7fa1497f7cbddb1f1b4cd22b4ebc8a19))
|
|
||||||
|
|
||||||
- **mcp**: Add specification for MCP server support
|
|
||||||
([`20916c5`](https://github.com/Monadical-SAS/cubbi/commit/20916c5713b3a047f4a8a33194f751f36e3c8a7a))
|
|
||||||
|
|
||||||
- **readme**: Remove license part
|
|
||||||
([`1c538f8`](https://github.com/Monadical-SAS/cubbi/commit/1c538f8a59e28888309c181ae8f8034b9e70a631))
|
|
||||||
|
|
||||||
- **readme**: Update README to update tool call
|
|
||||||
([`a4591dd`](https://github.com/Monadical-SAS/cubbi/commit/a4591ddbd863bc6658a7643d3f33d06c82816cae))
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- First commit
|
|
||||||
([`fde6529`](https://github.com/Monadical-SAS/cubbi/commit/fde6529d545b5625484c5c1236254d2e0c6f0f4d))
|
|
||||||
|
|
||||||
- **cli**: Auto connect to a session
|
|
||||||
([`4a63606`](https://github.com/Monadical-SAS/cubbi/commit/4a63606d58cc3e331a349974e9b3bf2d856a72a1))
|
|
||||||
|
|
||||||
- **cli**: Auto mount current directory as /app
|
|
||||||
([`e6e3c20`](https://github.com/Monadical-SAS/cubbi/commit/e6e3c207bcee531b135824688adf1a56ae427a01))
|
|
||||||
|
|
||||||
- **cli**: More information when closing session
|
|
||||||
([`08ba1ab`](https://github.com/Monadical-SAS/cubbi/commit/08ba1ab2da3c24237c0f0bc411924d8ffbe71765))
|
|
||||||
|
|
||||||
- **cli**: Phase 1 - local cli with docker integration
|
|
||||||
([`6443083`](https://github.com/Monadical-SAS/cubbi/commit/64430830d883308e4d52e17b25c260a0d5385141))
|
|
||||||
|
|
||||||
- **cli**: Separate session state into its own session.yaml file
|
|
||||||
([`7736573`](https://github.com/Monadical-SAS/cubbi/commit/7736573b84c7a51eaa60b932f835726b411ca742))
|
|
||||||
|
|
||||||
- **cli**: Support to join external network
|
|
||||||
([`133583b`](https://github.com/Monadical-SAS/cubbi/commit/133583b941ed56d1b0636277bb847c45eee7f3b8))
|
|
||||||
|
|
||||||
- **config**: Add global user configuration for the tool
|
|
||||||
([`dab783b`](https://github.com/Monadical-SAS/cubbi/commit/dab783b01d82bcb210b5e01ac3b93ba64c7bc023))
|
|
||||||
|
|
||||||
- langfuse - default driver - and api keys
|
|
||||||
|
|
||||||
- **config**: Ensure config is correctly saved
|
|
||||||
([`deb5945`](https://github.com/Monadical-SAS/cubbi/commit/deb5945e40d55643dca4e1aa4201dfa8da1bfd70))
|
|
||||||
|
|
||||||
- **gemini**: Support for gemini model
|
|
||||||
([`2f9fd68`](https://github.com/Monadical-SAS/cubbi/commit/2f9fd68cada9b5aaba652efb67368c2641046da5))
|
|
||||||
|
|
||||||
- **goose**: Auto add mcp server to goose configuration when starting a session
|
|
||||||
([`7805aa7`](https://github.com/Monadical-SAS/cubbi/commit/7805aa720eba78d47f2ad565f6944e84a21c4b1c))
|
|
||||||
|
|
||||||
- **goose**: Optimize init status
|
|
||||||
([`16f59b1`](https://github.com/Monadical-SAS/cubbi/commit/16f59b1c408dbff4781ad7ccfa70e81d6d98f7bd))
|
|
||||||
|
|
||||||
- **goose**: Update config using uv script with pyyaml
|
|
||||||
([#6](https://github.com/Monadical-SAS/cubbi/pull/6),
|
|
||||||
[`9e742b4`](https://github.com/Monadical-SAS/cubbi/commit/9e742b439b7b852efa4219850f8b67c143274045))
|
|
||||||
|
|
||||||
- **keys**: Pass local keys to the session by default
|
|
||||||
([`f83c49c`](https://github.com/Monadical-SAS/cubbi/commit/f83c49c0f340d1a3accba1fe1317994b492755c0))
|
|
||||||
|
|
||||||
- **llm**: Add default model/provider to auto configure the driver
|
|
||||||
([#7](https://github.com/Monadical-SAS/cubbi/pull/7),
|
|
||||||
[`5b9713d`](https://github.com/Monadical-SAS/cubbi/commit/5b9713dc2f7d7c25808ad37094838c697c056fec))
|
|
||||||
|
|
||||||
- **mc**: Support for uid/gid, and use default current user
|
|
||||||
([`a51115a`](https://github.com/Monadical-SAS/cubbi/commit/a51115a45d88bf703fb5380171042276873b7207))
|
|
||||||
|
|
||||||
- **mcp**: Add inspector
|
|
||||||
([`d098f26`](https://github.com/Monadical-SAS/cubbi/commit/d098f268cd164e9d708089c9f9525a940653c010))
|
|
||||||
|
|
||||||
- **mcp**: Add the possibility to have default mcp to connect to
|
|
||||||
([`4b0461a`](https://github.com/Monadical-SAS/cubbi/commit/4b0461a6faf81de1e1b54d1fe78fea7977cde9dd))
|
|
||||||
|
|
||||||
- **mcp**: Ensure inner mcp environemnt variables are passed
|
|
||||||
([`0d75bfc`](https://github.com/Monadical-SAS/cubbi/commit/0d75bfc3d8e130fb05048c2bc8a674f6b7e5de83))
|
|
||||||
|
|
||||||
- **mcp**: First docker proxy working
|
|
||||||
([`0892b6c`](https://github.com/Monadical-SAS/cubbi/commit/0892b6c8c472063c639cc78cf29b322bb39f998f))
|
|
||||||
|
|
||||||
- **mcp**: Improve inspector reliability over re-run
|
|
||||||
([`3ee8ce6`](https://github.com/Monadical-SAS/cubbi/commit/3ee8ce6338c35b7e48d788d2dddfa9b6a70381cb))
|
|
||||||
|
|
||||||
- **mcp**: Initial version of mcp
|
|
||||||
([`212f271`](https://github.com/Monadical-SAS/cubbi/commit/212f271268c5724775beceae119f97aec2748dcb))
|
|
||||||
|
|
||||||
- **project**: Explicitely add --project to save information in /mc-config across run.
|
|
||||||
([`3a182fd`](https://github.com/Monadical-SAS/cubbi/commit/3a182fd2658c0eb361ce5ed88938686e2bd19e59))
|
|
||||||
|
|
||||||
Containers are now isolated by default.
|
|
||||||
|
|
||||||
- **run**: Add --run command
|
|
||||||
([`33d90d0`](https://github.com/Monadical-SAS/cubbi/commit/33d90d05311ad872b7a7d4cd303ff6f7b7726038))
|
|
||||||
|
|
||||||
- **ssh**: Make SSH server optional with --ssh flag
|
|
||||||
([`5678438`](https://github.com/Monadical-SAS/cubbi/commit/56784386614fcd0a52be8a2eb89d2deef9323ca1))
|
|
||||||
|
|
||||||
- Added --ssh flag to session create command - Modified mc-init.sh to check MC_SSH_ENABLED
|
|
||||||
environment variable - SSH server is now disabled by default - Updated README.md with new flag
|
|
||||||
example - Fixed UnboundLocalError with container_name in exception handler
|
|
||||||
|
|
||||||
- **volume**: Add mc config volume command
|
|
||||||
([`2caeb42`](https://github.com/Monadical-SAS/cubbi/commit/2caeb425518242fbe1c921b9678e6e7571b9b0a6))
|
|
||||||
|
|
||||||
- **volume**: Add the possibilty to mount local directory into the container (like docker volume)
|
|
||||||
([`b72f1ee`](https://github.com/Monadical-SAS/cubbi/commit/b72f1eef9af598f2090a0edae8921c16814b3cda))
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- Move drivers directory into mcontainer package
|
|
||||||
([`307eee4`](https://github.com/Monadical-SAS/cubbi/commit/307eee4fcef47189a98a76187d6080a36423ad6e))
|
|
||||||
|
|
||||||
- Relocate goose driver to mcontainer/drivers/ - Update ConfigManager to dynamically scan for driver
|
|
||||||
YAML files - Add support for mc-driver.yaml instead of mai-driver.yaml - Update Driver model to
|
|
||||||
support init commands and other YAML fields - Auto-discover drivers at runtime instead of
|
|
||||||
hardcoding them - Update documentation to reflect new directory structure
|
|
||||||
|
|
||||||
- Reduce amount of data in session.yaml
|
|
||||||
([`979b438`](https://github.com/Monadical-SAS/cubbi/commit/979b43846a798f1fb25ff05e6dc1fc27fa16f590))
|
|
||||||
|
|
||||||
- Rename driver to image, first pass
|
|
||||||
([`51fb79b`](https://github.com/Monadical-SAS/cubbi/commit/51fb79baa30ff479ac5479ba5ea0cad70bbb4c20))
|
|
||||||
|
|
||||||
- Rename project to cubbi
|
|
||||||
([`12d77d0`](https://github.com/Monadical-SAS/cubbi/commit/12d77d0128e4d82e5ddc1a4ab7e873ddaa22e130))
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- Add unit tests
|
|
||||||
([`7c46d66`](https://github.com/Monadical-SAS/cubbi/commit/7c46d66b53ac49c08458bc5d72e636e7d296e74f))
|
|
||||||
21
CLAUDE.md
21
CLAUDE.md
@@ -1,12 +1,15 @@
|
|||||||
# Cubbi Container Development Guide
|
# Monadical Container Development Guide
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies using uv (Astral)
|
# Install dependencies using uv (Astral)
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
# Run Cubbi CLI
|
# Run MC service
|
||||||
uv run -m cubbi.cli
|
uv run -m mcontainer.service
|
||||||
|
|
||||||
|
# Run MC CLI
|
||||||
|
uv run -m mcontainer.cli
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lint/Test Commands
|
## Lint/Test Commands
|
||||||
@@ -48,15 +51,3 @@ Use uv instead:
|
|||||||
- **Configuration**: Use environment variables with YAML for configuration
|
- **Configuration**: Use environment variables with YAML for configuration
|
||||||
|
|
||||||
Refer to SPECIFICATIONS.md for detailed architecture and implementation guidance.
|
Refer to SPECIFICATIONS.md for detailed architecture and implementation guidance.
|
||||||
|
|
||||||
## Cubbi images
|
|
||||||
|
|
||||||
A cubbi image is a flavored docker image that wrap a tool (let's say goose), and dynamically configure the tool when the image is starting. All cubbi images are defined in `cubbi/images` directory.
|
|
||||||
|
|
||||||
Each image must have (let's take goose image for example):
|
|
||||||
- `goose/cubbi_image.yaml`, list of persistent paths, etc.
|
|
||||||
- `goose/Dockerfile`, that is used to build the cubbi image with cubbi tools
|
|
||||||
- `goose/goose_plugin.py`, a plugin file named of the cubbi image name, that is specific for this image, with the intent to configure dynamically the docker image when starting with the preferences of the user (via environment variable). They all import `cubbi_init.py`, but this file is shared accross all images, so it is normal that execution of the plugin import does not work, because the build system will copy the file in place during the build.
|
|
||||||
- `goose/README.md`, a tiny readme about the image
|
|
||||||
|
|
||||||
If you are creating a new image, look about existing images (goose, opencode).
|
|
||||||
|
|||||||
9
LICENSE
9
LICENSE
@@ -1,9 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 Monadical SAS
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
319
README.md
319
README.md
@@ -1,182 +1,124 @@
|
|||||||
<div align="center">
|
# MC - Monadical Container Tool
|
||||||
|
|
||||||
# Cubbi - Container Tool
|
MC (Monadical Container) is a command-line tool for managing ephemeral
|
||||||
|
containers that run AI tools and development environments. It works with both
|
||||||
|
local Docker and a dedicated remote web service that manages containers in a
|
||||||
|
Docker-in-Docker (DinD) environment. MC also supports connecting to MCP (Model Context Protocol) servers to extend AI tools with additional capabilities.
|
||||||
|
|
||||||
Cubbi is a command-line tool for managing ephemeral containers that run AI tools and development environments, with support for MCP servers. It supports [Aider](https://github.com/Aider-AI/aider), [Crush](https://github.com/charmbracelet/crush), [Claude Code](https://github.com/anthropics/claude-code), [Goose](https://github.com/block/goose), [Opencode](https://github.com/sst/opencode).
|
## Quick Reference
|
||||||
|
|
||||||

|
- `mc session create` - Create a new session
|
||||||

|
- `mcx` - Shortcut for `mc session create`
|
||||||
[](https://github.com/monadical-sas/cubbi/actions/workflows/pytests.yml)
|
- `mcx .` - Mount the current directory
|
||||||
[](https://opensource.org/licenses/MIT)
|
- `mcx /path/to/dir` - Mount a specific directory
|
||||||
|
- `mcx https://github.com/user/repo` - Clone a repository
|
||||||
|
|
||||||
</div>
|
## Requirements
|
||||||
|
|
||||||
## 🚀 Quick Reference
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
|
|
||||||
- `cubbi session create` - Create a new session
|
## Installation
|
||||||
- `cubbix` - Shortcut for `cubbi session create`
|
|
||||||
- `cubbix .` - Mount the current directory
|
|
||||||
- `cubbix /path/to/dir` - Mount a specific directory
|
|
||||||
|
|
||||||
## 📋 Requirements
|
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/)
|
|
||||||
- [uv](https://astral.sh/uv)
|
|
||||||
|
|
||||||
## 📥 Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Via uv
|
# Clone the repository
|
||||||
uv tool install cubbi
|
git clone https://github.com/monadical/mcontainer.git
|
||||||
|
|
||||||
# Without installation
|
# Install the tool locally
|
||||||
# (meaning all commands below must be prefixed with `uvx`)
|
# (with editable, so you can update the code and work with it)
|
||||||
uvx cubbi
|
cd mcontainer
|
||||||
```
|
|
||||||
|
|
||||||
Then compile your first image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cubbi image build goose
|
|
||||||
cubbi image build opencode
|
|
||||||
cubbi image build crush
|
|
||||||
```
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
|
|
||||||
If you are looking to contribute to the development, you will need to use `uv` as well:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/monadical-sas/cubbi
|
|
||||||
cd cubbi
|
|
||||||
uv tool install --with-editable . .
|
uv tool install --with-editable . .
|
||||||
# You'll have cubbi and cubbix executable files in your PATH, pointing to the local installation.
|
|
||||||
|
# Then you could use the tool as `mc`
|
||||||
|
mc --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Show help message (displays available commands)
|
# Show help message (displays available commands)
|
||||||
cubbi
|
mc
|
||||||
|
|
||||||
# Create a new session with the default image (using cubbix alias)
|
# Create a new session with the default driver (using mcx alias)
|
||||||
cubbix
|
mcx
|
||||||
|
|
||||||
# Create a session and run an initial command before the shell starts
|
# Create a session and run an initial command before the shell starts
|
||||||
cubbix --run "ls -l"
|
mcx --run "echo 'Setup complete'; ls -l"
|
||||||
|
|
||||||
# Create a session, run a command, and exit (no shell prompt)
|
|
||||||
cubbix --run "ls -l" --no-shell
|
|
||||||
|
|
||||||
# List all active sessions
|
# List all active sessions
|
||||||
cubbi session list
|
mc session list
|
||||||
|
|
||||||
# Connect to a specific session
|
# Connect to a specific session
|
||||||
cubbi session connect SESSION_ID
|
mc session connect SESSION_ID
|
||||||
|
|
||||||
# Close a session when done
|
# Close a session when done
|
||||||
cubbi session close SESSION_ID
|
mc session close SESSION_ID
|
||||||
|
|
||||||
# Close a session quickly (kill instead of graceful stop)
|
# Create a session with a specific driver
|
||||||
cubbi session close SESSION_ID --kill
|
mcx --driver goose
|
||||||
|
|
||||||
# Close all sessions at once
|
|
||||||
cubbi session close --all
|
|
||||||
|
|
||||||
# Close all sessions quickly
|
|
||||||
cubbi session close --all --kill
|
|
||||||
|
|
||||||
# Create a session with a specific image
|
|
||||||
cubbix --image goose
|
|
||||||
cubbix --image opencode
|
|
||||||
cubbix --image crush
|
|
||||||
|
|
||||||
# Create a session with environment variables
|
# Create a session with environment variables
|
||||||
cubbix -e VAR1=value1 -e VAR2=value2
|
mcx -e VAR1=value1 -e VAR2=value2
|
||||||
|
|
||||||
# Mount custom volumes (similar to Docker's -v flag)
|
# Mount custom volumes (similar to Docker's -v flag)
|
||||||
cubbix -v /local/path:/container/path
|
mcx -v /local/path:/container/path
|
||||||
cubbix -v ~/data:/data -v ./configs:/etc/app/config
|
mcx -v ~/data:/data -v ./configs:/etc/app/config
|
||||||
|
|
||||||
# Mount a local directory (current directory or specific path)
|
# Mount a local directory (current directory or specific path)
|
||||||
cubbix .
|
mcx .
|
||||||
cubbix /path/to/project
|
mcx /path/to/project
|
||||||
|
|
||||||
# Forward ports from container to host
|
|
||||||
cubbix --port 8000 # Forward port 8000
|
|
||||||
cubbix --port 8000,3000,5173 # Forward multiple ports (comma-separated)
|
|
||||||
cubbix --port 8000 --port 3000 # Forward multiple ports (repeated flag)
|
|
||||||
|
|
||||||
# Connect to external Docker networks
|
# Connect to external Docker networks
|
||||||
cubbix --network teamnet --network dbnet
|
mcx --network teamnet --network dbnet
|
||||||
|
|
||||||
# Restrict network access to specific domains
|
|
||||||
cubbix --domains github.com --domains "api.example.com:443"
|
|
||||||
|
|
||||||
# Connect to MCP servers for extended capabilities
|
# Connect to MCP servers for extended capabilities
|
||||||
cubbix --mcp github --mcp jira
|
mcx --mcp github --mcp jira
|
||||||
|
|
||||||
# Clone a Git repository
|
# Clone a Git repository
|
||||||
cubbix https://github.com/username/repo
|
mcx https://github.com/username/repo
|
||||||
|
|
||||||
# Using the cubbix shortcut (equivalent to cubbi session create)
|
# Using the mcx shortcut (equivalent to mc session create)
|
||||||
cubbix # Creates a session without mounting anything
|
mcx # Creates a session without mounting anything
|
||||||
cubbix . # Mounts the current directory
|
mcx . # Mounts the current directory
|
||||||
cubbix /path/to/project # Mounts the specified directory
|
mcx /path/to/project # Mounts the specified directory
|
||||||
cubbix https://github.com/username/repo # Clones the repository
|
mcx https://github.com/username/repo # Clones the repository
|
||||||
|
|
||||||
# Shorthand with MCP servers
|
# Shorthand with MCP servers
|
||||||
cubbix https://github.com/username/repo --mcp github
|
mcx https://github.com/username/repo --mcp github
|
||||||
|
|
||||||
# Shorthand with an initial command
|
# Shorthand with an initial command
|
||||||
cubbix . --run "apt-get update && apt-get install -y my-package"
|
mcx . --run "apt-get update && apt-get install -y my-package"
|
||||||
|
|
||||||
# Execute a command and exit without starting a shell
|
|
||||||
cubbix . --run "python script.py" --no-shell
|
|
||||||
|
|
||||||
# Enable SSH server in the container
|
# Enable SSH server in the container
|
||||||
cubbix --ssh
|
mcx --ssh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🖼️ Image Management
|
## Driver Management
|
||||||
|
|
||||||
Cubbi includes an image management system that allows you to build, manage, and use Docker images for different AI tools
|
MC includes a driver management system that allows you to build, manage, and use Docker images for different AI tools:
|
||||||
|
|
||||||
**Supported Images**
|
|
||||||
|
|
||||||
| Image Name | Langtrace Support |
|
|
||||||
|------------|-------------------|
|
|
||||||
| goose | yes |
|
|
||||||
| opencode | no |
|
|
||||||
| claudecode | no |
|
|
||||||
| aider | no |
|
|
||||||
| crush | no |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List available images
|
# List available drivers
|
||||||
cubbi image list
|
mc driver list
|
||||||
|
|
||||||
# Get detailed information about an image
|
# Get detailed information about a driver
|
||||||
cubbi image info goose
|
mc driver info goose
|
||||||
cubbi image info opencode
|
|
||||||
cubbi image info crush
|
|
||||||
|
|
||||||
# Build an image
|
# Build a driver image
|
||||||
cubbi image build goose
|
mc driver build goose
|
||||||
cubbi image build opencode
|
|
||||||
cubbi image build crush
|
# Build and push a driver image
|
||||||
|
mc driver build goose --push
|
||||||
```
|
```
|
||||||
|
|
||||||
Images are defined in the `cubbi/images/` directory, with each subdirectory containing:
|
Drivers are defined in the `mcontainer/drivers/` directory, with each subdirectory containing:
|
||||||
|
|
||||||
- `Dockerfile`: Docker image definition
|
- `Dockerfile`: Docker image definition
|
||||||
- `entrypoint.sh`: Container entrypoint script
|
- `entrypoint.sh`: Container entrypoint script
|
||||||
- `cubbi-init.sh`: Standardized initialization script
|
- `mc-init.sh`: Standardized initialization script
|
||||||
- `cubbi_image.yaml`: Image metadata and configuration
|
- `mc-driver.yaml`: Driver metadata and configuration
|
||||||
- `README.md`: Image documentation
|
- `README.md`: Driver documentation
|
||||||
|
|
||||||
Cubbi automatically discovers and loads image definitions from the YAML files.
|
MC automatically discovers and loads driver definitions from the YAML files.
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -187,34 +129,37 @@ uv run -m pytest
|
|||||||
# Run linting
|
# Run linting
|
||||||
uvx ruff check .
|
uvx ruff check .
|
||||||
|
|
||||||
|
# Run type checking
|
||||||
|
uvx mypy .
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
uvx ruff format .
|
uvx ruff format .
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## Configuration
|
||||||
|
|
||||||
Cubbi supports user-specific configuration via a YAML file located at `~/.config/cubbi/config.yaml`. This allows you to set default values and configure service credentials.
|
MC supports user-specific configuration via a YAML file located at `~/.config/mc/config.yaml`. This allows you to set default values and configure service credentials.
|
||||||
|
|
||||||
### Managing Configuration
|
### Managing Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View all configuration
|
# View all configuration
|
||||||
cubbi config list
|
mc config list
|
||||||
|
|
||||||
# Get a specific configuration value
|
# Get a specific configuration value
|
||||||
cubbi config get langfuse.url
|
mc config get langfuse.url
|
||||||
|
|
||||||
# Set configuration values
|
# Set configuration values
|
||||||
cubbi config set langfuse.url "https://cloud.langfuse.com"
|
mc config set langfuse.url "https://cloud.langfuse.com"
|
||||||
cubbi config set langfuse.public_key "pk-lf-..."
|
mc config set langfuse.public_key "pk-lf-..."
|
||||||
cubbi config set langfuse.secret_key "sk-lf-..."
|
mc config set langfuse.secret_key "sk-lf-..."
|
||||||
|
|
||||||
# Set API keys for various services
|
# Set API keys for various services
|
||||||
cubbi config set openai.api_key "sk-..."
|
mc config set openai.api_key "sk-..."
|
||||||
cubbi config set anthropic.api_key "sk-ant-..."
|
mc config set anthropic.api_key "sk-ant-..."
|
||||||
|
|
||||||
# Reset configuration to defaults
|
# Reset configuration to defaults
|
||||||
cubbi config reset
|
mc config reset
|
||||||
```
|
```
|
||||||
|
|
||||||
### Default Networks Configuration
|
### Default Networks Configuration
|
||||||
@@ -223,13 +168,13 @@ You can configure default networks that will be applied to every new session:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List default networks
|
# List default networks
|
||||||
cubbi config network list
|
mc config network list
|
||||||
|
|
||||||
# Add a network to defaults
|
# Add a network to defaults
|
||||||
cubbi config network add teamnet
|
mc config network add teamnet
|
||||||
|
|
||||||
# Remove a network from defaults
|
# Remove a network from defaults
|
||||||
cubbi config network remove teamnet
|
mc config network remove teamnet
|
||||||
```
|
```
|
||||||
|
|
||||||
### Default Volumes Configuration
|
### Default Volumes Configuration
|
||||||
@@ -238,70 +183,52 @@ You can configure default volumes that will be automatically mounted in every ne
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List default volumes
|
# List default volumes
|
||||||
cubbi config volume list
|
mc config volume list
|
||||||
|
|
||||||
# Add a volume to defaults
|
# Add a volume to defaults
|
||||||
cubbi config volume add /local/path:/container/path
|
mc config volume add /local/path:/container/path
|
||||||
|
|
||||||
# Remove a volume from defaults (will prompt if multiple matches found)
|
# Remove a volume from defaults (will prompt if multiple matches found)
|
||||||
cubbi config volume remove /local/path
|
mc config volume remove /local/path
|
||||||
```
|
```
|
||||||
|
|
||||||
Default volumes will be combined with any volumes specified using the `-v` flag when creating a session.
|
Default volumes will be combined with any volumes specified using the `-v` flag when creating a session.
|
||||||
|
|
||||||
### Default Ports Configuration
|
|
||||||
|
|
||||||
You can configure default ports that will be automatically forwarded in every new session:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List default ports
|
|
||||||
cubbi config port list
|
|
||||||
|
|
||||||
# Add a single port to defaults
|
|
||||||
cubbi config port add 8000
|
|
||||||
|
|
||||||
# Add multiple ports to defaults (comma-separated)
|
|
||||||
cubbi config port add 8000,3000,5173
|
|
||||||
|
|
||||||
# Remove a port from defaults
|
|
||||||
cubbi config port remove 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
Default ports will be combined with any ports specified using the `--port` flag when creating a session.
|
|
||||||
|
|
||||||
### Default MCP Servers Configuration
|
### Default MCP Servers Configuration
|
||||||
|
|
||||||
You can configure default MCP servers that sessions will automatically connect to:
|
You can configure default MCP servers that sessions will automatically connect to:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List default MCP servers
|
# List default MCP servers
|
||||||
cubbi config mcp list
|
mc config mcp list
|
||||||
|
|
||||||
# Add an MCP server to defaults
|
# Add an MCP server to defaults
|
||||||
cubbi config mcp add github
|
mc config mcp add github
|
||||||
|
|
||||||
# Remove an MCP server from defaults
|
# Remove an MCP server from defaults
|
||||||
cubbi config mcp remove github
|
mc config mcp remove github
|
||||||
```
|
```
|
||||||
|
|
||||||
When adding new MCP servers, they are added to defaults by default. Use the `--no-default` flag to prevent this:
|
When adding new MCP servers, they are added to defaults by default. Use the `--no-default` flag to prevent this:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cubbi mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=xxxx github mcp/github --no-default
|
# Add an MCP server without adding it to defaults
|
||||||
|
mc mcp add github ghcr.io/mcp/github:latest --no-default
|
||||||
|
mc mcp add-remote jira https://jira-mcp.example.com/sse --no-default
|
||||||
```
|
```
|
||||||
|
|
||||||
When creating sessions, if no MCP server is specified with `--mcp`, the default MCP servers will be used automatically.
|
When creating sessions, if no MCP server is specified with `--mcp`, the default MCP servers will be used automatically.
|
||||||
|
|
||||||
### External Network Connectivity
|
### External Network Connectivity
|
||||||
|
|
||||||
Cubbi containers can connect to external Docker networks, allowing them to communicate with other services in those networks:
|
MC containers can connect to external Docker networks, allowing them to communicate with other services in those networks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a session connected to external networks
|
# Create a session connected to external networks
|
||||||
cubbi session create --network teamnet --network dbnet
|
mc session create --network teamnet --network dbnet
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important**: Networks must be "attachable" to be joined by Cubbi containers. Here's how to create attachable networks:
|
**Important**: Networks must be "attachable" to be joined by MC containers. Here's how to create attachable networks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create an attachable network with Docker
|
# Create an attachable network with Docker
|
||||||
@@ -319,12 +246,12 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
teamnet:
|
teamnet:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
attachable: true # This is required for Cubbi containers to connect
|
attachable: true # This is required for MC containers to connect
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Credentials
|
### Service Credentials
|
||||||
|
|
||||||
Service credentials like API keys configured in `~/.config/cubbi/config.yaml` are automatically passed to containers as environment variables:
|
Service credentials like API keys configured in `~/.config/mc/config.yaml` are automatically passed to containers as environment variables:
|
||||||
|
|
||||||
| Config Setting | Environment Variable |
|
| Config Setting | Environment Variable |
|
||||||
|----------------|---------------------|
|
|----------------|---------------------|
|
||||||
@@ -336,65 +263,68 @@ Service credentials like API keys configured in `~/.config/cubbi/config.yaml` ar
|
|||||||
| `openrouter.api_key` | `OPENROUTER_API_KEY` |
|
| `openrouter.api_key` | `OPENROUTER_API_KEY` |
|
||||||
| `google.api_key` | `GOOGLE_API_KEY` |
|
| `google.api_key` | `GOOGLE_API_KEY` |
|
||||||
|
|
||||||
## 🌐 MCP Server Management
|
## MCP Server Management
|
||||||
|
|
||||||
MCP (Model Control Protocol) servers provide tool-calling capabilities to AI models, enhancing their ability to interact with external services, databases, and systems. Cubbi supports multiple types of MCP servers:
|
MCP (Model Context Protocol) servers provide tool-calling capabilities to AI models, enhancing their ability to interact with external services, databases, and systems. MC supports multiple types of MCP servers:
|
||||||
|
|
||||||
1. **Remote HTTP SSE servers** - External MCP servers accessed over HTTP
|
1. **Remote HTTP SSE servers** - External MCP servers accessed over HTTP
|
||||||
2. **Docker-based MCP servers** - Local MCP servers running in Docker containers, with a SSE proxy for stdio-to-SSE conversion
|
2. **Docker-based MCP servers** - Local MCP servers running in Docker containers
|
||||||
|
3. **Proxy-based MCP servers** - Local MCP servers with an SSE proxy for stdio-to-SSE conversion
|
||||||
|
|
||||||
### Managing MCP Servers
|
### Managing MCP Servers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all configured MCP servers and their status
|
# List all configured MCP servers and their status
|
||||||
cubbi mcp list
|
mc mcp list
|
||||||
|
|
||||||
# View detailed status of an MCP server
|
# View detailed status of an MCP server
|
||||||
cubbi mcp status github
|
mc mcp status github
|
||||||
|
|
||||||
# Start/stop/restart individual MCP servers
|
# Start/stop/restart individual MCP servers
|
||||||
cubbi mcp start github
|
mc mcp start github
|
||||||
cubbi mcp stop github
|
mc mcp stop github
|
||||||
cubbi mcp restart github
|
mc mcp restart github
|
||||||
|
|
||||||
# Start all MCP servers at once
|
# Start all MCP servers at once
|
||||||
cubbi mcp start --all
|
mc mcp start --all
|
||||||
|
|
||||||
# Stop and remove all MCP servers at once
|
# Stop and remove all MCP servers at once
|
||||||
cubbi mcp stop --all
|
mc mcp stop --all
|
||||||
|
|
||||||
# Run the MCP Inspector to visualize and interact with MCP servers
|
# Run the MCP Inspector to visualize and interact with MCP servers
|
||||||
# It automatically joins all MCP networks for seamless DNS resolution
|
# It automatically joins all MCP networks for seamless DNS resolution
|
||||||
# Uses two ports: frontend UI (default: 5173) and backend API (default: 3000)
|
# Uses two ports: frontend UI (default: 5173) and backend API (default: 3000)
|
||||||
cubbi mcp inspector
|
mc mcp inspector
|
||||||
|
|
||||||
# Run the MCP Inspector with custom ports
|
# Run the MCP Inspector with custom ports
|
||||||
cubbi mcp inspector --client-port 6173 --server-port 6174
|
mc mcp inspector --client-port 6173 --server-port 6174
|
||||||
|
|
||||||
# Run the MCP Inspector in detached mode
|
# Run the MCP Inspector in detached mode
|
||||||
cubbi mcp inspector --detach
|
mc mcp inspector --detach
|
||||||
|
|
||||||
# Stop the MCP Inspector
|
# Stop the MCP Inspector
|
||||||
cubbi mcp inspector --stop
|
mc mcp inspector --stop
|
||||||
|
|
||||||
# View MCP server logs
|
# View MCP server logs
|
||||||
cubbi mcp logs github
|
mc mcp logs github
|
||||||
|
|
||||||
# Remove an MCP server configuration
|
# Remove an MCP server configuration
|
||||||
cubbi mcp remove github
|
mc mcp remove github
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding MCP Servers
|
### Adding MCP Servers
|
||||||
|
|
||||||
Cubbi supports different types of MCP servers:
|
MC supports different types of MCP servers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example of docker-based MCP server
|
# Add a remote HTTP SSE MCP server
|
||||||
cubbi mcp add fetch mcp/fetch
|
mc mcp remote add github http://my-mcp-server.example.com/sse --header "Authorization=Bearer token123"
|
||||||
cubbi mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=xxxx github mcp/github
|
|
||||||
|
|
||||||
# Example of SSE-based MCP server
|
# Add a Docker-based MCP server
|
||||||
cubbi mcp add myserver https://myssemcp.com
|
mc mcp docker add github mcp/github:latest --command "github-mcp" --env GITHUB_TOKEN=ghp_123456
|
||||||
|
|
||||||
|
# Add a proxy-based MCP server (for stdio-to-SSE conversion)
|
||||||
|
mc mcp add github ghcr.io/mcp/github:latest --proxy-image ghcr.io/sparfenyuk/mcp-proxy:latest --command "github-mcp" --sse-port 8080 --no-default
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using MCP Servers with Sessions
|
### Using MCP Servers with Sessions
|
||||||
@@ -403,14 +333,13 @@ MCP servers can be attached to sessions when they are created:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a session with a single MCP server
|
# Create a session with a single MCP server
|
||||||
cubbi session create --mcp github
|
mc session create --mcp github
|
||||||
|
|
||||||
# Create a session with multiple MCP servers
|
# Create a session with multiple MCP servers
|
||||||
cubbi session create --mcp github --mcp jira
|
mc session create --mcp github --mcp jira
|
||||||
|
|
||||||
|
# Using MCP with a project repository
|
||||||
|
mc github.com/username/repo --mcp github
|
||||||
```
|
```
|
||||||
|
|
||||||
MCP servers are persistent and can be shared between sessions. They continue running even when sessions are closed, allowing for efficient reuse across multiple sessions.
|
MCP servers are persistent and can be shared between sessions. They continue running even when sessions are closed, allowing for efficient reuse across multiple sessions.
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
Cubbi is licensed under the [MIT License](LICENSE).
|
|
||||||
|
|||||||
175
cubbi/config.py
175
cubbi/config.py
@@ -1,175 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from .models import Config, Image
|
|
||||||
|
|
||||||
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "cubbi"
|
|
||||||
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.yaml"
|
|
||||||
DEFAULT_IMAGES_DIR = Path.home() / ".config" / "cubbi" / "images"
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
BUILTIN_IMAGES_DIR = Path(__file__).parent / "images"
|
|
||||||
|
|
||||||
# Dynamically loaded from images directory at runtime
|
|
||||||
DEFAULT_IMAGES = {}
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
|
||||||
def __init__(self, config_path: Optional[Path] = None):
|
|
||||||
self.config_path = config_path or DEFAULT_CONFIG_FILE
|
|
||||||
self.config_dir = self.config_path.parent
|
|
||||||
self.images_dir = DEFAULT_IMAGES_DIR
|
|
||||||
self.config = self._load_or_create_config()
|
|
||||||
|
|
||||||
# Always load package images on initialization
|
|
||||||
# These are separate from the user config
|
|
||||||
self.builtin_images = self._load_package_images()
|
|
||||||
|
|
||||||
def _load_or_create_config(self) -> Config:
|
|
||||||
"""Load existing config or create a new one with defaults"""
|
|
||||||
if self.config_path.exists():
|
|
||||||
try:
|
|
||||||
with open(self.config_path, "r") as f:
|
|
||||||
config_data = yaml.safe_load(f) or {}
|
|
||||||
|
|
||||||
# Create a new config from scratch, then update with data from file
|
|
||||||
config = Config(
|
|
||||||
docker=config_data.get("docker", {}),
|
|
||||||
defaults=config_data.get("defaults", {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add images
|
|
||||||
if "images" in config_data:
|
|
||||||
for image_name, image_data in config_data["images"].items():
|
|
||||||
config.images[image_name] = Image.model_validate(image_data)
|
|
||||||
|
|
||||||
return config
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading config: {e}")
|
|
||||||
return self._create_default_config()
|
|
||||||
else:
|
|
||||||
return self._create_default_config()
|
|
||||||
|
|
||||||
def _create_default_config(self) -> Config:
|
|
||||||
"""Create a default configuration"""
|
|
||||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.images_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Initial config without images
|
|
||||||
config = Config(
|
|
||||||
docker={
|
|
||||||
"socket": "/var/run/docker.sock",
|
|
||||||
"network": "cubbi-network",
|
|
||||||
},
|
|
||||||
defaults={
|
|
||||||
"image": "goose",
|
|
||||||
"domains": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.save_config(config)
|
|
||||||
return config
|
|
||||||
|
|
||||||
def save_config(self, config: Optional[Config] = None) -> None:
|
|
||||||
"""Save the current config to disk"""
|
|
||||||
if config:
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Use model_dump with mode="json" for proper serialization of enums
|
|
||||||
config_dict = self.config.model_dump(mode="json")
|
|
||||||
|
|
||||||
# Write to file
|
|
||||||
with open(self.config_path, "w") as f:
|
|
||||||
yaml.dump(config_dict, f)
|
|
||||||
|
|
||||||
def get_image(self, name: str) -> Optional[Image]:
|
|
||||||
"""Get an image by name, checking builtin images first, then user-configured ones"""
|
|
||||||
# Check builtin images first (package images take precedence)
|
|
||||||
if name in self.builtin_images:
|
|
||||||
return self.builtin_images[name]
|
|
||||||
# If not found, check user-configured images
|
|
||||||
return self.config.images.get(name)
|
|
||||||
|
|
||||||
def list_images(self) -> Dict[str, Image]:
|
|
||||||
"""List all available images (both builtin and user-configured)"""
|
|
||||||
# Start with user config images
|
|
||||||
all_images = dict(self.config.images)
|
|
||||||
|
|
||||||
# Add builtin images, overriding any user images with the same name
|
|
||||||
# This ensures that package-provided images always take precedence
|
|
||||||
all_images.update(self.builtin_images)
|
|
||||||
|
|
||||||
return all_images
|
|
||||||
|
|
||||||
# Session management has been moved to SessionManager in session.py
|
|
||||||
|
|
||||||
def load_image_from_dir(self, image_dir: Path) -> Optional[Image]:
|
|
||||||
"""Load an image configuration from a directory"""
|
|
||||||
# Check for image config file
|
|
||||||
yaml_path = image_dir / "cubbi_image.yaml"
|
|
||||||
if not yaml_path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(yaml_path, "r") as f:
|
|
||||||
image_data = yaml.safe_load(f)
|
|
||||||
|
|
||||||
# Extract required fields
|
|
||||||
if not all(
|
|
||||||
k in image_data
|
|
||||||
for k in ["name", "description", "version", "maintainer"]
|
|
||||||
):
|
|
||||||
print(f"Image config {yaml_path} missing required fields")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Use Image.model_validate to handle all fields from YAML
|
|
||||||
# This will map all fields according to the Image model structure
|
|
||||||
try:
|
|
||||||
# Ensure image field is set if not in YAML
|
|
||||||
if "image" not in image_data:
|
|
||||||
image_data["image"] = f"monadical/cubbi-{image_data['name']}:latest"
|
|
||||||
|
|
||||||
image = Image.model_validate(image_data)
|
|
||||||
return image
|
|
||||||
except Exception as validation_error:
|
|
||||||
print(
|
|
||||||
f"Error validating image data from {yaml_path}: {validation_error}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading image from {yaml_path}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _load_package_images(self) -> Dict[str, Image]:
|
|
||||||
"""Load all package images from the cubbi/images directory"""
|
|
||||||
images = {}
|
|
||||||
|
|
||||||
if not BUILTIN_IMAGES_DIR.exists():
|
|
||||||
return images
|
|
||||||
|
|
||||||
# Search for cubbi_image.yaml files in each subdirectory
|
|
||||||
for image_dir in BUILTIN_IMAGES_DIR.iterdir():
|
|
||||||
if image_dir.is_dir():
|
|
||||||
image = self.load_image_from_dir(image_dir)
|
|
||||||
if image:
|
|
||||||
images[image.name] = image
|
|
||||||
|
|
||||||
return images
|
|
||||||
|
|
||||||
def get_image_path(self, image_name: str) -> Optional[Path]:
|
|
||||||
"""Get the directory path for an image"""
|
|
||||||
# Check package images first (these are the bundled ones)
|
|
||||||
package_path = BUILTIN_IMAGES_DIR / image_name
|
|
||||||
if package_path.exists() and package_path.is_dir():
|
|
||||||
return package_path
|
|
||||||
|
|
||||||
# Then check user images
|
|
||||||
user_path = self.images_dir / image_name
|
|
||||||
if user_path.exists() and user_path.is_dir():
|
|
||||||
return user_path
|
|
||||||
|
|
||||||
return None
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
LABEL maintainer="team@monadical.com"
|
|
||||||
LABEL description="Aider AI pair programming for Cubbi"
|
|
||||||
|
|
||||||
# Install system dependencies including gosu for user switching
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
gosu \
|
|
||||||
sudo \
|
|
||||||
passwd \
|
|
||||||
bash \
|
|
||||||
curl \
|
|
||||||
bzip2 \
|
|
||||||
iputils-ping \
|
|
||||||
iproute2 \
|
|
||||||
libxcb1 \
|
|
||||||
libdbus-1-3 \
|
|
||||||
nano \
|
|
||||||
tmux \
|
|
||||||
git-core \
|
|
||||||
ripgrep \
|
|
||||||
openssh-client \
|
|
||||||
vim \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install uv (Python package manager)
|
|
||||||
WORKDIR /tmp
|
|
||||||
RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \
|
|
||||||
sh install.sh && \
|
|
||||||
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
|
||||||
mv /root/.local/bin/uvx /usr/local/bin/uvx && \
|
|
||||||
rm install.sh
|
|
||||||
|
|
||||||
# Install Aider using pip in system Python (more compatible with user switching)
|
|
||||||
RUN python -m pip install aider-chat
|
|
||||||
|
|
||||||
# Make sure aider is in PATH
|
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy initialization system
|
|
||||||
COPY cubbi_init.py /cubbi/cubbi_init.py
|
|
||||||
COPY aider_plugin.py /cubbi/aider_plugin.py
|
|
||||||
COPY cubbi_image.yaml /cubbi/cubbi_image.yaml
|
|
||||||
COPY init-status.sh /cubbi/init-status.sh
|
|
||||||
|
|
||||||
# Make scripts executable
|
|
||||||
RUN chmod +x /cubbi/cubbi_init.py /cubbi/init-status.sh
|
|
||||||
|
|
||||||
# Add aider to PATH in bashrc and init status check
|
|
||||||
RUN echo 'PATH="/root/.local/bin:$PATH"' >> /etc/bash.bashrc
|
|
||||||
RUN echo '[ -x /cubbi/init-status.sh ] && /cubbi/init-status.sh' >> /etc/bash.bashrc
|
|
||||||
|
|
||||||
# Set up environment
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ENV UV_LINK_MODE=copy
|
|
||||||
|
|
||||||
# Pre-install the cubbi_init
|
|
||||||
RUN /cubbi/cubbi_init.py --help
|
|
||||||
|
|
||||||
# Set WORKDIR to /app
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/cubbi/cubbi_init.py"]
|
|
||||||
CMD ["tail", "-f", "/dev/null"]
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
# Aider for Cubbi
|
|
||||||
|
|
||||||
This image provides Aider (AI pair programming) in a Cubbi container environment.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Aider is an AI pair programming tool that works in your terminal. This Cubbi image integrates Aider with secure API key management, persistent configuration, and support for multiple LLM providers.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Multiple LLM Support**: Works with OpenAI, Anthropic, DeepSeek, Gemini, OpenRouter, and more
|
|
||||||
- **Secure Authentication**: API key management through Cubbi's secure environment system
|
|
||||||
- **Persistent Configuration**: Settings and history preserved across container restarts
|
|
||||||
- **Git Integration**: Automatic commits and git awareness
|
|
||||||
- **Multi-Language Support**: Works with 100+ programming languages
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Set up API Key
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For OpenAI (GPT models)
|
|
||||||
uv run -m cubbi.cli config set services.openai.api_key "your-openai-key"
|
|
||||||
|
|
||||||
# For Anthropic (Claude models)
|
|
||||||
uv run -m cubbi.cli config set services.anthropic.api_key "your-anthropic-key"
|
|
||||||
|
|
||||||
# For DeepSeek (recommended for cost-effectiveness)
|
|
||||||
uv run -m cubbi.cli config set services.deepseek.api_key "your-deepseek-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Run Aider Environment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start Aider container with your project
|
|
||||||
uv run -m cubbi.cli session create --image aider /path/to/your/project
|
|
||||||
|
|
||||||
# Or without a project
|
|
||||||
uv run -m cubbi.cli session create --image aider
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Use Aider
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Basic usage
|
|
||||||
aider
|
|
||||||
|
|
||||||
# With specific model
|
|
||||||
aider --model sonnet
|
|
||||||
|
|
||||||
# With specific files
|
|
||||||
aider main.py utils.py
|
|
||||||
|
|
||||||
# One-shot request
|
|
||||||
aider --message "Add error handling to the login function"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Supported API Keys
|
|
||||||
|
|
||||||
- `OPENAI_API_KEY`: OpenAI GPT models (GPT-4, GPT-4o, etc.)
|
|
||||||
- `ANTHROPIC_API_KEY`: Anthropic Claude models (Sonnet, Haiku, etc.)
|
|
||||||
- `DEEPSEEK_API_KEY`: DeepSeek models (cost-effective option)
|
|
||||||
- `GEMINI_API_KEY`: Google Gemini models
|
|
||||||
- `OPENROUTER_API_KEY`: OpenRouter (access to many models)
|
|
||||||
|
|
||||||
### Additional Configuration
|
|
||||||
|
|
||||||
- `AIDER_MODEL`: Default model to use (e.g., "sonnet", "o3-mini", "deepseek")
|
|
||||||
- `AIDER_AUTO_COMMITS`: Enable automatic git commits (default: true)
|
|
||||||
- `AIDER_DARK_MODE`: Enable dark mode interface (default: false)
|
|
||||||
- `AIDER_API_KEYS`: Additional API keys in format "provider1=key1,provider2=key2"
|
|
||||||
|
|
||||||
### Network Configuration
|
|
||||||
|
|
||||||
- `HTTP_PROXY`: HTTP proxy server URL
|
|
||||||
- `HTTPS_PROXY`: HTTPS proxy server URL
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Basic AI Pair Programming
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start Aider with your project
|
|
||||||
uv run -m cubbi.cli session create --image aider /path/to/project
|
|
||||||
|
|
||||||
# Inside the container:
|
|
||||||
aider # Start interactive session
|
|
||||||
aider main.py # Work on specific file
|
|
||||||
aider --message "Add tests" # One-shot request
|
|
||||||
```
|
|
||||||
|
|
||||||
### Model Selection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use Claude Sonnet
|
|
||||||
aider --model sonnet
|
|
||||||
|
|
||||||
# Use GPT-4o
|
|
||||||
aider --model gpt-4o
|
|
||||||
|
|
||||||
# Use DeepSeek (cost-effective)
|
|
||||||
aider --model deepseek
|
|
||||||
|
|
||||||
# Use OpenRouter
|
|
||||||
aider --model openrouter/anthropic/claude-3.5-sonnet
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Features
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Work with multiple files
|
|
||||||
aider src/main.py tests/test_main.py
|
|
||||||
|
|
||||||
# Auto-commit changes
|
|
||||||
aider --auto-commits
|
|
||||||
|
|
||||||
# Read-only mode (won't edit files)
|
|
||||||
aider --read
|
|
||||||
|
|
||||||
# Apply a specific change
|
|
||||||
aider --message "Refactor the database connection code to use connection pooling"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enterprise/Proxy Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# With proxy
|
|
||||||
uv run -m cubbi.cli session create --image aider \
|
|
||||||
--env HTTPS_PROXY="https://proxy.company.com:8080" \
|
|
||||||
/path/to/project
|
|
||||||
|
|
||||||
# With custom model
|
|
||||||
uv run -m cubbi.cli session create --image aider \
|
|
||||||
--env AIDER_MODEL="sonnet" \
|
|
||||||
/path/to/project
|
|
||||||
```
|
|
||||||
|
|
||||||
## Persistent Configuration
|
|
||||||
|
|
||||||
The following directories are automatically persisted:
|
|
||||||
|
|
||||||
- `~/.aider/`: Aider configuration and chat history
|
|
||||||
- `~/.cache/aider/`: Model cache and temporary files
|
|
||||||
|
|
||||||
Configuration files are maintained across container restarts, ensuring your preferences and chat history are preserved.
|
|
||||||
|
|
||||||
## Model Recommendations
|
|
||||||
|
|
||||||
### Best Overall Performance
|
|
||||||
- **Claude 3.5 Sonnet**: Excellent code understanding and generation
|
|
||||||
- **OpenAI GPT-4o**: Strong performance across languages
|
|
||||||
- **Gemini 2.5 Pro**: Good balance of quality and speed
|
|
||||||
|
|
||||||
### Cost-Effective Options
|
|
||||||
- **DeepSeek V3**: Very cost-effective, good quality
|
|
||||||
- **OpenRouter**: Access to multiple models with competitive pricing
|
|
||||||
|
|
||||||
### Free Options
|
|
||||||
- **Gemini 2.5 Pro Exp**: Free tier available
|
|
||||||
- **OpenRouter**: Some free models available
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
cubbi/images/aider/
|
|
||||||
├── Dockerfile # Container image definition
|
|
||||||
├── cubbi_image.yaml # Cubbi image configuration
|
|
||||||
├── aider_plugin.py # Authentication and setup plugin
|
|
||||||
└── README.md # This documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication Flow
|
|
||||||
|
|
||||||
1. **Environment Variables**: API keys passed from Cubbi configuration
|
|
||||||
2. **Plugin Setup**: `aider_plugin.py` creates environment configuration
|
|
||||||
3. **Environment File**: Creates `~/.aider/.env` with API keys
|
|
||||||
4. **Ready**: Aider is ready for use with configured authentication
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**No API Key Found**
|
|
||||||
```
|
|
||||||
ℹ️ No API keys found - Aider will run without pre-configuration
|
|
||||||
```
|
|
||||||
**Solution**: Set API key in Cubbi configuration:
|
|
||||||
```bash
|
|
||||||
uv run -m cubbi.cli config set services.openai.api_key "your-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Model Not Available**
|
|
||||||
```
|
|
||||||
Error: Model 'xyz' not found
|
|
||||||
```
|
|
||||||
**Solution**: Check available models for your provider:
|
|
||||||
```bash
|
|
||||||
aider --models # List available models
|
|
||||||
```
|
|
||||||
|
|
||||||
**Git Issues**
|
|
||||||
```
|
|
||||||
Git repository not found
|
|
||||||
```
|
|
||||||
**Solution**: Initialize git in your project or mount a git repository:
|
|
||||||
```bash
|
|
||||||
git init
|
|
||||||
# or
|
|
||||||
uv run -m cubbi.cli session create --image aider /path/to/git/project
|
|
||||||
```
|
|
||||||
|
|
||||||
**Network/Proxy Issues**
|
|
||||||
```
|
|
||||||
Connection timeout or proxy errors
|
|
||||||
```
|
|
||||||
**Solution**: Configure proxy settings:
|
|
||||||
```bash
|
|
||||||
uv run -m cubbi.cli config set network.https_proxy "your-proxy-url"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check Aider version
|
|
||||||
aider --version
|
|
||||||
|
|
||||||
# List available models
|
|
||||||
aider --models
|
|
||||||
|
|
||||||
# Check configuration
|
|
||||||
cat ~/.aider/.env
|
|
||||||
|
|
||||||
# Verbose output
|
|
||||||
aider --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- **API Keys**: Stored securely with 0o600 permissions
|
|
||||||
- **Environment**: Isolated container environment
|
|
||||||
- **Git Integration**: Respects .gitignore and git configurations
|
|
||||||
- **Code Safety**: Always review changes before accepting
|
|
||||||
|
|
||||||
## Advanced Configuration
|
|
||||||
|
|
||||||
### Custom Model Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use with custom API endpoint
|
|
||||||
uv run -m cubbi.cli session create --image aider \
|
|
||||||
--env OPENAI_API_BASE="https://api.custom-provider.com/v1" \
|
|
||||||
--env OPENAI_API_KEY="your-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple API Keys
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Configure multiple providers
|
|
||||||
uv run -m cubbi.cli session create --image aider \
|
|
||||||
--env OPENAI_API_KEY="openai-key" \
|
|
||||||
--env ANTHROPIC_API_KEY="anthropic-key" \
|
|
||||||
--env AIDER_API_KEYS="provider1=key1,provider2=key2"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues related to:
|
|
||||||
- **Cubbi Integration**: Check Cubbi documentation or open an issue
|
|
||||||
- **Aider Functionality**: Visit [Aider documentation](https://aider.chat/)
|
|
||||||
- **Model Configuration**: Check [LLM documentation](https://aider.chat/docs/llms.html)
|
|
||||||
- **API Keys**: Visit provider documentation (OpenAI, Anthropic, etc.)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This image configuration is provided under the same license as the Cubbi project. Aider is licensed separately under Apache 2.0.
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Aider Plugin for Cubbi
|
|
||||||
Handles authentication setup and configuration for Aider AI pair programming
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from cubbi_init import ToolPlugin
|
|
||||||
|
|
||||||
|
|
||||||
class AiderPlugin(ToolPlugin):
|
|
||||||
"""Plugin for setting up Aider authentication and configuration"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tool_name(self) -> str:
|
|
||||||
return "aider"
|
|
||||||
|
|
||||||
def _get_user_ids(self) -> tuple[int, int]:
|
|
||||||
"""Get the cubbi user and group IDs from environment"""
|
|
||||||
user_id = int(os.environ.get("CUBBI_USER_ID", "1000"))
|
|
||||||
group_id = int(os.environ.get("CUBBI_GROUP_ID", "1000"))
|
|
||||||
return user_id, group_id
|
|
||||||
|
|
||||||
def _set_ownership(self, path: Path) -> None:
|
|
||||||
"""Set ownership of a path to the cubbi user"""
|
|
||||||
user_id, group_id = self._get_user_ids()
|
|
||||||
try:
|
|
||||||
os.chown(path, user_id, group_id)
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(f"Failed to set ownership for {path}: {e}", "WARNING")
|
|
||||||
|
|
||||||
def _get_aider_config_dir(self) -> Path:
|
|
||||||
"""Get the Aider configuration directory"""
|
|
||||||
return Path("/home/cubbi/.aider")
|
|
||||||
|
|
||||||
def _get_aider_cache_dir(self) -> Path:
|
|
||||||
"""Get the Aider cache directory"""
|
|
||||||
return Path("/home/cubbi/.cache/aider")
|
|
||||||
|
|
||||||
def _ensure_aider_dirs(self) -> tuple[Path, Path]:
|
|
||||||
"""Ensure Aider directories exist with correct ownership"""
|
|
||||||
config_dir = self._get_aider_config_dir()
|
|
||||||
cache_dir = self._get_aider_cache_dir()
|
|
||||||
|
|
||||||
# Create directories
|
|
||||||
for directory in [config_dir, cache_dir]:
|
|
||||||
try:
|
|
||||||
directory.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
||||||
self._set_ownership(directory)
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to create Aider directory {directory}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
|
|
||||||
return config_dir, cache_dir
|
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""Initialize Aider configuration"""
|
|
||||||
self.status.log("Setting up Aider configuration...")
|
|
||||||
|
|
||||||
# Ensure Aider directories exist
|
|
||||||
config_dir, cache_dir = self._ensure_aider_dirs()
|
|
||||||
|
|
||||||
# Set up environment variables for the session
|
|
||||||
env_vars = self._create_environment_config()
|
|
||||||
|
|
||||||
# Create .env file if we have API keys
|
|
||||||
if env_vars:
|
|
||||||
env_file = config_dir / ".env"
|
|
||||||
success = self._write_env_file(env_file, env_vars)
|
|
||||||
if success:
|
|
||||||
self.status.log("✅ Aider environment configured successfully")
|
|
||||||
else:
|
|
||||||
self.status.log("⚠️ Failed to write Aider environment file", "WARNING")
|
|
||||||
else:
|
|
||||||
self.status.log(
|
|
||||||
"ℹ️ No API keys found - Aider will run without pre-configuration", "INFO"
|
|
||||||
)
|
|
||||||
self.status.log(
|
|
||||||
" You can configure API keys later using environment variables",
|
|
||||||
"INFO",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Always return True to allow container to start
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _create_environment_config(self) -> Dict[str, str]:
|
|
||||||
"""Create environment variable configuration for Aider"""
|
|
||||||
env_vars = {}
|
|
||||||
|
|
||||||
# Map environment variables to Aider configuration
|
|
||||||
api_key_mappings = {
|
|
||||||
"OPENAI_API_KEY": "OPENAI_API_KEY",
|
|
||||||
"ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY",
|
|
||||||
"DEEPSEEK_API_KEY": "DEEPSEEK_API_KEY",
|
|
||||||
"GEMINI_API_KEY": "GEMINI_API_KEY",
|
|
||||||
"OPENROUTER_API_KEY": "OPENROUTER_API_KEY",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check for OpenAI API base URL
|
|
||||||
openai_url = os.environ.get("OPENAI_URL")
|
|
||||||
if openai_url:
|
|
||||||
env_vars["OPENAI_API_BASE"] = openai_url
|
|
||||||
self.status.log(f"Set OpenAI API base URL to {openai_url}")
|
|
||||||
|
|
||||||
# Check for standard API keys
|
|
||||||
for env_var, aider_var in api_key_mappings.items():
|
|
||||||
value = os.environ.get(env_var)
|
|
||||||
if value:
|
|
||||||
env_vars[aider_var] = value
|
|
||||||
provider = env_var.replace("_API_KEY", "").lower()
|
|
||||||
self.status.log(f"Added {provider} API key")
|
|
||||||
|
|
||||||
# Handle additional API keys from AIDER_API_KEYS
|
|
||||||
additional_keys = os.environ.get("AIDER_API_KEYS")
|
|
||||||
if additional_keys:
|
|
||||||
try:
|
|
||||||
# Parse format: "provider1=key1,provider2=key2"
|
|
||||||
for pair in additional_keys.split(","):
|
|
||||||
if "=" in pair:
|
|
||||||
provider, key = pair.strip().split("=", 1)
|
|
||||||
env_var_name = f"{provider.upper()}_API_KEY"
|
|
||||||
env_vars[env_var_name] = key
|
|
||||||
self.status.log(f"Added {provider} API key from AIDER_API_KEYS")
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to parse AIDER_API_KEYS: {e}", "WARNING")
|
|
||||||
|
|
||||||
# Add model configuration
|
|
||||||
model = os.environ.get("AIDER_MODEL")
|
|
||||||
if model:
|
|
||||||
env_vars["AIDER_MODEL"] = model
|
|
||||||
self.status.log(f"Set default model to {model}")
|
|
||||||
|
|
||||||
# Add git configuration
|
|
||||||
auto_commits = os.environ.get("AIDER_AUTO_COMMITS", "true")
|
|
||||||
if auto_commits.lower() in ["true", "false"]:
|
|
||||||
env_vars["AIDER_AUTO_COMMITS"] = auto_commits
|
|
||||||
|
|
||||||
# Add dark mode setting
|
|
||||||
dark_mode = os.environ.get("AIDER_DARK_MODE", "false")
|
|
||||||
if dark_mode.lower() in ["true", "false"]:
|
|
||||||
env_vars["AIDER_DARK_MODE"] = dark_mode
|
|
||||||
|
|
||||||
# Add proxy settings
|
|
||||||
for proxy_var in ["HTTP_PROXY", "HTTPS_PROXY"]:
|
|
||||||
value = os.environ.get(proxy_var)
|
|
||||||
if value:
|
|
||||||
env_vars[proxy_var] = value
|
|
||||||
self.status.log(f"Added proxy configuration: {proxy_var}")
|
|
||||||
|
|
||||||
return env_vars
|
|
||||||
|
|
||||||
def _write_env_file(self, env_file: Path, env_vars: Dict[str, str]) -> bool:
|
|
||||||
"""Write environment variables to .env file"""
|
|
||||||
try:
|
|
||||||
content = "\n".join(f"{key}={value}" for key, value in env_vars.items())
|
|
||||||
|
|
||||||
with open(env_file, "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
f.write("\n")
|
|
||||||
|
|
||||||
# Set ownership and secure file permissions (read/write for owner only)
|
|
||||||
self._set_ownership(env_file)
|
|
||||||
os.chmod(env_file, stat.S_IRUSR | stat.S_IWUSR)
|
|
||||||
|
|
||||||
self.status.log(f"Created Aider environment file at {env_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to write Aider environment file: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def setup_tool_configuration(self) -> bool:
|
|
||||||
"""Set up Aider configuration - called by base class"""
|
|
||||||
# Additional tool configuration can be added here if needed
|
|
||||||
return True
|
|
||||||
|
|
||||||
def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool:
|
|
||||||
"""Integrate Aider with available MCP servers if applicable"""
|
|
||||||
if mcp_config["count"] == 0:
|
|
||||||
self.status.log("No MCP servers to integrate")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Aider doesn't have native MCP support like Claude Code,
|
|
||||||
# but we could potentially add custom integrations here
|
|
||||||
self.status.log(
|
|
||||||
f"Found {mcp_config['count']} MCP server(s) - no direct integration available"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
name: aider
|
|
||||||
description: Aider AI pair programming environment
|
|
||||||
version: 1.0.0
|
|
||||||
maintainer: team@monadical.com
|
|
||||||
image: monadical/cubbi-aider:latest
|
|
||||||
|
|
||||||
init:
|
|
||||||
pre_command: /cubbi-init.sh
|
|
||||||
command: /entrypoint.sh
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# OpenAI Configuration
|
|
||||||
- name: OPENAI_API_KEY
|
|
||||||
description: OpenAI API key for GPT models
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# Anthropic Configuration
|
|
||||||
- name: ANTHROPIC_API_KEY
|
|
||||||
description: Anthropic API key for Claude models
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# DeepSeek Configuration
|
|
||||||
- name: DEEPSEEK_API_KEY
|
|
||||||
description: DeepSeek API key for DeepSeek models
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# Gemini Configuration
|
|
||||||
- name: GEMINI_API_KEY
|
|
||||||
description: Google Gemini API key
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# OpenRouter Configuration
|
|
||||||
- name: OPENROUTER_API_KEY
|
|
||||||
description: OpenRouter API key for various models
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# Generic provider API keys
|
|
||||||
- name: AIDER_API_KEYS
|
|
||||||
description: Additional API keys in format "provider1=key1,provider2=key2"
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# Model Configuration
|
|
||||||
- name: AIDER_MODEL
|
|
||||||
description: Default model to use (e.g., sonnet, o3-mini, deepseek)
|
|
||||||
required: false
|
|
||||||
|
|
||||||
# Git Configuration
|
|
||||||
- name: AIDER_AUTO_COMMITS
|
|
||||||
description: Enable automatic commits (true/false)
|
|
||||||
required: false
|
|
||||||
default: "true"
|
|
||||||
|
|
||||||
- name: AIDER_DARK_MODE
|
|
||||||
description: Enable dark mode (true/false)
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
# Proxy Configuration
|
|
||||||
- name: HTTP_PROXY
|
|
||||||
description: HTTP proxy server URL
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- name: HTTPS_PROXY
|
|
||||||
description: HTTPS proxy server URL
|
|
||||||
required: false
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- mountPath: /app
|
|
||||||
description: Application directory
|
|
||||||
|
|
||||||
persistent_configs:
|
|
||||||
- source: "/home/cubbi/.aider"
|
|
||||||
target: "/cubbi-config/aider-settings"
|
|
||||||
type: "directory"
|
|
||||||
description: "Aider configuration and history"
|
|
||||||
|
|
||||||
- source: "/home/cubbi/.cache/aider"
|
|
||||||
target: "/cubbi-config/aider-cache"
|
|
||||||
type: "directory"
|
|
||||||
description: "Aider cache directory"
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Comprehensive test script for Aider Cubbi image
|
|
||||||
Tests Docker image build, API key configuration, and Cubbi CLI integration
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(cmd, description="", check=True):
|
|
||||||
"""Run a shell command and return result"""
|
|
||||||
print(f"\n🔍 {description}")
|
|
||||||
print(f"Running: {cmd}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd, shell=True, capture_output=True, text=True, check=check
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.stdout:
|
|
||||||
print("STDOUT:")
|
|
||||||
print(result.stdout)
|
|
||||||
|
|
||||||
if result.stderr:
|
|
||||||
print("STDERR:")
|
|
||||||
print(result.stderr)
|
|
||||||
|
|
||||||
return result
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"❌ Command failed with exit code {e.returncode}")
|
|
||||||
if e.stdout:
|
|
||||||
print("STDOUT:")
|
|
||||||
print(e.stdout)
|
|
||||||
if e.stderr:
|
|
||||||
print("STDERR:")
|
|
||||||
print(e.stderr)
|
|
||||||
if check:
|
|
||||||
raise
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
def test_docker_image_exists():
|
|
||||||
"""Test if the Aider Docker image exists"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("🧪 Testing Docker Image Existence")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
result = run_command(
|
|
||||||
"docker images monadical/cubbi-aider:latest --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}'",
|
|
||||||
"Checking if Aider Docker image exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
if "monadical/cubbi-aider" in result.stdout:
|
|
||||||
print("✅ Aider Docker image exists")
|
|
||||||
else:
|
|
||||||
print("❌ Aider Docker image not found")
|
|
||||||
assert False, "Aider Docker image not found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_aider_version():
|
|
||||||
"""Test basic Aider functionality in container"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("🧪 Testing Aider Version")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
result = run_command(
|
|
||||||
"docker run --rm monadical/cubbi-aider:latest bash -c 'aider --version'",
|
|
||||||
"Testing Aider version command",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
"aider" in result.stdout and result.returncode == 0
|
|
||||||
), "Aider version command failed"
|
|
||||||
print("✅ Aider version command works")
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_key_configuration():
|
|
||||||
"""Test API key configuration and environment setup"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("🧪 Testing API Key Configuration")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test with multiple API keys
|
|
||||||
test_keys = {
|
|
||||||
"OPENAI_API_KEY": "test-openai-key",
|
|
||||||
"ANTHROPIC_API_KEY": "test-anthropic-key",
|
|
||||||
"DEEPSEEK_API_KEY": "test-deepseek-key",
|
|
||||||
"GEMINI_API_KEY": "test-gemini-key",
|
|
||||||
"OPENROUTER_API_KEY": "test-openrouter-key",
|
|
||||||
}
|
|
||||||
|
|
||||||
env_flags = " ".join([f'-e {key}="{value}"' for key, value in test_keys.items()])
|
|
||||||
|
|
||||||
result = run_command(
|
|
||||||
f"docker run --rm {env_flags} monadical/cubbi-aider:latest bash -c 'cat ~/.aider/.env'",
|
|
||||||
"Testing API key configuration in .env file",
|
|
||||||
)
|
|
||||||
|
|
||||||
success = True
|
|
||||||
for key, value in test_keys.items():
|
|
||||||
if f"{key}={value}" not in result.stdout:
|
|
||||||
print(f"❌ {key} not found in .env file")
|
|
||||||
success = False
|
|
||||||
else:
|
|
||||||
print(f"✅ {key} configured correctly")
|
|
||||||
|
|
||||||
# Test default configuration values
|
|
||||||
if "AIDER_AUTO_COMMITS=true" in result.stdout:
|
|
||||||
print("✅ Default AIDER_AUTO_COMMITS configured")
|
|
||||||
else:
|
|
||||||
print("❌ Default AIDER_AUTO_COMMITS not found")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
if "AIDER_DARK_MODE=false" in result.stdout:
|
|
||||||
print("✅ Default AIDER_DARK_MODE configured")
|
|
||||||
else:
|
|
||||||
print("❌ Default AIDER_DARK_MODE not found")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
assert success, "API key configuration test failed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cubbi_cli_integration():
|
|
||||||
"""Test Cubbi CLI integration"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("🧪 Testing Cubbi CLI Integration")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test image listing
|
|
||||||
result = run_command(
|
|
||||||
"uv run -m cubbi.cli image list | grep aider",
|
|
||||||
"Testing Cubbi CLI can see Aider image",
|
|
||||||
)
|
|
||||||
|
|
||||||
if "aider" in result.stdout and "Aider AI pair" in result.stdout:
|
|
||||||
print("✅ Cubbi CLI can list Aider image")
|
|
||||||
else:
|
|
||||||
print("❌ Cubbi CLI cannot see Aider image")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Test session creation with test command
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
|
||||||
test_env = {
|
|
||||||
"OPENAI_API_KEY": "test-session-key",
|
|
||||||
"ANTHROPIC_API_KEY": "test-anthropic-session-key",
|
|
||||||
}
|
|
||||||
|
|
||||||
env_vars = " ".join([f"{k}={v}" for k, v in test_env.items()])
|
|
||||||
|
|
||||||
result = run_command(
|
|
||||||
f"{env_vars} uv run -m cubbi.cli session create --image aider {temp_dir} --no-shell --run \"aider --version && echo 'Cubbi CLI test successful'\"",
|
|
||||||
"Testing Cubbi CLI session creation with Aider",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
result.returncode == 0
|
|
||||||
and re.search(r"aider \d+\.\d+\.\d+", result.stdout)
|
|
||||||
and "Cubbi CLI test successful" in result.stdout
|
|
||||||
), "Cubbi CLI session creation failed"
|
|
||||||
print("✅ Cubbi CLI session creation works")
|
|
||||||
|
|
||||||
|
|
||||||
def test_persistent_configuration():
|
|
||||||
"""Test persistent configuration directories"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("🧪 Testing Persistent Configuration")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test that persistent directories are created
|
|
||||||
result = run_command(
|
|
||||||
"docker run --rm -e OPENAI_API_KEY='test-key' monadical/cubbi-aider:latest bash -c 'ls -la /home/cubbi/.aider/ && ls -la /home/cubbi/.cache/'",
|
|
||||||
"Testing persistent configuration directories",
|
|
||||||
)
|
|
||||||
|
|
||||||
success = True
|
|
||||||
|
|
||||||
if ".env" in result.stdout:
|
|
||||||
print("✅ .env file created in ~/.aider/")
|
|
||||||
else:
|
|
||||||
print("❌ .env file not found in ~/.aider/")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
if "aider" in result.stdout:
|
|
||||||
print("✅ ~/.cache/aider directory exists")
|
|
||||||
else:
|
|
||||||
print("❌ ~/.cache/aider directory not found")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
assert success, "API key configuration test failed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_functionality():
|
|
||||||
"""Test the Aider plugin functionality"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("🧪 Testing Plugin Functionality")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test plugin without API keys (should still work)
|
|
||||||
result = run_command(
|
|
||||||
"docker run --rm monadical/cubbi-aider:latest bash -c 'echo \"Plugin test without API keys\"'",
|
|
||||||
"Testing plugin functionality without API keys",
|
|
||||||
)
|
|
||||||
|
|
||||||
if "No API keys found - Aider will run without pre-configuration" in result.stdout:
|
|
||||||
print("✅ Plugin handles missing API keys gracefully")
|
|
||||||
else:
|
|
||||||
# This might be in stderr or initialization might have changed
|
|
||||||
print("ℹ️ Plugin API key handling test - check output above")
|
|
||||||
|
|
||||||
# Test plugin with API keys
|
|
||||||
result = run_command(
|
|
||||||
"docker run --rm -e OPENAI_API_KEY='test-plugin-key' monadical/cubbi-aider:latest bash -c 'echo \"Plugin test with API keys\"'",
|
|
||||||
"Testing plugin functionality with API keys",
|
|
||||||
)
|
|
||||||
|
|
||||||
if "Aider environment configured successfully" in result.stdout:
|
|
||||||
print("✅ Plugin configures environment successfully")
|
|
||||||
else:
|
|
||||||
print("❌ Plugin environment configuration failed")
|
|
||||||
assert False, "Plugin environment configuration failed"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run all tests"""
|
|
||||||
print("🚀 Starting Aider Cubbi Image Tests")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
tests = [
|
|
||||||
("Docker Image Exists", test_docker_image_exists),
|
|
||||||
("Aider Version", test_aider_version),
|
|
||||||
("API Key Configuration", test_api_key_configuration),
|
|
||||||
("Persistent Configuration", test_persistent_configuration),
|
|
||||||
("Plugin Functionality", test_plugin_functionality),
|
|
||||||
("Cubbi CLI Integration", test_cubbi_cli_integration),
|
|
||||||
]
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
for test_name, test_func in tests:
|
|
||||||
try:
|
|
||||||
test_func()
|
|
||||||
results[test_name] = True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Test '{test_name}' failed with exception: {e}")
|
|
||||||
results[test_name] = False
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("📊 TEST SUMMARY")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
total_tests = len(tests)
|
|
||||||
passed_tests = sum(1 for result in results.values() if result)
|
|
||||||
failed_tests = total_tests - passed_tests
|
|
||||||
|
|
||||||
for test_name, result in results.items():
|
|
||||||
status = "✅ PASS" if result else "❌ FAIL"
|
|
||||||
print(f"{status} {test_name}")
|
|
||||||
|
|
||||||
print(f"\nTotal: {total_tests} | Passed: {passed_tests} | Failed: {failed_tests}")
|
|
||||||
|
|
||||||
if failed_tests == 0:
|
|
||||||
print("\n🎉 All tests passed! Aider image is ready for use.")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print(f"\n⚠️ {failed_tests} test(s) failed. Please check the output above.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""
|
|
||||||
Base image implementation for MAI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
from ..models import Image
|
|
||||||
|
|
||||||
|
|
||||||
class ImageManager:
|
|
||||||
"""Manager for MAI images"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_default_images() -> Dict[str, Image]:
|
|
||||||
"""Get the default built-in images"""
|
|
||||||
from ..config import DEFAULT_IMAGES
|
|
||||||
|
|
||||||
return DEFAULT_IMAGES
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_image_metadata(image_name: str) -> Optional[Dict]:
|
|
||||||
"""Get metadata for a specific image"""
|
|
||||||
from ..config import DEFAULT_IMAGES
|
|
||||||
|
|
||||||
if image_name in DEFAULT_IMAGES:
|
|
||||||
return DEFAULT_IMAGES[image_name].model_dump()
|
|
||||||
|
|
||||||
return None
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
LABEL maintainer="team@monadical.com"
|
|
||||||
LABEL description="Claude Code for Cubbi"
|
|
||||||
|
|
||||||
# Install system dependencies including gosu for user switching
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
gosu \
|
|
||||||
sudo \
|
|
||||||
passwd \
|
|
||||||
bash \
|
|
||||||
curl \
|
|
||||||
bzip2 \
|
|
||||||
iputils-ping \
|
|
||||||
iproute2 \
|
|
||||||
libxcb1 \
|
|
||||||
libdbus-1-3 \
|
|
||||||
nano \
|
|
||||||
tmux \
|
|
||||||
git-core \
|
|
||||||
ripgrep \
|
|
||||||
openssh-client \
|
|
||||||
vim \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install uv (Python package manager)
|
|
||||||
WORKDIR /tmp
|
|
||||||
RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \
|
|
||||||
sh install.sh && \
|
|
||||||
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
|
||||||
mv /root/.local/bin/uvx /usr/local/bin/uvx && \
|
|
||||||
rm install.sh
|
|
||||||
|
|
||||||
# Install Node.js (for Claude Code NPM package)
|
|
||||||
ARG NODE_VERSION=v22.16.0
|
|
||||||
RUN mkdir -p /opt/node && \
|
|
||||||
ARCH=$(uname -m) && \
|
|
||||||
if [ "$ARCH" = "x86_64" ]; then \
|
|
||||||
NODE_ARCH=linux-x64; \
|
|
||||||
elif [ "$ARCH" = "aarch64" ]; then \
|
|
||||||
NODE_ARCH=linux-arm64; \
|
|
||||||
else \
|
|
||||||
echo "Unsupported architecture"; exit 1; \
|
|
||||||
fi && \
|
|
||||||
curl -fsSL https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-$NODE_ARCH.tar.gz -o node.tar.gz && \
|
|
||||||
tar -xf node.tar.gz -C /opt/node --strip-components=1 && \
|
|
||||||
rm node.tar.gz
|
|
||||||
|
|
||||||
ENV PATH="/opt/node/bin:$PATH"
|
|
||||||
|
|
||||||
# Install Claude Code globally
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy initialization system
|
|
||||||
COPY cubbi_init.py /cubbi/cubbi_init.py
|
|
||||||
COPY claudecode_plugin.py /cubbi/claudecode_plugin.py
|
|
||||||
COPY cubbi_image.yaml /cubbi/cubbi_image.yaml
|
|
||||||
COPY init-status.sh /cubbi/init-status.sh
|
|
||||||
|
|
||||||
# Make scripts executable
|
|
||||||
RUN chmod +x /cubbi/cubbi_init.py /cubbi/init-status.sh
|
|
||||||
|
|
||||||
# Add Node.js to PATH in bashrc and init status check
|
|
||||||
RUN echo 'PATH="/opt/node/bin:$PATH"' >> /etc/bash.bashrc
|
|
||||||
RUN echo '[ -x /cubbi/init-status.sh ] && /cubbi/init-status.sh' >> /etc/bash.bashrc
|
|
||||||
|
|
||||||
# Set up environment
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ENV UV_LINK_MODE=copy
|
|
||||||
|
|
||||||
# Pre-install the cubbi_init
|
|
||||||
RUN /cubbi/cubbi_init.py --help
|
|
||||||
|
|
||||||
# Set WORKDIR to /app
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/cubbi/cubbi_init.py"]
|
|
||||||
CMD ["tail", "-f", "/dev/null"]
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# Claude Code for Cubbi
|
|
||||||
|
|
||||||
This image provides Claude Code (Anthropic's official CLI for Claude) in a Cubbi container environment.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Claude Code is an interactive CLI tool that helps with software engineering tasks. This Cubbi image integrates Claude Code with secure API key management, persistent configuration, and enterprise features.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Claude Code CLI**: Full access to Claude's coding capabilities
|
|
||||||
- **Secure Authentication**: API key management through Cubbi's secure environment system
|
|
||||||
- **Persistent Configuration**: Settings and cache preserved across container restarts
|
|
||||||
- **Enterprise Support**: Bedrock and Vertex AI integration
|
|
||||||
- **Network Support**: Proxy configuration for corporate environments
|
|
||||||
- **Tool Permissions**: Pre-configured permissions for all Claude Code tools
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Set up API Key
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Set your Anthropic API key in Cubbi configuration
|
|
||||||
cubbi config set services.anthropic.api_key "your-api-key-here"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Run Claude Code Environment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start Claude Code container
|
|
||||||
cubbi run claudecode
|
|
||||||
|
|
||||||
# Execute Claude Code commands
|
|
||||||
cubbi exec claudecode "claude 'help me write a Python function'"
|
|
||||||
|
|
||||||
# Start interactive session
|
|
||||||
cubbi exec claudecode "claude"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Required Environment Variables
|
|
||||||
|
|
||||||
- `ANTHROPIC_API_KEY`: Your Anthropic API key (required)
|
|
||||||
|
|
||||||
### Optional Environment Variables
|
|
||||||
|
|
||||||
- `ANTHROPIC_AUTH_TOKEN`: Custom authorization token for enterprise deployments
|
|
||||||
- `ANTHROPIC_CUSTOM_HEADERS`: Additional HTTP headers (JSON format)
|
|
||||||
- `CLAUDE_CODE_USE_BEDROCK`: Set to "true" to use Amazon Bedrock
|
|
||||||
- `CLAUDE_CODE_USE_VERTEX`: Set to "true" to use Google Vertex AI
|
|
||||||
- `HTTP_PROXY`: HTTP proxy server URL
|
|
||||||
- `HTTPS_PROXY`: HTTPS proxy server URL
|
|
||||||
- `DISABLE_TELEMETRY`: Set to "true" to disable telemetry
|
|
||||||
|
|
||||||
### Advanced Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enterprise deployment with Bedrock
|
|
||||||
cubbi config set environment.claude_code_use_bedrock true
|
|
||||||
cubbi run claudecode
|
|
||||||
|
|
||||||
# With custom proxy
|
|
||||||
cubbi config set network.https_proxy "https://proxy.company.com:8080"
|
|
||||||
cubbi run claudecode
|
|
||||||
|
|
||||||
# Disable telemetry
|
|
||||||
cubbi config set environment.disable_telemetry true
|
|
||||||
cubbi run claudecode
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get help
|
|
||||||
cubbi exec claudecode "claude --help"
|
|
||||||
|
|
||||||
# One-time task
|
|
||||||
cubbi exec claudecode "claude 'write a unit test for this function'"
|
|
||||||
|
|
||||||
# Interactive mode
|
|
||||||
cubbi exec claudecode "claude"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Working with Projects
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start Claude Code in your project directory
|
|
||||||
cubbi run claudecode --mount /path/to/your/project:/app
|
|
||||||
cubbi exec claudecode "cd /app && claude"
|
|
||||||
|
|
||||||
# Create a commit
|
|
||||||
cubbi exec claudecode "cd /app && claude commit"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Features
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run with specific model configuration
|
|
||||||
cubbi exec claudecode "claude -m claude-3-5-sonnet-20241022 'analyze this code'"
|
|
||||||
|
|
||||||
# Use with plan mode
|
|
||||||
cubbi exec claudecode "claude -p 'refactor this function'"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Persistent Configuration
|
|
||||||
|
|
||||||
The following directories are automatically persisted:
|
|
||||||
|
|
||||||
- `~/.claude/`: Claude Code settings and configuration
|
|
||||||
- `~/.cache/claude/`: Claude Code cache and temporary files
|
|
||||||
|
|
||||||
Configuration files are maintained across container restarts, ensuring your settings and preferences are preserved.
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
cubbi/images/claudecode/
|
|
||||||
├── Dockerfile # Container image definition
|
|
||||||
├── cubbi_image.yaml # Cubbi image configuration
|
|
||||||
├── claudecode_plugin.py # Authentication and setup plugin
|
|
||||||
├── cubbi_init.py # Initialization script (shared)
|
|
||||||
├── init-status.sh # Status check script (shared)
|
|
||||||
└── README.md # This documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication Flow
|
|
||||||
|
|
||||||
1. **Environment Variables**: API key passed from Cubbi configuration
|
|
||||||
2. **Plugin Setup**: `claudecode_plugin.py` creates `~/.claude/settings.json`
|
|
||||||
3. **Verification**: Plugin verifies Claude Code installation and configuration
|
|
||||||
4. **Ready**: Claude Code is ready for use with configured authentication
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**API Key Not Set**
|
|
||||||
```
|
|
||||||
⚠️ No authentication configuration found
|
|
||||||
Please set ANTHROPIC_API_KEY environment variable
|
|
||||||
```
|
|
||||||
**Solution**: Set API key in Cubbi configuration:
|
|
||||||
```bash
|
|
||||||
cubbi config set services.anthropic.api_key "your-api-key-here"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Claude Code Not Found**
|
|
||||||
```
|
|
||||||
❌ Claude Code not properly installed
|
|
||||||
```
|
|
||||||
**Solution**: Rebuild the container image:
|
|
||||||
```bash
|
|
||||||
docker build -t cubbi-claudecode:latest cubbi/images/claudecode/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Network Issues**
|
|
||||||
```
|
|
||||||
Connection timeout or proxy errors
|
|
||||||
```
|
|
||||||
**Solution**: Configure proxy settings:
|
|
||||||
```bash
|
|
||||||
cubbi config set network.https_proxy "your-proxy-url"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
Enable verbose output for debugging:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check configuration
|
|
||||||
cubbi exec claudecode "cat ~/.claude/settings.json"
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
cubbi exec claudecode "claude --version"
|
|
||||||
cubbi exec claudecode "which claude"
|
|
||||||
cubbi exec claudecode "node --version"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- **API Keys**: Stored securely with 0o600 permissions
|
|
||||||
- **Configuration**: Settings files have restricted access
|
|
||||||
- **Environment**: Isolated container environment
|
|
||||||
- **Telemetry**: Can be disabled for privacy
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Building the Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build locally
|
|
||||||
docker build -t cubbi-claudecode:test cubbi/images/claudecode/
|
|
||||||
|
|
||||||
# Test basic functionality
|
|
||||||
docker run --rm -it \
|
|
||||||
-e ANTHROPIC_API_KEY="your-api-key" \
|
|
||||||
cubbi-claudecode:test \
|
|
||||||
bash -c "claude --version"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run through Cubbi
|
|
||||||
cubbi run claudecode --name test-claude
|
|
||||||
cubbi exec test-claude "claude --version"
|
|
||||||
cubbi stop test-claude
|
|
||||||
```
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues related to:
|
|
||||||
- **Cubbi Integration**: Check Cubbi documentation or open an issue
|
|
||||||
- **Claude Code**: Visit [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code)
|
|
||||||
- **API Keys**: Visit [Anthropic Console](https://console.anthropic.com/)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This image configuration is provided under the same license as the Cubbi project. Claude Code is licensed separately by Anthropic.
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Claude Code Plugin for Cubbi
|
|
||||||
Handles authentication setup and configuration for Claude Code
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from cubbi_init import ToolPlugin
|
|
||||||
|
|
||||||
# API key mappings from environment variables to Claude Code configuration
|
|
||||||
API_KEY_MAPPINGS = {
|
|
||||||
"ANTHROPIC_API_KEY": "api_key",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "auth_token",
|
|
||||||
"ANTHROPIC_CUSTOM_HEADERS": "custom_headers",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Enterprise integration environment variables
|
|
||||||
ENTERPRISE_MAPPINGS = {
|
|
||||||
"CLAUDE_CODE_USE_BEDROCK": "use_bedrock",
|
|
||||||
"CLAUDE_CODE_USE_VERTEX": "use_vertex",
|
|
||||||
"HTTP_PROXY": "http_proxy",
|
|
||||||
"HTTPS_PROXY": "https_proxy",
|
|
||||||
"DISABLE_TELEMETRY": "disable_telemetry",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCodePlugin(ToolPlugin):
|
|
||||||
"""Plugin for setting up Claude Code authentication and configuration"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tool_name(self) -> str:
|
|
||||||
return "claudecode"
|
|
||||||
|
|
||||||
def _get_user_ids(self) -> tuple[int, int]:
|
|
||||||
"""Get the cubbi user and group IDs from environment"""
|
|
||||||
user_id = int(os.environ.get("CUBBI_USER_ID", "1000"))
|
|
||||||
group_id = int(os.environ.get("CUBBI_GROUP_ID", "1000"))
|
|
||||||
return user_id, group_id
|
|
||||||
|
|
||||||
def _set_ownership(self, path: Path) -> None:
|
|
||||||
"""Set ownership of a path to the cubbi user"""
|
|
||||||
user_id, group_id = self._get_user_ids()
|
|
||||||
try:
|
|
||||||
os.chown(path, user_id, group_id)
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(f"Failed to set ownership for {path}: {e}", "WARNING")
|
|
||||||
|
|
||||||
def _get_claude_dir(self) -> Path:
|
|
||||||
"""Get the Claude Code configuration directory"""
|
|
||||||
return Path("/home/cubbi/.claude")
|
|
||||||
|
|
||||||
def _ensure_claude_dir(self) -> Path:
|
|
||||||
"""Ensure Claude directory exists with correct ownership"""
|
|
||||||
claude_dir = self._get_claude_dir()
|
|
||||||
|
|
||||||
try:
|
|
||||||
claude_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
self._set_ownership(claude_dir)
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to create Claude directory {claude_dir}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
|
|
||||||
return claude_dir
|
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""Initialize Claude Code configuration"""
|
|
||||||
self.status.log("Setting up Claude Code authentication...")
|
|
||||||
|
|
||||||
# Ensure Claude directory exists
|
|
||||||
claude_dir = self._ensure_claude_dir()
|
|
||||||
|
|
||||||
# Create settings configuration
|
|
||||||
settings = self._create_settings()
|
|
||||||
|
|
||||||
if settings:
|
|
||||||
settings_file = claude_dir / "settings.json"
|
|
||||||
success = self._write_settings(settings_file, settings)
|
|
||||||
if success:
|
|
||||||
self.status.log("✅ Claude Code authentication configured successfully")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.status.log("⚠️ No authentication configuration found", "WARNING")
|
|
||||||
self.status.log(
|
|
||||||
" Please set ANTHROPIC_API_KEY environment variable", "WARNING"
|
|
||||||
)
|
|
||||||
self.status.log(" Claude Code will run without authentication", "INFO")
|
|
||||||
# Return True to allow container to start without API key
|
|
||||||
# Users can still use Claude Code with their own authentication methods
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _create_settings(self) -> Optional[Dict]:
|
|
||||||
"""Create Claude Code settings configuration"""
|
|
||||||
settings = {}
|
|
||||||
|
|
||||||
# Core authentication
|
|
||||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
||||||
if not api_key:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Basic authentication setup
|
|
||||||
settings["apiKey"] = api_key
|
|
||||||
|
|
||||||
# Custom authorization token (optional)
|
|
||||||
auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN")
|
|
||||||
if auth_token:
|
|
||||||
settings["authToken"] = auth_token
|
|
||||||
|
|
||||||
# Custom headers (optional)
|
|
||||||
custom_headers = os.environ.get("ANTHROPIC_CUSTOM_HEADERS")
|
|
||||||
if custom_headers:
|
|
||||||
try:
|
|
||||||
# Expect JSON string format
|
|
||||||
settings["customHeaders"] = json.loads(custom_headers)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.status.log(
|
|
||||||
"⚠️ Invalid ANTHROPIC_CUSTOM_HEADERS format, skipping", "WARNING"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Enterprise integration settings
|
|
||||||
if os.environ.get("CLAUDE_CODE_USE_BEDROCK") == "true":
|
|
||||||
settings["provider"] = "bedrock"
|
|
||||||
|
|
||||||
if os.environ.get("CLAUDE_CODE_USE_VERTEX") == "true":
|
|
||||||
settings["provider"] = "vertex"
|
|
||||||
|
|
||||||
# Network proxy settings
|
|
||||||
http_proxy = os.environ.get("HTTP_PROXY")
|
|
||||||
https_proxy = os.environ.get("HTTPS_PROXY")
|
|
||||||
if http_proxy or https_proxy:
|
|
||||||
settings["proxy"] = {}
|
|
||||||
if http_proxy:
|
|
||||||
settings["proxy"]["http"] = http_proxy
|
|
||||||
if https_proxy:
|
|
||||||
settings["proxy"]["https"] = https_proxy
|
|
||||||
|
|
||||||
# Telemetry settings
|
|
||||||
if os.environ.get("DISABLE_TELEMETRY") == "true":
|
|
||||||
settings["telemetry"] = {"enabled": False}
|
|
||||||
|
|
||||||
# Tool permissions (allow all by default in Cubbi environment)
|
|
||||||
settings["permissions"] = {
|
|
||||||
"tools": {
|
|
||||||
"read": {"allowed": True},
|
|
||||||
"write": {"allowed": True},
|
|
||||||
"edit": {"allowed": True},
|
|
||||||
"bash": {"allowed": True},
|
|
||||||
"webfetch": {"allowed": True},
|
|
||||||
"websearch": {"allowed": True},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|
||||||
def _write_settings(self, settings_file: Path, settings: Dict) -> bool:
|
|
||||||
"""Write settings to Claude Code configuration file"""
|
|
||||||
try:
|
|
||||||
# Write settings with secure permissions
|
|
||||||
with open(settings_file, "w") as f:
|
|
||||||
json.dump(settings, f, indent=2)
|
|
||||||
|
|
||||||
# Set ownership and secure file permissions (read/write for owner only)
|
|
||||||
self._set_ownership(settings_file)
|
|
||||||
os.chmod(settings_file, stat.S_IRUSR | stat.S_IWUSR)
|
|
||||||
|
|
||||||
self.status.log(f"Created Claude Code settings at {settings_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to write Claude Code settings: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def setup_tool_configuration(self) -> bool:
|
|
||||||
"""Set up Claude Code configuration - called by base class"""
|
|
||||||
# Additional tool configuration can be added here if needed
|
|
||||||
return True
|
|
||||||
|
|
||||||
def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool:
|
|
||||||
"""Integrate Claude Code with available MCP servers"""
|
|
||||||
if mcp_config["count"] == 0:
|
|
||||||
self.status.log("No MCP servers to integrate")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Claude Code has built-in MCP support, so we can potentially
|
|
||||||
# configure MCP servers in the settings if needed
|
|
||||||
self.status.log("MCP server integration available for Claude Code")
|
|
||||||
return True
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
name: claudecode
|
|
||||||
description: Claude Code AI environment
|
|
||||||
version: 1.0.0
|
|
||||||
maintainer: team@monadical.com
|
|
||||||
image: monadical/cubbi-claudecode:latest
|
|
||||||
|
|
||||||
init:
|
|
||||||
pre_command: /cubbi-init.sh
|
|
||||||
command: /entrypoint.sh
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# Core Anthropic Authentication
|
|
||||||
- name: ANTHROPIC_API_KEY
|
|
||||||
description: Anthropic API key for Claude
|
|
||||||
required: true
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# Optional Enterprise Integration
|
|
||||||
- name: ANTHROPIC_AUTH_TOKEN
|
|
||||||
description: Custom authorization token for Claude
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
- name: ANTHROPIC_CUSTOM_HEADERS
|
|
||||||
description: Additional HTTP headers for Claude API requests
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
# Enterprise Deployment Options
|
|
||||||
- name: CLAUDE_CODE_USE_BEDROCK
|
|
||||||
description: Use Amazon Bedrock instead of direct API
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- name: CLAUDE_CODE_USE_VERTEX
|
|
||||||
description: Use Google Vertex AI instead of direct API
|
|
||||||
required: false
|
|
||||||
|
|
||||||
# Network Configuration
|
|
||||||
- name: HTTP_PROXY
|
|
||||||
description: HTTP proxy server URL
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- name: HTTPS_PROXY
|
|
||||||
description: HTTPS proxy server URL
|
|
||||||
required: false
|
|
||||||
|
|
||||||
# Optional Telemetry Control
|
|
||||||
- name: DISABLE_TELEMETRY
|
|
||||||
description: Disable Claude Code telemetry
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- mountPath: /app
|
|
||||||
description: Application directory
|
|
||||||
|
|
||||||
persistent_configs:
|
|
||||||
- source: "/home/cubbi/.claude"
|
|
||||||
target: "/cubbi-config/claude-settings"
|
|
||||||
type: "directory"
|
|
||||||
description: "Claude Code settings and configuration"
|
|
||||||
|
|
||||||
- source: "/home/cubbi/.cache/claude"
|
|
||||||
target: "/cubbi-config/claude-cache"
|
|
||||||
type: "directory"
|
|
||||||
description: "Claude Code cache directory"
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Automated test suite for Claude Code Cubbi integration
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def run_test(description: str, command: list, timeout: int = 30) -> bool:
|
|
||||||
"""Run a test command and return success status"""
|
|
||||||
print(f"🧪 Testing: {description}")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command, capture_output=True, text=True, timeout=timeout
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(" ✅ PASS")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f" ❌ FAIL: {result.stderr}")
|
|
||||||
if result.stdout:
|
|
||||||
print(f" 📋 stdout: {result.stdout}")
|
|
||||||
return False
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print(f" ⏰ TIMEOUT: Command exceeded {timeout}s")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ ERROR: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_suite():
|
|
||||||
"""Run complete test suite"""
|
|
||||||
tests_passed = 0
|
|
||||||
total_tests = 0
|
|
||||||
|
|
||||||
print("🚀 Starting Claude Code Cubbi Integration Test Suite")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test 1: Build image
|
|
||||||
total_tests += 1
|
|
||||||
if run_test(
|
|
||||||
"Build Claude Code image",
|
|
||||||
["docker", "build", "-t", "cubbi-claudecode:test", "cubbi/images/claudecode/"],
|
|
||||||
timeout=180,
|
|
||||||
):
|
|
||||||
tests_passed += 1
|
|
||||||
|
|
||||||
# Test 2: Tag image for Cubbi
|
|
||||||
total_tests += 1
|
|
||||||
if run_test(
|
|
||||||
"Tag image for Cubbi",
|
|
||||||
["docker", "tag", "cubbi-claudecode:test", "monadical/cubbi-claudecode:latest"],
|
|
||||||
):
|
|
||||||
tests_passed += 1
|
|
||||||
|
|
||||||
# Test 3: Basic container startup
|
|
||||||
total_tests += 1
|
|
||||||
if run_test(
|
|
||||||
"Container startup with test API key",
|
|
||||||
[
|
|
||||||
"docker",
|
|
||||||
"run",
|
|
||||||
"--rm",
|
|
||||||
"-e",
|
|
||||||
"ANTHROPIC_API_KEY=test-key",
|
|
||||||
"cubbi-claudecode:test",
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
"claude --version",
|
|
||||||
],
|
|
||||||
):
|
|
||||||
tests_passed += 1
|
|
||||||
|
|
||||||
# Test 4: Cubbi image list
|
|
||||||
total_tests += 1
|
|
||||||
if run_test(
|
|
||||||
"Cubbi image list includes claudecode",
|
|
||||||
["uv", "run", "-m", "cubbi.cli", "image", "list"],
|
|
||||||
):
|
|
||||||
tests_passed += 1
|
|
||||||
|
|
||||||
# Test 5: Cubbi session creation
|
|
||||||
total_tests += 1
|
|
||||||
session_result = subprocess.run(
|
|
||||||
[
|
|
||||||
"uv",
|
|
||||||
"run",
|
|
||||||
"-m",
|
|
||||||
"cubbi.cli",
|
|
||||||
"session",
|
|
||||||
"create",
|
|
||||||
"--image",
|
|
||||||
"claudecode",
|
|
||||||
"--name",
|
|
||||||
"test-automation",
|
|
||||||
"--no-connect",
|
|
||||||
"--env",
|
|
||||||
"ANTHROPIC_API_KEY=test-key",
|
|
||||||
"--run",
|
|
||||||
"claude --version",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
if session_result.returncode == 0:
|
|
||||||
print("🧪 Testing: Cubbi session creation")
|
|
||||||
print(" ✅ PASS")
|
|
||||||
tests_passed += 1
|
|
||||||
|
|
||||||
# Extract session ID for cleanup
|
|
||||||
session_id = None
|
|
||||||
for line in session_result.stdout.split("\n"):
|
|
||||||
if "Session ID:" in line:
|
|
||||||
session_id = line.split("Session ID: ")[1].strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
if session_id:
|
|
||||||
# Test 6: Session cleanup
|
|
||||||
total_tests += 1
|
|
||||||
if run_test(
|
|
||||||
"Clean up test session",
|
|
||||||
["uv", "run", "-m", "cubbi.cli", "session", "close", session_id],
|
|
||||||
):
|
|
||||||
tests_passed += 1
|
|
||||||
else:
|
|
||||||
print("🧪 Testing: Clean up test session")
|
|
||||||
print(" ⚠️ SKIP: Could not extract session ID")
|
|
||||||
total_tests += 1
|
|
||||||
else:
|
|
||||||
print("🧪 Testing: Cubbi session creation")
|
|
||||||
print(f" ❌ FAIL: {session_result.stderr}")
|
|
||||||
total_tests += 2 # This test and cleanup test both fail
|
|
||||||
|
|
||||||
# Test 7: Session without API key
|
|
||||||
total_tests += 1
|
|
||||||
no_key_result = subprocess.run(
|
|
||||||
[
|
|
||||||
"uv",
|
|
||||||
"run",
|
|
||||||
"-m",
|
|
||||||
"cubbi.cli",
|
|
||||||
"session",
|
|
||||||
"create",
|
|
||||||
"--image",
|
|
||||||
"claudecode",
|
|
||||||
"--name",
|
|
||||||
"test-no-key",
|
|
||||||
"--no-connect",
|
|
||||||
"--run",
|
|
||||||
"claude --version",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
if no_key_result.returncode == 0:
|
|
||||||
print("🧪 Testing: Session without API key")
|
|
||||||
print(" ✅ PASS")
|
|
||||||
tests_passed += 1
|
|
||||||
|
|
||||||
# Extract session ID and close
|
|
||||||
session_id = None
|
|
||||||
for line in no_key_result.stdout.split("\n"):
|
|
||||||
if "Session ID:" in line:
|
|
||||||
session_id = line.split("Session ID: ")[1].strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
if session_id:
|
|
||||||
subprocess.run(
|
|
||||||
["uv", "run", "-m", "cubbi.cli", "session", "close", session_id],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("🧪 Testing: Session without API key")
|
|
||||||
print(f" ❌ FAIL: {no_key_result.stderr}")
|
|
||||||
|
|
||||||
# Test 8: Persistent configuration test
|
|
||||||
total_tests += 1
|
|
||||||
persist_result = subprocess.run(
|
|
||||||
[
|
|
||||||
"uv",
|
|
||||||
"run",
|
|
||||||
"-m",
|
|
||||||
"cubbi.cli",
|
|
||||||
"session",
|
|
||||||
"create",
|
|
||||||
"--image",
|
|
||||||
"claudecode",
|
|
||||||
"--name",
|
|
||||||
"test-persist-auto",
|
|
||||||
"--project",
|
|
||||||
"test-automation",
|
|
||||||
"--no-connect",
|
|
||||||
"--env",
|
|
||||||
"ANTHROPIC_API_KEY=test-key",
|
|
||||||
"--run",
|
|
||||||
"echo 'automation test' > ~/.claude/automation.txt && cat ~/.claude/automation.txt",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
if persist_result.returncode == 0:
|
|
||||||
print("🧪 Testing: Persistent configuration")
|
|
||||||
print(" ✅ PASS")
|
|
||||||
tests_passed += 1
|
|
||||||
|
|
||||||
# Extract session ID and close
|
|
||||||
session_id = None
|
|
||||||
for line in persist_result.stdout.split("\n"):
|
|
||||||
if "Session ID:" in line:
|
|
||||||
session_id = line.split("Session ID: ")[1].strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
if session_id:
|
|
||||||
subprocess.run(
|
|
||||||
["uv", "run", "-m", "cubbi.cli", "session", "close", session_id],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("🧪 Testing: Persistent configuration")
|
|
||||||
print(f" ❌ FAIL: {persist_result.stderr}")
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"📊 Test Results: {tests_passed}/{total_tests} tests passed")
|
|
||||||
|
|
||||||
if tests_passed == total_tests:
|
|
||||||
print("🎉 All tests passed! Claude Code integration is working correctly.")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"❌ {total_tests - tests_passed} test(s) failed. Please check the output above."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main test entry point"""
|
|
||||||
success = test_suite()
|
|
||||||
exit(0 if success else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
LABEL maintainer="team@monadical.com"
|
|
||||||
LABEL description="Crush AI coding assistant for Cubbi"
|
|
||||||
|
|
||||||
# Install system dependencies including gosu for user switching and shadow for useradd/groupadd
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
gosu \
|
|
||||||
sudo \
|
|
||||||
passwd \
|
|
||||||
bash \
|
|
||||||
curl \
|
|
||||||
bzip2 \
|
|
||||||
iputils-ping \
|
|
||||||
iproute2 \
|
|
||||||
libxcb1 \
|
|
||||||
libdbus-1-3 \
|
|
||||||
nano \
|
|
||||||
tmux \
|
|
||||||
git-core \
|
|
||||||
ripgrep \
|
|
||||||
openssh-client \
|
|
||||||
vim \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install deps
|
|
||||||
WORKDIR /tmp
|
|
||||||
RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \
|
|
||||||
sh install.sh && \
|
|
||||||
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
|
||||||
mv /root/.local/bin/uvx /usr/local/bin/uvx && \
|
|
||||||
rm install.sh
|
|
||||||
|
|
||||||
# Install crush via npm
|
|
||||||
RUN npm install -g @charmland/crush
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy initialization system
|
|
||||||
COPY cubbi_init.py /cubbi/cubbi_init.py
|
|
||||||
COPY crush_plugin.py /cubbi/crush_plugin.py
|
|
||||||
COPY cubbi_image.yaml /cubbi/cubbi_image.yaml
|
|
||||||
COPY init-status.sh /cubbi/init-status.sh
|
|
||||||
RUN chmod +x /cubbi/cubbi_init.py /cubbi/init-status.sh
|
|
||||||
RUN echo '[ -x /cubbi/init-status.sh ] && /cubbi/init-status.sh' >> /etc/bash.bashrc
|
|
||||||
|
|
||||||
# Set up environment
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ENV UV_LINK_MODE=copy
|
|
||||||
|
|
||||||
# Pre-install the cubbi_init
|
|
||||||
RUN /cubbi/cubbi_init.py --help
|
|
||||||
|
|
||||||
# Set WORKDIR to /app, common practice and expected by cubbi-init.sh
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/cubbi/cubbi_init.py"]
|
|
||||||
CMD ["tail", "-f", "/dev/null"]
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Crush Image for Cubbi
|
|
||||||
|
|
||||||
This image provides a containerized environment for running [Crush](https://github.com/charmbracelet/crush), a terminal-based AI coding assistant.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Pre-configured environment for Crush AI coding assistant
|
|
||||||
- Multi-model support (OpenAI, Anthropic, Groq)
|
|
||||||
- JSON-based configuration
|
|
||||||
- MCP server integration support
|
|
||||||
- Session preservation across runs
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### AI Provider Configuration
|
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `OPENAI_API_KEY` | OpenAI API key for crush | No | - |
|
|
||||||
| `ANTHROPIC_API_KEY` | Anthropic API key for crush | No | - |
|
|
||||||
| `GROQ_API_KEY` | Groq API key for crush | No | - |
|
|
||||||
| `OPENAI_URL` | Custom OpenAI-compatible API URL | No | - |
|
|
||||||
| `CUBBI_MODEL` | AI model to use with crush | No | - |
|
|
||||||
| `CUBBI_PROVIDER` | AI provider to use with crush | No | - |
|
|
||||||
|
|
||||||
### Cubbi Core Variables
|
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `CUBBI_USER_ID` | UID for the container user | No | `1000` |
|
|
||||||
| `CUBBI_GROUP_ID` | GID for the container user | No | `1000` |
|
|
||||||
| `CUBBI_RUN_COMMAND` | Command to execute after initialization | No | - |
|
|
||||||
| `CUBBI_NO_SHELL` | Exit after command execution | No | `false` |
|
|
||||||
| `CUBBI_CONFIG_DIR` | Directory for persistent configurations | No | `/cubbi-config` |
|
|
||||||
| `CUBBI_PERSISTENT_LINKS` | Semicolon-separated list of source:target symlinks | No | - |
|
|
||||||
|
|
||||||
### MCP Integration Variables
|
|
||||||
|
|
||||||
| Variable | Description | Required |
|
|
||||||
|----------|-------------|----------|
|
|
||||||
| `MCP_COUNT` | Number of available MCP servers | No |
|
|
||||||
| `MCP_NAMES` | JSON array of MCP server names | No |
|
|
||||||
| `MCP_{idx}_NAME` | Name of MCP server at index | No |
|
|
||||||
| `MCP_{idx}_TYPE` | Type of MCP server | No |
|
|
||||||
| `MCP_{idx}_HOST` | Hostname of MCP server | No |
|
|
||||||
| `MCP_{idx}_URL` | Full URL for remote MCP servers | No |
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
To build this image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd cubbi/images/crush
|
|
||||||
docker build -t monadical/cubbi-crush:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new session with this image
|
|
||||||
cubbix -i crush
|
|
||||||
|
|
||||||
# Run crush with specific provider
|
|
||||||
cubbix -i crush -e CUBBI_PROVIDER=openai -e CUBBI_MODEL=gpt-4
|
|
||||||
|
|
||||||
# Test crush installation
|
|
||||||
cubbix -i crush --no-shell --run "crush --help"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Crush uses JSON configuration stored in `/home/cubbi/.config/crush/config.json`. The plugin automatically configures:
|
|
||||||
|
|
||||||
- AI providers based on available API keys
|
|
||||||
- Default models and providers from environment variables
|
|
||||||
- Session preservation settings
|
|
||||||
- MCP server integrations
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Crush-specific plugin for Cubbi initialization
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from cubbi_init import ToolPlugin
|
|
||||||
|
|
||||||
|
|
||||||
class CrushPlugin(ToolPlugin):
|
|
||||||
"""Plugin for Crush AI coding assistant initialization"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tool_name(self) -> str:
|
|
||||||
return "crush"
|
|
||||||
|
|
||||||
def _get_user_ids(self) -> tuple[int, int]:
|
|
||||||
"""Get the cubbi user and group IDs from environment"""
|
|
||||||
user_id = int(os.environ.get("CUBBI_USER_ID", "1000"))
|
|
||||||
group_id = int(os.environ.get("CUBBI_GROUP_ID", "1000"))
|
|
||||||
return user_id, group_id
|
|
||||||
|
|
||||||
def _set_ownership(self, path: Path) -> None:
|
|
||||||
"""Set ownership of a path to the cubbi user"""
|
|
||||||
user_id, group_id = self._get_user_ids()
|
|
||||||
try:
|
|
||||||
os.chown(path, user_id, group_id)
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(f"Failed to set ownership for {path}: {e}", "WARNING")
|
|
||||||
|
|
||||||
def _get_user_config_path(self) -> Path:
|
|
||||||
"""Get the correct config path for the cubbi user"""
|
|
||||||
return Path("/home/cubbi/.config/crush")
|
|
||||||
|
|
||||||
def _ensure_user_config_dir(self) -> Path:
|
|
||||||
"""Ensure config directory exists with correct ownership"""
|
|
||||||
config_dir = self._get_user_config_path()
|
|
||||||
|
|
||||||
# Create the full directory path
|
|
||||||
try:
|
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
except FileExistsError:
|
|
||||||
# Directory already exists, which is fine
|
|
||||||
pass
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to create config directory {config_dir}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
return config_dir
|
|
||||||
|
|
||||||
# Set ownership for the directories
|
|
||||||
config_parent = config_dir.parent
|
|
||||||
if config_parent.exists():
|
|
||||||
self._set_ownership(config_parent)
|
|
||||||
|
|
||||||
if config_dir.exists():
|
|
||||||
self._set_ownership(config_dir)
|
|
||||||
|
|
||||||
return config_dir
|
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""Initialize Crush configuration"""
|
|
||||||
self._ensure_user_config_dir()
|
|
||||||
return self.setup_tool_configuration()
|
|
||||||
|
|
||||||
def setup_tool_configuration(self) -> bool:
|
|
||||||
"""Set up Crush configuration file"""
|
|
||||||
# Ensure directory exists before writing
|
|
||||||
config_dir = self._ensure_user_config_dir()
|
|
||||||
if not config_dir.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"Config directory {config_dir} does not exist and could not be created",
|
|
||||||
"ERROR",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
config_file = config_dir / "config.json"
|
|
||||||
|
|
||||||
# Load or initialize configuration
|
|
||||||
if config_file.exists():
|
|
||||||
try:
|
|
||||||
with config_file.open("r") as f:
|
|
||||||
config_data = json.load(f)
|
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
self.status.log(f"Failed to load existing config: {e}", "WARNING")
|
|
||||||
config_data = {}
|
|
||||||
else:
|
|
||||||
config_data = {}
|
|
||||||
|
|
||||||
# Set default model and provider if specified
|
|
||||||
# cubbi_model = os.environ.get("CUBBI_MODEL")
|
|
||||||
# cubbi_provider = os.environ.get("CUBBI_PROVIDER")
|
|
||||||
# XXX i didn't understood yet the configuration file, tbd later.
|
|
||||||
|
|
||||||
try:
|
|
||||||
with config_file.open("w") as f:
|
|
||||||
json.dump(config_data, f, indent=2)
|
|
||||||
|
|
||||||
# Set ownership of the config file to cubbi user
|
|
||||||
self._set_ownership(config_file)
|
|
||||||
|
|
||||||
self.status.log(f"Updated Crush configuration at {config_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to write Crush configuration: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool:
|
|
||||||
"""Integrate Crush with available MCP servers"""
|
|
||||||
if mcp_config["count"] == 0:
|
|
||||||
self.status.log("No MCP servers to integrate")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Ensure directory exists before writing
|
|
||||||
config_dir = self._ensure_user_config_dir()
|
|
||||||
if not config_dir.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"Config directory {config_dir} does not exist and could not be created",
|
|
||||||
"ERROR",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
config_file = config_dir / "config.json"
|
|
||||||
|
|
||||||
if config_file.exists():
|
|
||||||
try:
|
|
||||||
with config_file.open("r") as f:
|
|
||||||
config_data = json.load(f)
|
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
self.status.log(f"Failed to load existing config: {e}", "WARNING")
|
|
||||||
config_data = {}
|
|
||||||
else:
|
|
||||||
config_data = {}
|
|
||||||
|
|
||||||
if "mcp_servers" not in config_data:
|
|
||||||
config_data["mcp_servers"] = {}
|
|
||||||
|
|
||||||
for server in mcp_config["servers"]:
|
|
||||||
server_name = server["name"]
|
|
||||||
server_host = server["host"]
|
|
||||||
server_url = server["url"]
|
|
||||||
|
|
||||||
if server_name and server_host:
|
|
||||||
mcp_url = f"http://{server_host}:8080/sse"
|
|
||||||
self.status.log(f"Adding MCP server: {server_name} - {mcp_url}")
|
|
||||||
|
|
||||||
config_data["mcp_servers"][server_name] = {
|
|
||||||
"uri": mcp_url,
|
|
||||||
"type": server.get("type", "sse"),
|
|
||||||
"enabled": True,
|
|
||||||
}
|
|
||||||
elif server_name and server_url:
|
|
||||||
self.status.log(
|
|
||||||
f"Adding remote MCP server: {server_name} - {server_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
config_data["mcp_servers"][server_name] = {
|
|
||||||
"uri": server_url,
|
|
||||||
"type": server.get("type", "sse"),
|
|
||||||
"enabled": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with config_file.open("w") as f:
|
|
||||||
json.dump(config_data, f, indent=2)
|
|
||||||
|
|
||||||
# Set ownership of the config file to cubbi user
|
|
||||||
self._set_ownership(config_file)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to integrate MCP servers: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
name: crush
|
|
||||||
description: Crush AI coding assistant environment
|
|
||||||
version: 1.0.0
|
|
||||||
maintainer: team@monadical.com
|
|
||||||
image: monadical/cubbi-crush:latest
|
|
||||||
|
|
||||||
init:
|
|
||||||
pre_command: /cubbi-init.sh
|
|
||||||
command: /entrypoint.sh
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- name: OPENAI_API_KEY
|
|
||||||
description: OpenAI API key for crush
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
- name: ANTHROPIC_API_KEY
|
|
||||||
description: Anthropic API key for crush
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
- name: GROQ_API_KEY
|
|
||||||
description: Groq API key for crush
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
- name: OPENAI_URL
|
|
||||||
description: Custom OpenAI-compatible API URL
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- name: CUBBI_MODEL
|
|
||||||
description: AI model to use with crush
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- name: CUBBI_PROVIDER
|
|
||||||
description: AI provider to use with crush
|
|
||||||
required: false
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- mountPath: /app
|
|
||||||
description: Application directory
|
|
||||||
|
|
||||||
persistent_configs:
|
|
||||||
- source: "/home/cubbi/.config/crush"
|
|
||||||
target: "/cubbi-config/crush-config"
|
|
||||||
type: "directory"
|
|
||||||
description: "Crush configuration directory"
|
|
||||||
- source: "/app/.crush"
|
|
||||||
target: "/cubbi-config/crush-app"
|
|
||||||
type: "directory"
|
|
||||||
description: "Crush application data and sessions"
|
|
||||||
@@ -1,702 +0,0 @@
|
|||||||
#!/usr/bin/env -S uv run --script
|
|
||||||
# /// script
|
|
||||||
# dependencies = ["ruamel.yaml"]
|
|
||||||
# ///
|
|
||||||
"""
|
|
||||||
Standalone Cubbi initialization script
|
|
||||||
|
|
||||||
This is a self-contained script that includes all the necessary initialization
|
|
||||||
logic without requiring the full cubbi package to be installed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import grp
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
import pwd
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
|
|
||||||
|
|
||||||
# Status Management
|
|
||||||
class StatusManager:
|
|
||||||
"""Manages initialization status and logging"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, log_file: str = "/cubbi/init.log", status_file: str = "/cubbi/init.status"
|
|
||||||
):
|
|
||||||
self.log_file = Path(log_file)
|
|
||||||
self.status_file = Path(status_file)
|
|
||||||
self._setup_logging()
|
|
||||||
|
|
||||||
def _setup_logging(self) -> None:
|
|
||||||
"""Set up logging to both stdout and log file"""
|
|
||||||
self.log_file.touch(exist_ok=True)
|
|
||||||
self.set_status(False)
|
|
||||||
|
|
||||||
def log(self, message: str, level: str = "INFO") -> None:
|
|
||||||
"""Log a message with timestamp"""
|
|
||||||
print(message)
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
with open(self.log_file, "a") as f:
|
|
||||||
f.write(message + "\n")
|
|
||||||
f.flush()
|
|
||||||
|
|
||||||
def set_status(self, complete: bool) -> None:
|
|
||||||
"""Set initialization completion status"""
|
|
||||||
status = "true" if complete else "false"
|
|
||||||
with open(self.status_file, "w") as f:
|
|
||||||
f.write(f"INIT_COMPLETE={status}\n")
|
|
||||||
|
|
||||||
def start_initialization(self) -> None:
|
|
||||||
"""Mark initialization as started"""
|
|
||||||
self.set_status(False)
|
|
||||||
|
|
||||||
def complete_initialization(self) -> None:
|
|
||||||
"""Mark initialization as completed"""
|
|
||||||
self.set_status(True)
|
|
||||||
|
|
||||||
|
|
||||||
# Configuration Management
|
|
||||||
@dataclass
|
|
||||||
class PersistentConfig:
|
|
||||||
"""Persistent configuration mapping"""
|
|
||||||
|
|
||||||
source: str
|
|
||||||
target: str
|
|
||||||
type: str = "directory"
|
|
||||||
description: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ImageConfig:
|
|
||||||
"""Cubbi image configuration"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
version: str
|
|
||||||
maintainer: str
|
|
||||||
image: str
|
|
||||||
persistent_configs: List[PersistentConfig] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigParser:
|
|
||||||
"""Parses Cubbi image configuration and environment variables"""
|
|
||||||
|
|
||||||
def __init__(self, config_file: str = "/cubbi/cubbi_image.yaml"):
|
|
||||||
self.config_file = Path(config_file)
|
|
||||||
self.environment: Dict[str, str] = dict(os.environ)
|
|
||||||
|
|
||||||
def load_image_config(self) -> ImageConfig:
|
|
||||||
"""Load and parse the cubbi_image.yaml configuration"""
|
|
||||||
if not self.config_file.exists():
|
|
||||||
raise FileNotFoundError(f"Configuration file not found: {self.config_file}")
|
|
||||||
|
|
||||||
yaml = YAML(typ="safe")
|
|
||||||
with open(self.config_file, "r") as f:
|
|
||||||
config_data = yaml.load(f)
|
|
||||||
|
|
||||||
# Parse persistent configurations
|
|
||||||
persistent_configs = []
|
|
||||||
for pc_data in config_data.get("persistent_configs", []):
|
|
||||||
persistent_configs.append(PersistentConfig(**pc_data))
|
|
||||||
|
|
||||||
return ImageConfig(
|
|
||||||
name=config_data["name"],
|
|
||||||
description=config_data["description"],
|
|
||||||
version=config_data["version"],
|
|
||||||
maintainer=config_data["maintainer"],
|
|
||||||
image=config_data["image"],
|
|
||||||
persistent_configs=persistent_configs,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_cubbi_config(self) -> Dict[str, Any]:
|
|
||||||
"""Get standard Cubbi configuration from environment"""
|
|
||||||
return {
|
|
||||||
"user_id": int(self.environment.get("CUBBI_USER_ID", "1000")),
|
|
||||||
"group_id": int(self.environment.get("CUBBI_GROUP_ID", "1000")),
|
|
||||||
"run_command": self.environment.get("CUBBI_RUN_COMMAND"),
|
|
||||||
"no_shell": self.environment.get("CUBBI_NO_SHELL", "false").lower()
|
|
||||||
== "true",
|
|
||||||
"config_dir": self.environment.get("CUBBI_CONFIG_DIR", "/cubbi-config"),
|
|
||||||
"persistent_links": self.environment.get("CUBBI_PERSISTENT_LINKS", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_mcp_config(self) -> Dict[str, Any]:
|
|
||||||
"""Get MCP server configuration from environment"""
|
|
||||||
mcp_count = int(self.environment.get("MCP_COUNT", "0"))
|
|
||||||
mcp_servers = []
|
|
||||||
|
|
||||||
for idx in range(mcp_count):
|
|
||||||
server = {
|
|
||||||
"name": self.environment.get(f"MCP_{idx}_NAME"),
|
|
||||||
"type": self.environment.get(f"MCP_{idx}_TYPE"),
|
|
||||||
"host": self.environment.get(f"MCP_{idx}_HOST"),
|
|
||||||
"url": self.environment.get(f"MCP_{idx}_URL"),
|
|
||||||
}
|
|
||||||
if server["name"]: # Only add if name is present
|
|
||||||
mcp_servers.append(server)
|
|
||||||
|
|
||||||
return {"count": mcp_count, "servers": mcp_servers}
|
|
||||||
|
|
||||||
|
|
||||||
# Core Management Classes
|
|
||||||
class UserManager:
|
|
||||||
"""Manages user and group creation/modification in containers"""
|
|
||||||
|
|
||||||
def __init__(self, status: StatusManager):
|
|
||||||
self.status = status
|
|
||||||
self.username = "cubbi"
|
|
||||||
|
|
||||||
def _run_command(self, cmd: list[str]) -> bool:
|
|
||||||
"""Run a system command and log the result"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
||||||
if result.stdout:
|
|
||||||
self.status.log(f"Command output: {result.stdout.strip()}")
|
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
self.status.log(f"Command failed: {' '.join(cmd)}", "ERROR")
|
|
||||||
self.status.log(f"Error: {e.stderr}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def setup_user_and_group(self, user_id: int, group_id: int) -> bool:
|
|
||||||
"""Set up user and group with specified IDs"""
|
|
||||||
self.status.log(
|
|
||||||
f"Setting up user '{self.username}' with UID: {user_id}, GID: {group_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle group creation/modification
|
|
||||||
try:
|
|
||||||
existing_group = grp.getgrnam(self.username)
|
|
||||||
if existing_group.gr_gid != group_id:
|
|
||||||
self.status.log(
|
|
||||||
f"Modifying group '{self.username}' GID from {existing_group.gr_gid} to {group_id}"
|
|
||||||
)
|
|
||||||
if not self._run_command(
|
|
||||||
["groupmod", "-g", str(group_id), self.username]
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
except KeyError:
|
|
||||||
if not self._run_command(["groupadd", "-g", str(group_id), self.username]):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Handle user creation/modification
|
|
||||||
try:
|
|
||||||
existing_user = pwd.getpwnam(self.username)
|
|
||||||
if existing_user.pw_uid != user_id or existing_user.pw_gid != group_id:
|
|
||||||
self.status.log(
|
|
||||||
f"Modifying user '{self.username}' UID from {existing_user.pw_uid} to {user_id}, GID from {existing_user.pw_gid} to {group_id}"
|
|
||||||
)
|
|
||||||
if not self._run_command(
|
|
||||||
[
|
|
||||||
"usermod",
|
|
||||||
"--uid",
|
|
||||||
str(user_id),
|
|
||||||
"--gid",
|
|
||||||
str(group_id),
|
|
||||||
self.username,
|
|
||||||
]
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
except KeyError:
|
|
||||||
if not self._run_command(
|
|
||||||
[
|
|
||||||
"useradd",
|
|
||||||
"--shell",
|
|
||||||
"/bin/bash",
|
|
||||||
"--uid",
|
|
||||||
str(user_id),
|
|
||||||
"--gid",
|
|
||||||
str(group_id),
|
|
||||||
"--no-create-home",
|
|
||||||
self.username,
|
|
||||||
]
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Create the sudoers file entry for the 'cubbi' user
|
|
||||||
sudoers_command = [
|
|
||||||
"sh",
|
|
||||||
"-c",
|
|
||||||
"echo 'cubbi ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/cubbi && chmod 0440 /etc/sudoers.d/cubbi",
|
|
||||||
]
|
|
||||||
if not self._run_command(sudoers_command):
|
|
||||||
self.status.log("Failed to create sudoers entry for cubbi", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class DirectoryManager:
|
|
||||||
"""Manages directory creation and permission setup"""
|
|
||||||
|
|
||||||
def __init__(self, status: StatusManager):
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
def create_directory(
|
|
||||||
self, path: str, user_id: int, group_id: int, mode: int = 0o755
|
|
||||||
) -> bool:
|
|
||||||
"""Create a directory with proper ownership and permissions"""
|
|
||||||
dir_path = Path(path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
dir_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chown(path, user_id, group_id)
|
|
||||||
dir_path.chmod(mode)
|
|
||||||
self.status.log(f"Created directory: {path}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to create/configure directory {path}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def setup_standard_directories(self, user_id: int, group_id: int) -> bool:
|
|
||||||
"""Set up standard Cubbi directories"""
|
|
||||||
directories = [
|
|
||||||
("/app", 0o755),
|
|
||||||
("/cubbi-config", 0o755),
|
|
||||||
("/cubbi-config/home", 0o755),
|
|
||||||
]
|
|
||||||
|
|
||||||
self.status.log("Setting up standard directories")
|
|
||||||
|
|
||||||
success = True
|
|
||||||
for dir_path, mode in directories:
|
|
||||||
if not self.create_directory(dir_path, user_id, group_id, mode):
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Create /home/cubbi as a symlink to /cubbi-config/home
|
|
||||||
try:
|
|
||||||
home_cubbi = Path("/home/cubbi")
|
|
||||||
if home_cubbi.exists() or home_cubbi.is_symlink():
|
|
||||||
home_cubbi.unlink()
|
|
||||||
|
|
||||||
self.status.log("Creating /home/cubbi as symlink to /cubbi-config/home")
|
|
||||||
home_cubbi.symlink_to("/cubbi-config/home")
|
|
||||||
os.lchown("/home/cubbi", user_id, group_id)
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to create home directory symlink: {e}", "ERROR")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Create .local directory in the persistent home
|
|
||||||
local_dir = Path("/cubbi-config/home/.local")
|
|
||||||
if not self.create_directory(str(local_dir), user_id, group_id, 0o755):
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Copy /root/.local/bin to user's home if it exists
|
|
||||||
root_local_bin = Path("/root/.local/bin")
|
|
||||||
if root_local_bin.exists():
|
|
||||||
user_local_bin = Path("/cubbi-config/home/.local/bin")
|
|
||||||
try:
|
|
||||||
user_local_bin.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for item in root_local_bin.iterdir():
|
|
||||||
if item.is_file():
|
|
||||||
shutil.copy2(item, user_local_bin / item.name)
|
|
||||||
elif item.is_dir():
|
|
||||||
shutil.copytree(
|
|
||||||
item, user_local_bin / item.name, dirs_exist_ok=True
|
|
||||||
)
|
|
||||||
|
|
||||||
self._chown_recursive(user_local_bin, user_id, group_id)
|
|
||||||
self.status.log("Copied /root/.local/bin to user directory")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to copy /root/.local/bin: {e}", "ERROR")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
def _chown_recursive(self, path: Path, user_id: int, group_id: int) -> None:
|
|
||||||
"""Recursively change ownership of a directory"""
|
|
||||||
try:
|
|
||||||
os.chown(path, user_id, group_id)
|
|
||||||
for item in path.iterdir():
|
|
||||||
if item.is_dir():
|
|
||||||
self._chown_recursive(item, user_id, group_id)
|
|
||||||
else:
|
|
||||||
os.chown(item, user_id, group_id)
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Warning: Could not change ownership of {path}: {e}", "WARNING"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
|
||||||
"""Manages persistent configuration symlinks and mappings"""
|
|
||||||
|
|
||||||
def __init__(self, status: StatusManager):
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
def create_symlink(
|
|
||||||
self, source_path: str, target_path: str, user_id: int, group_id: int
|
|
||||||
) -> bool:
|
|
||||||
"""Create a symlink with proper ownership"""
|
|
||||||
try:
|
|
||||||
source = Path(source_path)
|
|
||||||
|
|
||||||
parent_dir = source.parent
|
|
||||||
if not parent_dir.exists():
|
|
||||||
self.status.log(f"Creating parent directory: {parent_dir}")
|
|
||||||
parent_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chown(parent_dir, user_id, group_id)
|
|
||||||
|
|
||||||
self.status.log(f"Creating symlink: {source_path} -> {target_path}")
|
|
||||||
if source.is_symlink() or source.exists():
|
|
||||||
source.unlink()
|
|
||||||
|
|
||||||
source.symlink_to(target_path)
|
|
||||||
os.lchown(source_path, user_id, group_id)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to create symlink {source_path} -> {target_path}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _ensure_target_directory(
|
|
||||||
self, target_path: str, user_id: int, group_id: int
|
|
||||||
) -> bool:
|
|
||||||
"""Ensure the target directory exists with proper ownership"""
|
|
||||||
try:
|
|
||||||
target_dir = Path(target_path)
|
|
||||||
if not target_dir.exists():
|
|
||||||
self.status.log(f"Creating target directory: {target_path}")
|
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Set ownership of the target directory to cubbi user
|
|
||||||
os.chown(target_path, user_id, group_id)
|
|
||||||
self.status.log(f"Set ownership of {target_path} to {user_id}:{group_id}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to ensure target directory {target_path}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def setup_persistent_configs(
|
|
||||||
self, persistent_configs: List[PersistentConfig], user_id: int, group_id: int
|
|
||||||
) -> bool:
|
|
||||||
"""Set up persistent configuration symlinks from image config"""
|
|
||||||
if not persistent_configs:
|
|
||||||
self.status.log("No persistent configurations defined in image config")
|
|
||||||
return True
|
|
||||||
|
|
||||||
success = True
|
|
||||||
for config in persistent_configs:
|
|
||||||
# Ensure target directory exists with proper ownership
|
|
||||||
if not self._ensure_target_directory(config.target, user_id, group_id):
|
|
||||||
success = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not self.create_symlink(config.source, config.target, user_id, group_id):
|
|
||||||
success = False
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
|
|
||||||
class CommandManager:
|
|
||||||
"""Manages command execution and user switching"""
|
|
||||||
|
|
||||||
def __init__(self, status: StatusManager):
|
|
||||||
self.status = status
|
|
||||||
self.username = "cubbi"
|
|
||||||
|
|
||||||
def run_as_user(self, command: List[str], user: str = None) -> int:
|
|
||||||
"""Run a command as the specified user using gosu"""
|
|
||||||
if user is None:
|
|
||||||
user = self.username
|
|
||||||
|
|
||||||
full_command = ["gosu", user] + command
|
|
||||||
self.status.log(f"Executing as {user}: {' '.join(command)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(full_command, check=False)
|
|
||||||
return result.returncode
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to execute command: {e}", "ERROR")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def run_user_command(self, command: str) -> int:
|
|
||||||
"""Run user-specified command as cubbi user"""
|
|
||||||
if not command:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
self.status.log(f"Executing user command: {command}")
|
|
||||||
return self.run_as_user(["sh", "-c", command])
|
|
||||||
|
|
||||||
def exec_as_user(self, args: List[str]) -> None:
|
|
||||||
"""Execute the final command as cubbi user (replaces current process)"""
|
|
||||||
if not args:
|
|
||||||
args = ["tail", "-f", "/dev/null"]
|
|
||||||
|
|
||||||
self.status.log(
|
|
||||||
f"Switching to user '{self.username}' and executing: {' '.join(args)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.execvp("gosu", ["gosu", self.username] + args)
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to exec as user: {e}", "ERROR")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# Tool Plugin System
|
|
||||||
class ToolPlugin(ABC):
|
|
||||||
"""Base class for tool-specific initialization plugins"""
|
|
||||||
|
|
||||||
def __init__(self, status: StatusManager, config: Dict[str, Any]):
|
|
||||||
self.status = status
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def tool_name(self) -> str:
|
|
||||||
"""Return the name of the tool this plugin supports"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""Main tool initialization logic"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool:
|
|
||||||
"""Integrate with available MCP servers"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# Main Initializer
|
|
||||||
class CubbiInitializer:
|
|
||||||
"""Main Cubbi initialization orchestrator"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.status = StatusManager()
|
|
||||||
self.config_parser = ConfigParser()
|
|
||||||
self.user_manager = UserManager(self.status)
|
|
||||||
self.directory_manager = DirectoryManager(self.status)
|
|
||||||
self.config_manager = ConfigManager(self.status)
|
|
||||||
self.command_manager = CommandManager(self.status)
|
|
||||||
|
|
||||||
def run_initialization(self, final_args: List[str]) -> None:
|
|
||||||
"""Run the complete initialization process"""
|
|
||||||
try:
|
|
||||||
self.status.start_initialization()
|
|
||||||
|
|
||||||
# Load configuration
|
|
||||||
image_config = self.config_parser.load_image_config()
|
|
||||||
cubbi_config = self.config_parser.get_cubbi_config()
|
|
||||||
mcp_config = self.config_parser.get_mcp_config()
|
|
||||||
|
|
||||||
self.status.log(f"Initializing {image_config.name} v{image_config.version}")
|
|
||||||
|
|
||||||
# Core initialization
|
|
||||||
success = self._run_core_initialization(image_config, cubbi_config)
|
|
||||||
if not success:
|
|
||||||
self.status.log("Core initialization failed", "ERROR")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Tool-specific initialization
|
|
||||||
success = self._run_tool_initialization(
|
|
||||||
image_config, cubbi_config, mcp_config
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
self.status.log("Tool initialization failed", "ERROR")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Mark complete
|
|
||||||
self.status.complete_initialization()
|
|
||||||
|
|
||||||
# Handle commands
|
|
||||||
self._handle_command_execution(cubbi_config, final_args)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Initialization failed with error: {e}", "ERROR")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def _run_core_initialization(self, image_config, cubbi_config) -> bool:
|
|
||||||
"""Run core Cubbi initialization steps"""
|
|
||||||
user_id = cubbi_config["user_id"]
|
|
||||||
group_id = cubbi_config["group_id"]
|
|
||||||
|
|
||||||
if not self.user_manager.setup_user_and_group(user_id, group_id):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not self.directory_manager.setup_standard_directories(user_id, group_id):
|
|
||||||
return False
|
|
||||||
|
|
||||||
config_path = Path(cubbi_config["config_dir"])
|
|
||||||
if not config_path.exists():
|
|
||||||
self.status.log(f"Creating config directory: {cubbi_config['config_dir']}")
|
|
||||||
try:
|
|
||||||
config_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chown(cubbi_config["config_dir"], user_id, group_id)
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to create config directory: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not self.config_manager.setup_persistent_configs(
|
|
||||||
image_config.persistent_configs, user_id, group_id
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _run_tool_initialization(self, image_config, cubbi_config, mcp_config) -> bool:
|
|
||||||
"""Run tool-specific initialization"""
|
|
||||||
# Look for a tool-specific plugin file in the same directory
|
|
||||||
plugin_name = image_config.name.lower().replace("-", "_")
|
|
||||||
plugin_file = Path(__file__).parent / f"{plugin_name}_plugin.py"
|
|
||||||
|
|
||||||
if not plugin_file.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"No tool-specific plugin found at {plugin_file}, skipping tool initialization"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Dynamically load the plugin module
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
f"{image_config.name.lower()}_plugin", plugin_file
|
|
||||||
)
|
|
||||||
plugin_module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(plugin_module)
|
|
||||||
|
|
||||||
# Find the plugin class (should inherit from ToolPlugin)
|
|
||||||
plugin_class = None
|
|
||||||
for attr_name in dir(plugin_module):
|
|
||||||
attr = getattr(plugin_module, attr_name)
|
|
||||||
if (
|
|
||||||
isinstance(attr, type)
|
|
||||||
and hasattr(attr, "tool_name")
|
|
||||||
and hasattr(attr, "initialize")
|
|
||||||
and attr_name != "ToolPlugin"
|
|
||||||
): # Skip the base class
|
|
||||||
plugin_class = attr
|
|
||||||
break
|
|
||||||
|
|
||||||
if not plugin_class:
|
|
||||||
self.status.log(
|
|
||||||
f"No valid plugin class found in {plugin_file}", "ERROR"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Instantiate and run the plugin
|
|
||||||
plugin = plugin_class(
|
|
||||||
self.status,
|
|
||||||
{
|
|
||||||
"image_config": image_config,
|
|
||||||
"cubbi_config": cubbi_config,
|
|
||||||
"mcp_config": mcp_config,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.status.log(f"Running {plugin.tool_name}-specific initialization")
|
|
||||||
|
|
||||||
if not plugin.initialize():
|
|
||||||
self.status.log(f"{plugin.tool_name} initialization failed", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not plugin.integrate_mcp_servers(mcp_config):
|
|
||||||
self.status.log(f"{plugin.tool_name} MCP integration failed", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to load or execute plugin {plugin_file}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _handle_command_execution(self, cubbi_config, final_args):
|
|
||||||
"""Handle command execution"""
|
|
||||||
exit_code = 0
|
|
||||||
|
|
||||||
if cubbi_config["run_command"]:
|
|
||||||
self.status.log("--- Executing initial command ---")
|
|
||||||
exit_code = self.command_manager.run_user_command(
|
|
||||||
cubbi_config["run_command"]
|
|
||||||
)
|
|
||||||
self.status.log(
|
|
||||||
f"--- Initial command finished (exit code: {exit_code}) ---"
|
|
||||||
)
|
|
||||||
|
|
||||||
if cubbi_config["no_shell"]:
|
|
||||||
self.status.log(
|
|
||||||
"--- CUBBI_NO_SHELL=true, exiting container without starting shell ---"
|
|
||||||
)
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
self.command_manager.exec_as_user(final_args)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
"""Main CLI entry point"""
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Cubbi container initialization script",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="""
|
|
||||||
This script initializes a Cubbi container environment by:
|
|
||||||
1. Setting up user and group with proper IDs
|
|
||||||
2. Creating standard directories with correct permissions
|
|
||||||
3. Setting up persistent configuration symlinks
|
|
||||||
4. Running tool-specific initialization if available
|
|
||||||
5. Executing user commands or starting an interactive shell
|
|
||||||
|
|
||||||
Environment Variables:
|
|
||||||
CUBBI_USER_ID User ID for the cubbi user (default: 1000)
|
|
||||||
CUBBI_GROUP_ID Group ID for the cubbi user (default: 1000)
|
|
||||||
CUBBI_RUN_COMMAND Initial command to run before shell
|
|
||||||
CUBBI_NO_SHELL Exit after run command instead of starting shell
|
|
||||||
CUBBI_CONFIG_DIR Configuration directory path (default: /cubbi-config)
|
|
||||||
MCP_COUNT Number of MCP servers to configure
|
|
||||||
MCP_<N>_NAME Name of MCP server N
|
|
||||||
MCP_<N>_TYPE Type of MCP server N
|
|
||||||
MCP_<N>_HOST Host of MCP server N
|
|
||||||
MCP_<N>_URL URL of MCP server N
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
cubbi_init.py # Initialize and start bash shell
|
|
||||||
cubbi_init.py --help # Show this help message
|
|
||||||
cubbi_init.py /bin/zsh # Initialize and start zsh shell
|
|
||||||
cubbi_init.py python script.py # Initialize and run python script
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"command",
|
|
||||||
nargs="*",
|
|
||||||
help="Command to execute after initialization (default: interactive shell)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse known args to handle cases where the command might have its own arguments
|
|
||||||
args, unknown = parser.parse_known_args()
|
|
||||||
|
|
||||||
# Combine parsed command with unknown args
|
|
||||||
final_args = args.command + unknown
|
|
||||||
|
|
||||||
# Handle the common case where docker CMD passes ["tail", "-f", "/dev/null"]
|
|
||||||
# This should be treated as "no specific command" (empty args)
|
|
||||||
if final_args == ["tail", "-f", "/dev/null"]:
|
|
||||||
final_args = []
|
|
||||||
|
|
||||||
initializer = CubbiInitializer()
|
|
||||||
initializer.run_initialization(final_args)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Goose Image for Cubbi
|
|
||||||
|
|
||||||
This image provides a containerized environment for running [Goose](https://goose.ai).
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Pre-configured environment for Goose AI
|
|
||||||
- Langfuse logging support
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### Goose Configuration
|
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `CUBBI_MODEL` | Model to use with Goose | No | - |
|
|
||||||
| `CUBBI_PROVIDER` | Provider to use with Goose | No | - |
|
|
||||||
|
|
||||||
### Langfuse Integration
|
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `LANGFUSE_INIT_PROJECT_PUBLIC_KEY` | Langfuse public key | No | - |
|
|
||||||
| `LANGFUSE_INIT_PROJECT_SECRET_KEY` | Langfuse secret key | No | - |
|
|
||||||
| `LANGFUSE_URL` | Langfuse API URL | No | `https://cloud.langfuse.com` |
|
|
||||||
|
|
||||||
### Cubbi Core Variables
|
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `CUBBI_USER_ID` | UID for the container user | No | `1000` |
|
|
||||||
| `CUBBI_GROUP_ID` | GID for the container user | No | `1000` |
|
|
||||||
| `CUBBI_RUN_COMMAND` | Command to execute after initialization | No | - |
|
|
||||||
| `CUBBI_NO_SHELL` | Exit after command execution | No | `false` |
|
|
||||||
| `CUBBI_CONFIG_DIR` | Directory for persistent configurations | No | `/cubbi-config` |
|
|
||||||
| `CUBBI_PERSISTENT_LINKS` | Semicolon-separated list of source:target symlinks | No | - |
|
|
||||||
|
|
||||||
### MCP Integration Variables
|
|
||||||
|
|
||||||
| Variable | Description | Required |
|
|
||||||
|----------|-------------|----------|
|
|
||||||
| `MCP_COUNT` | Number of available MCP servers | No |
|
|
||||||
| `MCP_NAMES` | JSON array of MCP server names | No |
|
|
||||||
| `MCP_{idx}_NAME` | Name of MCP server at index | No |
|
|
||||||
| `MCP_{idx}_TYPE` | Type of MCP server | No |
|
|
||||||
| `MCP_{idx}_HOST` | Hostname of MCP server | No |
|
|
||||||
| `MCP_{idx}_URL` | Full URL for remote MCP servers | No |
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
To build this image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd drivers/goose
|
|
||||||
docker build -t monadical/cubbi-goose:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new session with this image
|
|
||||||
cubbix -i goose
|
|
||||||
```
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: goose
|
|
||||||
description: Goose AI environment
|
|
||||||
version: 1.0.0
|
|
||||||
maintainer: team@monadical.com
|
|
||||||
image: monadical/cubbi-goose:latest
|
|
||||||
|
|
||||||
init:
|
|
||||||
pre_command: /cubbi-init.sh
|
|
||||||
command: /entrypoint.sh
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- name: LANGFUSE_INIT_PROJECT_PUBLIC_KEY
|
|
||||||
description: Langfuse public key
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
- name: LANGFUSE_INIT_PROJECT_SECRET_KEY
|
|
||||||
description: Langfuse secret key
|
|
||||||
required: false
|
|
||||||
sensitive: true
|
|
||||||
|
|
||||||
- name: LANGFUSE_URL
|
|
||||||
description: Langfuse API URL
|
|
||||||
required: false
|
|
||||||
default: https://cloud.langfuse.com
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- mountPath: /app
|
|
||||||
description: Application directory
|
|
||||||
|
|
||||||
persistent_configs:
|
|
||||||
- source: "/app/.goose"
|
|
||||||
target: "/cubbi-config/goose-app"
|
|
||||||
type: "directory"
|
|
||||||
description: "Goose memory"
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Goose-specific plugin for Cubbi initialization
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from cubbi_init import ToolPlugin
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
|
|
||||||
|
|
||||||
class GoosePlugin(ToolPlugin):
|
|
||||||
"""Plugin for Goose AI tool initialization"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tool_name(self) -> str:
|
|
||||||
return "goose"
|
|
||||||
|
|
||||||
def _get_user_ids(self) -> tuple[int, int]:
|
|
||||||
"""Get the cubbi user and group IDs from environment"""
|
|
||||||
user_id = int(os.environ.get("CUBBI_USER_ID", "1000"))
|
|
||||||
group_id = int(os.environ.get("CUBBI_GROUP_ID", "1000"))
|
|
||||||
return user_id, group_id
|
|
||||||
|
|
||||||
def _set_ownership(self, path: Path) -> None:
|
|
||||||
"""Set ownership of a path to the cubbi user"""
|
|
||||||
user_id, group_id = self._get_user_ids()
|
|
||||||
try:
|
|
||||||
os.chown(path, user_id, group_id)
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(f"Failed to set ownership for {path}: {e}", "WARNING")
|
|
||||||
|
|
||||||
def _get_user_config_path(self) -> Path:
|
|
||||||
"""Get the correct config path for the cubbi user"""
|
|
||||||
return Path("/home/cubbi/.config/goose")
|
|
||||||
|
|
||||||
def _ensure_user_config_dir(self) -> Path:
|
|
||||||
"""Ensure config directory exists with correct ownership"""
|
|
||||||
config_dir = self._get_user_config_path()
|
|
||||||
|
|
||||||
# Create the full directory path
|
|
||||||
try:
|
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
except FileExistsError:
|
|
||||||
# Directory already exists, which is fine
|
|
||||||
pass
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to create config directory {config_dir}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
return config_dir
|
|
||||||
|
|
||||||
# Set ownership for the directories
|
|
||||||
config_parent = config_dir.parent
|
|
||||||
if config_parent.exists():
|
|
||||||
self._set_ownership(config_parent)
|
|
||||||
|
|
||||||
if config_dir.exists():
|
|
||||||
self._set_ownership(config_dir)
|
|
||||||
|
|
||||||
return config_dir
|
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""Initialize Goose configuration"""
|
|
||||||
self._ensure_user_config_dir()
|
|
||||||
return self.setup_tool_configuration()
|
|
||||||
|
|
||||||
def setup_tool_configuration(self) -> bool:
|
|
||||||
"""Set up Goose configuration file"""
|
|
||||||
# Ensure directory exists before writing
|
|
||||||
config_dir = self._ensure_user_config_dir()
|
|
||||||
if not config_dir.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"Config directory {config_dir} does not exist and could not be created",
|
|
||||||
"ERROR",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
config_file = config_dir / "config.yaml"
|
|
||||||
yaml = YAML(typ="safe")
|
|
||||||
|
|
||||||
# Load or initialize configuration
|
|
||||||
if config_file.exists():
|
|
||||||
with config_file.open("r") as f:
|
|
||||||
config_data = yaml.load(f) or {}
|
|
||||||
else:
|
|
||||||
config_data = {}
|
|
||||||
|
|
||||||
if "extensions" not in config_data:
|
|
||||||
config_data["extensions"] = {}
|
|
||||||
|
|
||||||
# Add default developer extension
|
|
||||||
config_data["extensions"]["developer"] = {
|
|
||||||
"enabled": True,
|
|
||||||
"name": "developer",
|
|
||||||
"timeout": 300,
|
|
||||||
"type": "builtin",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update with environment variables
|
|
||||||
goose_model = os.environ.get("CUBBI_MODEL")
|
|
||||||
goose_provider = os.environ.get("CUBBI_PROVIDER")
|
|
||||||
|
|
||||||
if goose_model:
|
|
||||||
config_data["GOOSE_MODEL"] = goose_model
|
|
||||||
self.status.log(f"Set GOOSE_MODEL to {goose_model}")
|
|
||||||
|
|
||||||
if goose_provider:
|
|
||||||
config_data["GOOSE_PROVIDER"] = goose_provider
|
|
||||||
self.status.log(f"Set GOOSE_PROVIDER to {goose_provider}")
|
|
||||||
|
|
||||||
# If provider is OpenAI and OPENAI_URL is set, configure OPENAI_HOST
|
|
||||||
if goose_provider.lower() == "openai":
|
|
||||||
openai_url = os.environ.get("OPENAI_URL")
|
|
||||||
if openai_url:
|
|
||||||
config_data["OPENAI_HOST"] = openai_url
|
|
||||||
self.status.log(f"Set OPENAI_HOST to {openai_url}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with config_file.open("w") as f:
|
|
||||||
yaml.dump(config_data, f)
|
|
||||||
|
|
||||||
# Set ownership of the config file to cubbi user
|
|
||||||
self._set_ownership(config_file)
|
|
||||||
|
|
||||||
self.status.log(f"Updated Goose configuration at {config_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to write Goose configuration: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool:
|
|
||||||
"""Integrate Goose with available MCP servers"""
|
|
||||||
if mcp_config["count"] == 0:
|
|
||||||
self.status.log("No MCP servers to integrate")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Ensure directory exists before writing
|
|
||||||
config_dir = self._ensure_user_config_dir()
|
|
||||||
if not config_dir.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"Config directory {config_dir} does not exist and could not be created",
|
|
||||||
"ERROR",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
config_file = config_dir / "config.yaml"
|
|
||||||
yaml = YAML(typ="safe")
|
|
||||||
|
|
||||||
if config_file.exists():
|
|
||||||
with config_file.open("r") as f:
|
|
||||||
config_data = yaml.load(f) or {}
|
|
||||||
else:
|
|
||||||
config_data = {"extensions": {}}
|
|
||||||
|
|
||||||
if "extensions" not in config_data:
|
|
||||||
config_data["extensions"] = {}
|
|
||||||
|
|
||||||
for server in mcp_config["servers"]:
|
|
||||||
server_name = server["name"]
|
|
||||||
server_host = server["host"]
|
|
||||||
server_url = server["url"]
|
|
||||||
|
|
||||||
if server_name and server_host:
|
|
||||||
mcp_url = f"http://{server_host}:8080/sse"
|
|
||||||
self.status.log(f"Adding MCP extension: {server_name} - {mcp_url}")
|
|
||||||
|
|
||||||
config_data["extensions"][server_name] = {
|
|
||||||
"enabled": True,
|
|
||||||
"name": server_name,
|
|
||||||
"timeout": 60,
|
|
||||||
"type": server.get("type", "sse"),
|
|
||||||
"uri": mcp_url,
|
|
||||||
"envs": {},
|
|
||||||
}
|
|
||||||
elif server_name and server_url:
|
|
||||||
self.status.log(
|
|
||||||
f"Adding remote MCP extension: {server_name} - {server_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
config_data["extensions"][server_name] = {
|
|
||||||
"enabled": True,
|
|
||||||
"name": server_name,
|
|
||||||
"timeout": 60,
|
|
||||||
"type": server.get("type", "sse"),
|
|
||||||
"uri": server_url,
|
|
||||||
"envs": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with config_file.open("w") as f:
|
|
||||||
yaml.dump(config_data, f)
|
|
||||||
|
|
||||||
# Set ownership of the config file to cubbi user
|
|
||||||
self._set_ownership(config_file)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to integrate MCP servers: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
LABEL maintainer="team@monadical.com"
|
|
||||||
LABEL description="Opencode for Cubbi"
|
|
||||||
|
|
||||||
# Install system dependencies including gosu for user switching and shadow for useradd/groupadd
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
gosu \
|
|
||||||
sudo \
|
|
||||||
passwd \
|
|
||||||
bash \
|
|
||||||
curl \
|
|
||||||
bzip2 \
|
|
||||||
iputils-ping \
|
|
||||||
iproute2 \
|
|
||||||
libxcb1 \
|
|
||||||
libdbus-1-3 \
|
|
||||||
nano \
|
|
||||||
tmux \
|
|
||||||
git-core \
|
|
||||||
ripgrep \
|
|
||||||
openssh-client \
|
|
||||||
vim \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install deps
|
|
||||||
WORKDIR /tmp
|
|
||||||
RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \
|
|
||||||
sh install.sh && \
|
|
||||||
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
|
||||||
mv /root/.local/bin/uvx /usr/local/bin/uvx && \
|
|
||||||
rm install.sh
|
|
||||||
|
|
||||||
# Install Node.js
|
|
||||||
ARG NODE_VERSION=v22.16.0
|
|
||||||
RUN mkdir -p /opt/node && \
|
|
||||||
ARCH=$(uname -m) && \
|
|
||||||
if [ "$ARCH" = "x86_64" ]; then \
|
|
||||||
NODE_ARCH=linux-x64; \
|
|
||||||
elif [ "$ARCH" = "aarch64" ]; then \
|
|
||||||
NODE_ARCH=linux-arm64; \
|
|
||||||
else \
|
|
||||||
echo "Unsupported architecture"; exit 1; \
|
|
||||||
fi && \
|
|
||||||
curl -fsSL https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-$NODE_ARCH.tar.gz -o node.tar.gz && \
|
|
||||||
tar -xf node.tar.gz -C /opt/node --strip-components=1 && \
|
|
||||||
rm node.tar.gz
|
|
||||||
|
|
||||||
|
|
||||||
ENV PATH="/opt/node/bin:$PATH"
|
|
||||||
RUN npm i -g yarn
|
|
||||||
RUN npm i -g opencode-ai
|
|
||||||
|
|
||||||
# Create app directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy initialization system
|
|
||||||
COPY cubbi_init.py /cubbi/cubbi_init.py
|
|
||||||
COPY opencode_plugin.py /cubbi/opencode_plugin.py
|
|
||||||
COPY cubbi_image.yaml /cubbi/cubbi_image.yaml
|
|
||||||
COPY init-status.sh /cubbi/init-status.sh
|
|
||||||
RUN chmod +x /cubbi/cubbi_init.py /cubbi/init-status.sh
|
|
||||||
RUN echo 'PATH="/opt/node/bin:$PATH"' >> /etc/bash.bashrc
|
|
||||||
RUN echo '[ -x /cubbi/init-status.sh ] && /cubbi/init-status.sh' >> /etc/bash.bashrc
|
|
||||||
|
|
||||||
# Set up environment
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ENV UV_LINK_MODE=copy
|
|
||||||
ENV COLORTERM=truecolor
|
|
||||||
|
|
||||||
# Pre-install the cubbi_init
|
|
||||||
RUN /cubbi/cubbi_init.py --help
|
|
||||||
|
|
||||||
# Set WORKDIR to /app, common practice and expected by cubbi-init.sh
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/cubbi/cubbi_init.py"]
|
|
||||||
CMD ["tail", "-f", "/dev/null"]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Opencode Image for Cubbi
|
|
||||||
|
|
||||||
This image provides a containerized environment for running [Opencode](https://opencode.ai).
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Pre-configured environment for Opencode AI
|
|
||||||
- Langfuse logging support
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### Opencode Configuration
|
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `CUBBI_MODEL` | Model to use with Opencode | No | - |
|
|
||||||
| `CUBBI_PROVIDER` | Provider to use with Opencode | No | - |
|
|
||||||
|
|
||||||
### Cubbi Core Variables
|
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
|
||||||
|----------|-------------|----------|---------|
|
|
||||||
| `CUBBI_USER_ID` | UID for the container user | No | `1000` |
|
|
||||||
| `CUBBI_GROUP_ID` | GID for the container user | No | `1000` |
|
|
||||||
| `CUBBI_RUN_COMMAND` | Command to execute after initialization | No | - |
|
|
||||||
| `CUBBI_NO_SHELL` | Exit after command execution | No | `false` |
|
|
||||||
| `CUBBI_CONFIG_DIR` | Directory for persistent configurations | No | `/cubbi-config` |
|
|
||||||
| `CUBBI_PERSISTENT_LINKS` | Semicolon-separated list of source:target symlinks | No | - |
|
|
||||||
|
|
||||||
### MCP Integration Variables
|
|
||||||
|
|
||||||
| Variable | Description | Required |
|
|
||||||
|----------|-------------|----------|
|
|
||||||
| `MCP_COUNT` | Number of available MCP servers | No |
|
|
||||||
| `MCP_NAMES` | JSON array of MCP server names | No |
|
|
||||||
| `MCP_{idx}_NAME` | Name of MCP server at index | No |
|
|
||||||
| `MCP_{idx}_TYPE` | Type of MCP server | No |
|
|
||||||
| `MCP_{idx}_HOST` | Hostname of MCP server | No |
|
|
||||||
| `MCP_{idx}_URL` | Full URL for remote MCP servers | No |
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
To build this image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd drivers/opencode
|
|
||||||
docker build -t monadical/cubbi-opencode:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new session with this image
|
|
||||||
cubbix -i opencode
|
|
||||||
```
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: opencode
|
|
||||||
description: Opencode AI environment
|
|
||||||
version: 1.0.0
|
|
||||||
maintainer: team@monadical.com
|
|
||||||
image: monadical/cubbi-opencode:latest
|
|
||||||
|
|
||||||
init:
|
|
||||||
pre_command: /cubbi-init.sh
|
|
||||||
command: /entrypoint.sh
|
|
||||||
|
|
||||||
environment: []
|
|
||||||
volumes:
|
|
||||||
- mountPath: /app
|
|
||||||
description: Application directory
|
|
||||||
|
|
||||||
persistent_configs:
|
|
||||||
- source: "/home/cubbi/.config/opencode"
|
|
||||||
target: "/cubbi-config/config-opencode"
|
|
||||||
type: "directory"
|
|
||||||
description: "Opencode configuration"
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Opencode-specific plugin for Cubbi initialization
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from cubbi_init import ToolPlugin
|
|
||||||
|
|
||||||
# Map of environment variables to provider names in auth.json
|
|
||||||
API_KEY_MAPPINGS = {
|
|
||||||
"ANTHROPIC_API_KEY": "anthropic",
|
|
||||||
"GOOGLE_API_KEY": "google",
|
|
||||||
"OPENAI_API_KEY": "openai",
|
|
||||||
"OPENROUTER_API_KEY": "openrouter",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class OpencodePlugin(ToolPlugin):
|
|
||||||
"""Plugin for Opencode AI tool initialization"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tool_name(self) -> str:
|
|
||||||
return "opencode"
|
|
||||||
|
|
||||||
def _get_user_ids(self) -> tuple[int, int]:
|
|
||||||
"""Get the cubbi user and group IDs from environment"""
|
|
||||||
user_id = int(os.environ.get("CUBBI_USER_ID", "1000"))
|
|
||||||
group_id = int(os.environ.get("CUBBI_GROUP_ID", "1000"))
|
|
||||||
return user_id, group_id
|
|
||||||
|
|
||||||
def _set_ownership(self, path: Path) -> None:
|
|
||||||
"""Set ownership of a path to the cubbi user"""
|
|
||||||
user_id, group_id = self._get_user_ids()
|
|
||||||
try:
|
|
||||||
os.chown(path, user_id, group_id)
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(f"Failed to set ownership for {path}: {e}", "WARNING")
|
|
||||||
|
|
||||||
def _get_user_config_path(self) -> Path:
|
|
||||||
"""Get the correct config path for the cubbi user"""
|
|
||||||
return Path("/home/cubbi/.config/opencode")
|
|
||||||
|
|
||||||
def _get_user_data_path(self) -> Path:
|
|
||||||
"""Get the correct data path for the cubbi user"""
|
|
||||||
return Path("/home/cubbi/.local/share/opencode")
|
|
||||||
|
|
||||||
def _ensure_user_config_dir(self) -> Path:
|
|
||||||
"""Ensure config directory exists with correct ownership"""
|
|
||||||
config_dir = self._get_user_config_path()
|
|
||||||
|
|
||||||
# Create the full directory path
|
|
||||||
try:
|
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
except FileExistsError:
|
|
||||||
# Directory already exists, which is fine
|
|
||||||
pass
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(
|
|
||||||
f"Failed to create config directory {config_dir}: {e}", "ERROR"
|
|
||||||
)
|
|
||||||
return config_dir
|
|
||||||
|
|
||||||
# Set ownership for the directories
|
|
||||||
config_parent = config_dir.parent
|
|
||||||
if config_parent.exists():
|
|
||||||
self._set_ownership(config_parent)
|
|
||||||
|
|
||||||
if config_dir.exists():
|
|
||||||
self._set_ownership(config_dir)
|
|
||||||
|
|
||||||
return config_dir
|
|
||||||
|
|
||||||
def _ensure_user_data_dir(self) -> Path:
|
|
||||||
"""Ensure data directory exists with correct ownership"""
|
|
||||||
data_dir = self._get_user_data_path()
|
|
||||||
|
|
||||||
# Create the full directory path
|
|
||||||
try:
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
except FileExistsError:
|
|
||||||
# Directory already exists, which is fine
|
|
||||||
pass
|
|
||||||
except OSError as e:
|
|
||||||
self.status.log(f"Failed to create data directory {data_dir}: {e}", "ERROR")
|
|
||||||
return data_dir
|
|
||||||
|
|
||||||
# Set ownership for the directories
|
|
||||||
data_parent = data_dir.parent
|
|
||||||
if data_parent.exists():
|
|
||||||
self._set_ownership(data_parent)
|
|
||||||
|
|
||||||
if data_dir.exists():
|
|
||||||
self._set_ownership(data_dir)
|
|
||||||
|
|
||||||
return data_dir
|
|
||||||
|
|
||||||
def _create_auth_file(self) -> bool:
|
|
||||||
"""Create auth.json file with configured API keys"""
|
|
||||||
# Ensure data directory exists
|
|
||||||
data_dir = self._ensure_user_data_dir()
|
|
||||||
if not data_dir.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"Data directory {data_dir} does not exist and could not be created",
|
|
||||||
"ERROR",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
auth_file = data_dir / "auth.json"
|
|
||||||
auth_data = {}
|
|
||||||
|
|
||||||
# Check each API key and add to auth data if present
|
|
||||||
for env_var, provider in API_KEY_MAPPINGS.items():
|
|
||||||
api_key = os.environ.get(env_var)
|
|
||||||
if api_key:
|
|
||||||
auth_data[provider] = {"type": "api", "key": api_key}
|
|
||||||
|
|
||||||
# Add custom endpoint URL for OpenAI if available
|
|
||||||
if provider == "openai":
|
|
||||||
openai_url = os.environ.get("OPENAI_URL")
|
|
||||||
if openai_url:
|
|
||||||
auth_data[provider]["baseURL"] = openai_url
|
|
||||||
self.status.log(
|
|
||||||
f"Added OpenAI custom endpoint URL: {openai_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.status.log(f"Added {provider} API key to auth configuration")
|
|
||||||
|
|
||||||
# Only write file if we have at least one API key
|
|
||||||
if not auth_data:
|
|
||||||
self.status.log("No API keys found, skipping auth.json creation")
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
with auth_file.open("w") as f:
|
|
||||||
json.dump(auth_data, f, indent=2)
|
|
||||||
|
|
||||||
# Set ownership of the auth file to cubbi user
|
|
||||||
self._set_ownership(auth_file)
|
|
||||||
|
|
||||||
# Set secure permissions (readable only by owner)
|
|
||||||
auth_file.chmod(0o600)
|
|
||||||
|
|
||||||
self.status.log(f"Created OpenCode auth configuration at {auth_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to create auth configuration: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""Initialize Opencode configuration"""
|
|
||||||
self._ensure_user_config_dir()
|
|
||||||
|
|
||||||
# Create auth.json file with API keys
|
|
||||||
auth_success = self._create_auth_file()
|
|
||||||
|
|
||||||
# Set up tool configuration
|
|
||||||
config_success = self.setup_tool_configuration()
|
|
||||||
|
|
||||||
return auth_success and config_success
|
|
||||||
|
|
||||||
def setup_tool_configuration(self) -> bool:
|
|
||||||
"""Set up Opencode configuration file"""
|
|
||||||
# Ensure directory exists before writing
|
|
||||||
config_dir = self._ensure_user_config_dir()
|
|
||||||
if not config_dir.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"Config directory {config_dir} does not exist and could not be created",
|
|
||||||
"ERROR",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
config_file = config_dir / "config.json"
|
|
||||||
|
|
||||||
# Load or initialize configuration
|
|
||||||
if config_file.exists():
|
|
||||||
with config_file.open("r") as f:
|
|
||||||
config_data = json.load(f) or {}
|
|
||||||
else:
|
|
||||||
config_data = {}
|
|
||||||
|
|
||||||
# Set default theme to system
|
|
||||||
config_data.setdefault("theme", "system")
|
|
||||||
|
|
||||||
# Update with environment variables
|
|
||||||
opencode_model = os.environ.get("CUBBI_MODEL")
|
|
||||||
opencode_provider = os.environ.get("CUBBI_PROVIDER")
|
|
||||||
|
|
||||||
if opencode_model and opencode_provider:
|
|
||||||
config_data["model"] = f"{opencode_provider}/{opencode_model}"
|
|
||||||
self.status.log(f"Set model to {config_data['model']}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with config_file.open("w") as f:
|
|
||||||
json.dump(config_data, f, indent=2)
|
|
||||||
|
|
||||||
# Set ownership of the config file to cubbi user
|
|
||||||
self._set_ownership(config_file)
|
|
||||||
|
|
||||||
self.status.log(f"Updated Opencode configuration at {config_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to write Opencode configuration: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool:
|
|
||||||
"""Integrate Opencode with available MCP servers"""
|
|
||||||
if mcp_config["count"] == 0:
|
|
||||||
self.status.log("No MCP servers to integrate")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Ensure directory exists before writing
|
|
||||||
config_dir = self._ensure_user_config_dir()
|
|
||||||
if not config_dir.exists():
|
|
||||||
self.status.log(
|
|
||||||
f"Config directory {config_dir} does not exist and could not be created",
|
|
||||||
"ERROR",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
config_file = config_dir / "config.json"
|
|
||||||
|
|
||||||
if config_file.exists():
|
|
||||||
with config_file.open("r") as f:
|
|
||||||
config_data = json.load(f) or {}
|
|
||||||
else:
|
|
||||||
config_data = {}
|
|
||||||
|
|
||||||
if "mcp" not in config_data:
|
|
||||||
config_data["mcp"] = {}
|
|
||||||
|
|
||||||
for server in mcp_config["servers"]:
|
|
||||||
server_name = server["name"]
|
|
||||||
server_host = server.get("host")
|
|
||||||
server_url = server.get("url")
|
|
||||||
|
|
||||||
if server_name and server_host:
|
|
||||||
mcp_url = f"http://{server_host}:8080/sse"
|
|
||||||
self.status.log(f"Adding MCP extension: {server_name} - {mcp_url}")
|
|
||||||
|
|
||||||
config_data["mcp"][server_name] = {
|
|
||||||
"type": "remote",
|
|
||||||
"url": mcp_url,
|
|
||||||
}
|
|
||||||
elif server_name and server_url:
|
|
||||||
self.status.log(
|
|
||||||
f"Adding remote MCP extension: {server_name} - {server_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
config_data["mcp"][server_name] = {
|
|
||||||
"type": "remote",
|
|
||||||
"url": server_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with config_file.open("w") as f:
|
|
||||||
json.dump(config_data, f, indent=2)
|
|
||||||
|
|
||||||
# Set ownership of the config file to cubbi user
|
|
||||||
self._set_ownership(config_file)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.status.log(f"Failed to integrate MCP servers: {e}", "ERROR")
|
|
||||||
return False
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
# Cubbi - Container Tool
|
# MC - Monadical AI Container Tool
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Cubbi is a command-line tool for managing ephemeral
|
MC (Monadical Container) is a command-line tool for managing ephemeral
|
||||||
containers that run AI tools and development environments. It works with both
|
containers that run AI tools and development environments. It works with both
|
||||||
local Docker and a dedicated remote web service that manages containers in a
|
local Docker and a dedicated remote web service that manages containers in a
|
||||||
Docker-in-Docker (DinD) environment.
|
Docker-in-Docker (DinD) environment.
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
### Cubbi Service
|
### MC Service
|
||||||
- **Web Framework**: FastAPI for high-performance, async API endpoints
|
- **Web Framework**: FastAPI for high-performance, async API endpoints
|
||||||
- **Package Management**: uv (Astral) for dependency management
|
- **Package Management**: uv (Astral) for dependency management
|
||||||
- **Database**: SQLite for development, PostgreSQL for production
|
- **Database**: SQLite for development, PostgreSQL for production
|
||||||
- **Container Management**: Docker SDK for Python
|
- **Container Management**: Docker SDK for Python
|
||||||
- **Authentication**: OAuth 2.0 integration with Authentik
|
- **Authentication**: OAuth 2.0 integration with Authentik
|
||||||
|
|
||||||
### Cubbi CLI
|
### MC CLI
|
||||||
- **Language**: Python
|
- **Language**: Python
|
||||||
- **Package Management**: uv for dependency management
|
- **Package Management**: uv for dependency management
|
||||||
- **Distribution**: Standalone binary via PyInstaller or similar
|
- **Distribution**: Standalone binary via PyInstaller or similar
|
||||||
@@ -26,17 +26,17 @@ Docker-in-Docker (DinD) environment.
|
|||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
1. **CLI Tool (`cubbi`)**: The command-line interface users interact with
|
1. **CLI Tool (`mc`)**: The command-line interface users interact with
|
||||||
2. **Cubbi Service**: A web service that handles remote container execution
|
2. **MC Service**: A web service that handles remote container execution
|
||||||
3. **Container Images**: Predefined container templates for various AI tools
|
3. **Container Drivers**: Predefined container templates for various AI tools
|
||||||
|
|
||||||
### Architecture Diagram
|
### Architecture Diagram
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────────────────┐
|
┌─────────────┐ ┌─────────────────────────┐
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ Cubbi CLI │◄─────────►│ Local Docker Daemon │
|
│ MC CLI │◄─────────►│ Local Docker Daemon │
|
||||||
│ (cubbi) │ │ │
|
│ (mc) │ │ │
|
||||||
│ │ └─────────────────────────┘
|
│ │ └─────────────────────────┘
|
||||||
└──────┬──────┘
|
└──────┬──────┘
|
||||||
│
|
│
|
||||||
@@ -44,8 +44,8 @@ Docker-in-Docker (DinD) environment.
|
|||||||
│
|
│
|
||||||
┌──────▼──────┐ ┌─────────────────────────┐
|
┌──────▼──────┐ ┌─────────────────────────┐
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ Cubbi │◄─────────►│ Docker-in-Docker │
|
│ MC Service │◄─────────►│ Docker-in-Docker │
|
||||||
│ Service │ │ │
|
│ (Web API) │ │ │
|
||||||
│ │ └─────────────────────────┘
|
│ │ └─────────────────────────┘
|
||||||
└─────────────┘
|
└─────────────┘
|
||||||
│
|
│
|
||||||
@@ -61,23 +61,23 @@ Docker-in-Docker (DinD) environment.
|
|||||||
|
|
||||||
## Core Concepts
|
## Core Concepts
|
||||||
|
|
||||||
- **Session**: An active container instance with a specific image
|
- **Session**: An active container instance with a specific driver
|
||||||
- **Image**: A predefined container template with specific AI tools installed
|
- **Driver**: A predefined container template with specific AI tools installed
|
||||||
- **Remote**: A configured cubbi service instance
|
- **Remote**: A configured MC service instance
|
||||||
|
|
||||||
## User Configuration
|
## User Configuration
|
||||||
|
|
||||||
Cubbi supports user-specific configuration via a YAML file located at `~/.config/cubbi/config.yaml`. This provides a way to set default values, store service credentials, and customize behavior without modifying code.
|
MC supports user-specific configuration via a YAML file located at `~/.config/mc/config.yaml`. This provides a way to set default values, store service credentials, and customize behavior without modifying code.
|
||||||
|
|
||||||
### Configuration File Structure
|
### Configuration File Structure
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# ~/.config/cubbi/config.yaml
|
# ~/.config/mc/config.yaml
|
||||||
defaults:
|
defaults:
|
||||||
image: "goose" # Default image to use
|
driver: "goose" # Default driver to use
|
||||||
connect: true # Automatically connect after creating session
|
connect: true # Automatically connect after creating session
|
||||||
mount_local: true # Mount local directory by default
|
mount_local: true # Mount local directory by default
|
||||||
networks: [] # Default networks to connect to (besides cubbi-network)
|
networks: [] # Default networks to connect to (besides mc-network)
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Service credentials with simplified naming
|
# Service credentials with simplified naming
|
||||||
@@ -97,17 +97,17 @@ services:
|
|||||||
api_key: "sk-or-..."
|
api_key: "sk-or-..."
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
network: "cubbi-network" # Default Docker network to use
|
network: "mc-network" # Default Docker network to use
|
||||||
socket: "/var/run/docker.sock" # Docker socket path
|
socket: "/var/run/docker.sock" # Docker socket path
|
||||||
|
|
||||||
remote:
|
remote:
|
||||||
default: "production" # Default remote to use
|
default: "production" # Default remote to use
|
||||||
endpoints:
|
endpoints:
|
||||||
production:
|
production:
|
||||||
url: "https://cubbi.monadical.com"
|
url: "https://mc.monadical.com"
|
||||||
auth_method: "oauth"
|
auth_method: "oauth"
|
||||||
staging:
|
staging:
|
||||||
url: "https://cubbi-staging.monadical.com"
|
url: "https://mc-staging.monadical.com"
|
||||||
auth_method: "oauth"
|
auth_method: "oauth"
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
@@ -145,22 +145,22 @@ The simplified configuration names are mapped to environment variables:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View entire configuration
|
# View entire configuration
|
||||||
cubbi config list
|
mc config list
|
||||||
|
|
||||||
# Get specific configuration value
|
# Get specific configuration value
|
||||||
cubbi config get defaults.driver
|
mc config get defaults.driver
|
||||||
|
|
||||||
# Set configuration value (using simplified naming)
|
# Set configuration value (using simplified naming)
|
||||||
cubbi config set langfuse.url "https://cloud.langfuse.com"
|
mc config set langfuse.url "https://cloud.langfuse.com"
|
||||||
cubbi config set openai.api_key "sk-..."
|
mc config set openai.api_key "sk-..."
|
||||||
|
|
||||||
# Network configuration
|
# Network configuration
|
||||||
cubbi config network list # List default networks
|
mc config network list # List default networks
|
||||||
cubbi config network add example-network # Add a network to defaults
|
mc config network add example-network # Add a network to defaults
|
||||||
cubbi config network remove example-network # Remove a network from defaults
|
mc config network remove example-network # Remove a network from defaults
|
||||||
|
|
||||||
# Reset configuration to defaults
|
# Reset configuration to defaults
|
||||||
cubbi config reset
|
mc config reset
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI Tool Commands
|
## CLI Tool Commands
|
||||||
@@ -169,81 +169,81 @@ cubbi config reset
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a new session locally (shorthand)
|
# Create a new session locally (shorthand)
|
||||||
cubbi
|
mc
|
||||||
|
|
||||||
# List active sessions on local system
|
# List active sessions on local system
|
||||||
cubbi session list
|
mc session list
|
||||||
|
|
||||||
# Create a new session locally
|
# Create a new session locally
|
||||||
cubbi session create [OPTIONS]
|
mc session create [OPTIONS]
|
||||||
|
|
||||||
# Create a session with a specific image
|
# Create a session with a specific driver
|
||||||
cubbi session create --image goose
|
mc session create --driver goose
|
||||||
|
|
||||||
# Create a session with a specific project repository
|
# Create a session with a specific project repository
|
||||||
cubbi session create --image goose --project github.com/hello/private
|
mc session create --driver goose --project github.com/hello/private
|
||||||
|
|
||||||
# Create a session with external networks
|
# Create a session with external networks
|
||||||
cubbi session create --network teamnet --network othernetwork
|
mc session create --network teamnet --network othernetwork
|
||||||
|
|
||||||
# Create a session with a project (shorthand)
|
# Create a session with a project (shorthand)
|
||||||
cubbi git@github.com:hello/private
|
mc git@github.com:hello/private
|
||||||
|
|
||||||
# Close a specific session
|
# Close a specific session
|
||||||
cubbi session close <id>
|
mc session close <id>
|
||||||
|
|
||||||
# Connect to an existing session
|
# Connect to an existing session
|
||||||
cubbi session connect <id>
|
mc session connect <id>
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Remote Management
|
### Remote Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add a remote Cubbi service
|
# Add a remote MC service
|
||||||
cubbi remote add <name> <url>
|
mc remote add <name> <url>
|
||||||
|
|
||||||
# List configured remote services
|
# List configured remote services
|
||||||
cubbi remote list
|
mc remote list
|
||||||
|
|
||||||
# Remove a remote service
|
# Remove a remote service
|
||||||
cubbi remote remove <name>
|
mc remote remove <name>
|
||||||
|
|
||||||
# Authenticate with a remote service
|
# Authenticate with a remote service
|
||||||
cubbi -r <remote_name> auth
|
mc -r <remote_name> auth
|
||||||
|
|
||||||
# Create a session on a remote service
|
# Create a session on a remote service
|
||||||
cubbi -r <remote_name> [session create]
|
mc -r <remote_name> [session create]
|
||||||
|
|
||||||
# List sessions on a remote service
|
# List sessions on a remote service
|
||||||
cubbi -r <remote_name> session list
|
mc -r <remote_name> session list
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set environment variables for a session
|
# Set environment variables for a session
|
||||||
cubbi session create -e VAR1=value1 -e VAR2=value2
|
mc session create -e VAR1=value1 -e VAR2=value2
|
||||||
|
|
||||||
# Set environment variables for a remote session
|
# Set environment variables for a remote session
|
||||||
cubbi -r <remote_name> session create -e VAR1=value1
|
mc -r <remote_name> session create -e VAR1=value1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stream logs from a session
|
# Stream logs from a session
|
||||||
cubbi session logs <id>
|
mc session logs <id>
|
||||||
|
|
||||||
# Stream logs with follow option
|
# Stream logs with follow option
|
||||||
cubbi session logs <id> -f
|
mc session logs <id> -f
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cubbi Service Specification
|
## MC Service Specification
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
The Cubbi Service is a web service that manages ephemeral containers in a Docker-in-Docker environment. It provides a REST API for container lifecycle management, authentication, and real-time log streaming.
|
The MC Service is a web service that manages ephemeral containers in a Docker-in-Docker environment. It provides a REST API for container lifecycle management, authentication, and real-time log streaming.
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
@@ -258,19 +258,19 @@ POST /auth/logout - Invalidate current token
|
|||||||
|
|
||||||
### Authentik Integration
|
### Authentik Integration
|
||||||
|
|
||||||
The Cubbi Service integrates with Authentik at https://authentik.monadical.io using OAuth 2.0:
|
The MC Service integrates with Authentik at https://authentik.monadical.io using OAuth 2.0:
|
||||||
|
|
||||||
1. **Application Registration**:
|
1. **Application Registration**:
|
||||||
- Cubbi Service is registered as an OAuth application in Authentik
|
- MC Service is registered as an OAuth application in Authentik
|
||||||
- Configured with redirect URI to `/auth/callback`
|
- Configured with redirect URI to `/auth/callback`
|
||||||
- Assigned appropriate scopes for user identification
|
- Assigned appropriate scopes for user identification
|
||||||
|
|
||||||
2. **Authentication Flow**:
|
2. **Authentication Flow**:
|
||||||
- User initiates authentication via CLI
|
- User initiates authentication via CLI
|
||||||
- Cubbi CLI opens browser to Authentik authorization URL
|
- MC CLI opens browser to Authentik authorization URL
|
||||||
- User logs in through Authentik's interface
|
- User logs in through Authentik's interface
|
||||||
- Authentik redirects to callback URL with authorization code
|
- Authentik redirects to callback URL with authorization code
|
||||||
- Cubbi Service exchanges code for access and refresh tokens
|
- MC Service exchanges code for access and refresh tokens
|
||||||
- CLI receives and securely stores tokens
|
- CLI receives and securely stores tokens
|
||||||
|
|
||||||
3. **Token Management**:
|
3. **Token Management**:
|
||||||
@@ -289,11 +289,11 @@ POST /sessions/{id}/connect - Establish connection to session
|
|||||||
GET /sessions/{id}/logs - Stream session logs
|
GET /sessions/{id}/logs - Stream session logs
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Images
|
#### Drivers
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /images - List available images
|
GET /drivers - List available drivers
|
||||||
GET /images/{name} - Get image details
|
GET /drivers/{name} - Get driver details
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Projects
|
#### Projects
|
||||||
@@ -309,19 +309,19 @@ DELETE /projects/{id} - Remove a project
|
|||||||
### Service Configuration
|
### Service Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# cubbi-service.yaml
|
# mc-service.yaml
|
||||||
server:
|
server:
|
||||||
port: 3000
|
port: 3000
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
socket: /var/run/docker.sock
|
socket: /var/run/docker.sock
|
||||||
network: cubbi-network
|
network: mc-network
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
provider: authentik
|
provider: authentik
|
||||||
url: https://authentik.monadical.io
|
url: https://authentik.monadical.io
|
||||||
clientId: cubbi-service
|
clientId: mc-service
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
providers:
|
providers:
|
||||||
@@ -332,13 +332,13 @@ logging:
|
|||||||
public_key: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY}
|
public_key: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY}
|
||||||
secret_key: ${LANGFUSE_INIT_PROJECT_SECRET_KEY}
|
secret_key: ${LANGFUSE_INIT_PROJECT_SECRET_KEY}
|
||||||
|
|
||||||
images:
|
drivers:
|
||||||
- name: goose
|
- name: goose
|
||||||
image: monadical/cubbi-goose:latest
|
image: monadical/mc-goose:latest
|
||||||
- name: aider
|
- name: aider
|
||||||
image: monadical/cubbi-aider:latest
|
image: monadical/mc-aider:latest
|
||||||
- name: claude-code
|
- name: claude-code
|
||||||
image: monadical/cubbi-claude-code:latest
|
image: monadical/mc-claude-code:latest
|
||||||
|
|
||||||
projects:
|
projects:
|
||||||
storage:
|
storage:
|
||||||
@@ -352,7 +352,7 @@ projects:
|
|||||||
|
|
||||||
### Docker-in-Docker Implementation
|
### Docker-in-Docker Implementation
|
||||||
|
|
||||||
The Cubbi Service runs in a container with access to the host's Docker socket, allowing it to create and manage sibling containers. This approach provides:
|
The MC Service runs in a container with access to the host's Docker socket, allowing it to create and manage sibling containers. This approach provides:
|
||||||
|
|
||||||
1. Isolation between containers
|
1. Isolation between containers
|
||||||
2. Simple lifecycle management
|
2. Simple lifecycle management
|
||||||
@@ -367,7 +367,7 @@ For remote connections to containers, the service provides two methods:
|
|||||||
|
|
||||||
### Logging Implementation
|
### Logging Implementation
|
||||||
|
|
||||||
The Cubbi Service implements log collection and forwarding:
|
The MC Service implements log collection and forwarding:
|
||||||
|
|
||||||
1. Container logs are captured using Docker's logging drivers
|
1. Container logs are captured using Docker's logging drivers
|
||||||
2. Logs are forwarded to configured providers (Fluentd, Langfuse)
|
2. Logs are forwarded to configured providers (Fluentd, Langfuse)
|
||||||
@@ -377,22 +377,22 @@ The Cubbi Service implements log collection and forwarding:
|
|||||||
|
|
||||||
### Persistent Project Configuration
|
### Persistent Project Configuration
|
||||||
|
|
||||||
Cubbi provides persistent storage for project-specific configurations that need to survive container restarts. This is implemented through a dedicated volume mount and symlink system:
|
MC provides persistent storage for project-specific configurations that need to survive container restarts. This is implemented through a dedicated volume mount and symlink system:
|
||||||
|
|
||||||
1. **Configuration Storage**:
|
1. **Configuration Storage**:
|
||||||
- Each project has a dedicated configuration directory on the host at `~/.cubbi/projects/<project-hash>/config`
|
- Each project has a dedicated configuration directory on the host at `~/.mc/projects/<project-hash>/config`
|
||||||
- For projects specified by URL, the hash is derived from the repository URL
|
- For projects specified by URL, the hash is derived from the repository URL
|
||||||
- For local projects, the hash is derived from the absolute path of the local directory
|
- For local projects, the hash is derived from the absolute path of the local directory
|
||||||
- This directory is mounted into the container at `/cubbi-config`
|
- This directory is mounted into the container at `/mc-config`
|
||||||
|
|
||||||
2. **Image Configuration**:
|
2. **Driver Configuration**:
|
||||||
- Each image can specify configuration files/directories that should persist across sessions
|
- Each driver can specify configuration files/directories that should persist across sessions
|
||||||
- These are defined in the image's `cubbi_image.yaml` file in the `persistent_configs` section
|
- These are defined in the driver's `mc-driver.yaml` file in the `persistent_configs` section
|
||||||
- Example for Goose image:
|
- Example for Goose driver:
|
||||||
```yaml
|
```yaml
|
||||||
persistent_configs:
|
persistent_configs:
|
||||||
- source: "/app/.goose" # Path in container
|
- source: "/app/.goose" # Path in container
|
||||||
target: "/cubbi-config/goose" # Path in persistent storage
|
target: "/mc-config/goose" # Path in persistent storage
|
||||||
type: "directory" # directory or file
|
type: "directory" # directory or file
|
||||||
description: "Goose memory and configuration"
|
description: "Goose memory and configuration"
|
||||||
```
|
```
|
||||||
@@ -406,8 +406,8 @@ Cubbi provides persistent storage for project-specific configurations that need
|
|||||||
4. **Environment Variables**:
|
4. **Environment Variables**:
|
||||||
- Container has access to configuration location via environment variables:
|
- Container has access to configuration location via environment variables:
|
||||||
```
|
```
|
||||||
CUBBI_CONFIG_DIR=/cubbi-config
|
MC_CONFIG_DIR=/mc-config
|
||||||
CUBBI_IMAGE_CONFIG_DIR=/cubbi-config/<image-name>
|
MC_DRIVER_CONFIG_DIR=/mc-config/<driver-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures that important configurations like Goose's memory store, authentication tokens, and other state information persist between container sessions while maintaining isolation between different projects.
|
This ensures that important configurations like Goose's memory store, authentication tokens, and other state information persist between container sessions while maintaining isolation between different projects.
|
||||||
@@ -418,21 +418,21 @@ Users can add projects with associated credentials:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add a project with SSH key
|
# Add a project with SSH key
|
||||||
cubbi project add github.com/hello/private --ssh-key ~/.ssh/id_ed25519
|
mc project add github.com/hello/private --ssh-key ~/.ssh/id_ed25519
|
||||||
|
|
||||||
# Add a project with token authentication
|
# Add a project with token authentication
|
||||||
cubbi project add github.com/hello/private --token ghp_123456789
|
mc project add github.com/hello/private --token ghp_123456789
|
||||||
|
|
||||||
# List all projects
|
# List all projects
|
||||||
cubbi project list
|
mc project list
|
||||||
|
|
||||||
# Remove a project
|
# Remove a project
|
||||||
cubbi project remove github.com/hello/private
|
mc project remove github.com/hello/private
|
||||||
```
|
```
|
||||||
|
|
||||||
### Project Configuration
|
### Project Configuration
|
||||||
|
|
||||||
Projects are stored in the Cubbi service and referenced by their repository URL. The configuration includes:
|
Projects are stored in the MC service and referenced by their repository URL. The configuration includes:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Project configuration
|
# Project configuration
|
||||||
@@ -448,59 +448,60 @@ auth:
|
|||||||
public_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
|
public_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Image Implementation
|
## Driver Implementation
|
||||||
|
|
||||||
### Image Structure
|
### Driver Structure
|
||||||
|
|
||||||
Each image is a Docker container with a standardized structure:
|
Each driver is a Docker image with a standardized structure:
|
||||||
|
|
||||||
```
|
```
|
||||||
/
|
/
|
||||||
├── entrypoint.sh # Container initialization
|
├── entrypoint.sh # Container initialization
|
||||||
├── cubbi-init.sh # Standardized initialization script
|
├── mc-init.sh # Standardized initialization script
|
||||||
├── cubbi_image.yaml # Image metadata and configuration
|
├── mc-driver.yaml # Driver metadata and configuration
|
||||||
├── tool/ # AI tool installation
|
├── tool/ # AI tool installation
|
||||||
└── ssh/ # SSH server configuration
|
└── ssh/ # SSH server configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
### Standardized Initialization Script
|
### Standardized Initialization Script
|
||||||
|
|
||||||
All images include a standardized `cubbi-init.sh` script that handles common initialization tasks:
|
All drivers include a standardized `mc-init.sh` script that handles common initialization tasks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Project initialization
|
# Project initialization
|
||||||
if [ -n "$CUBBI_PROJECT_URL" ]; then
|
if [ -n "$MC_PROJECT_URL" ]; then
|
||||||
echo "Initializing project: $CUBBI_PROJECT_URL"
|
echo "Initializing project: $MC_PROJECT_URL"
|
||||||
|
|
||||||
# Set up SSH key if provided
|
# Set up SSH key if provided
|
||||||
if [ -n "$CUBBI_GIT_SSH_KEY" ]; then
|
if [ -n "$MC_GIT_SSH_KEY" ]; then
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$CUBBI_GIT_SSH_KEY" > ~/.ssh/id_ed25519
|
echo "$MC_GIT_SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set up token if provided
|
# Set up token if provided
|
||||||
if [ -n "$CUBBI_GIT_TOKEN" ]; then
|
if [ -n "$MC_GIT_TOKEN" ]; then
|
||||||
git config --global credential.helper store
|
git config --global credential.helper store
|
||||||
echo "https://$CUBBI_GIT_TOKEN:x-oauth-basic@github.com" > ~/.git-credentials
|
echo "https://$MC_GIT_TOKEN:x-oauth-basic@github.com" > ~/.git-credentials
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone $CUBBI_PROJECT_URL /app
|
git clone $MC_PROJECT_URL /app
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
# Run project-specific initialization if present
|
# Run project-specific initialization if present
|
||||||
if [ -f "/app/.cubbi/init.sh" ]; then
|
if [ -f "/app/.mc/init.sh" ]; then
|
||||||
bash /app/.cubbi/init.sh
|
bash /app/.mc/init.sh
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Image-specific initialization continues...
|
# Driver-specific initialization continues...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Image Configuration (cubbi_image.yaml)
|
### Driver Configuration (mc-driver.yaml)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: goose
|
name: goose
|
||||||
@@ -509,7 +510,7 @@ version: 1.0.0
|
|||||||
maintainer: team@monadical.com
|
maintainer: team@monadical.com
|
||||||
|
|
||||||
init:
|
init:
|
||||||
pre_command: /cubbi-init.sh
|
pre_command: /mc-init.sh
|
||||||
command: /entrypoint.sh
|
command: /entrypoint.sh
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@@ -523,21 +524,21 @@ environment:
|
|||||||
required: false
|
required: false
|
||||||
|
|
||||||
# Project environment variables
|
# Project environment variables
|
||||||
- name: CUBBI_PROJECT_URL
|
- name: MC_PROJECT_URL
|
||||||
description: Project repository URL
|
description: Project repository URL
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
- name: CUBBI_PROJECT_TYPE
|
- name: MC_PROJECT_TYPE
|
||||||
description: Project repository type (git, svn, etc.)
|
description: Project repository type (git, svn, etc.)
|
||||||
required: false
|
required: false
|
||||||
default: git
|
default: git
|
||||||
|
|
||||||
- name: CUBBI_GIT_SSH_KEY
|
- name: MC_GIT_SSH_KEY
|
||||||
description: SSH key for Git authentication
|
description: SSH key for Git authentication
|
||||||
required: false
|
required: false
|
||||||
sensitive: true
|
sensitive: true
|
||||||
|
|
||||||
- name: CUBBI_GIT_TOKEN
|
- name: MC_GIT_TOKEN
|
||||||
description: Token for Git authentication
|
description: Token for Git authentication
|
||||||
required: false
|
required: false
|
||||||
sensitive: true
|
sensitive: true
|
||||||
@@ -552,12 +553,12 @@ volumes:
|
|||||||
|
|
||||||
persistent_configs:
|
persistent_configs:
|
||||||
- source: "/app/.goose"
|
- source: "/app/.goose"
|
||||||
target: "/cubbi-config/goose"
|
target: "/mc-config/goose"
|
||||||
type: "directory"
|
type: "directory"
|
||||||
description: "Goose memory and configuration"
|
description: "Goose memory and configuration"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example Built-in images
|
### Example Built-in Drivers
|
||||||
|
|
||||||
1. **goose**: Goose with MCP servers
|
1. **goose**: Goose with MCP servers
|
||||||
2. **aider**: Aider coding assistant
|
2. **aider**: Aider coding assistant
|
||||||
@@ -568,32 +569,32 @@ persistent_configs:
|
|||||||
|
|
||||||
### Docker Network Integration
|
### Docker Network Integration
|
||||||
|
|
||||||
Cubbi provides flexible network management for containers:
|
MC provides flexible network management for containers:
|
||||||
|
|
||||||
1. **Default Cubbi Network**:
|
1. **Default MC Network**:
|
||||||
- Each container is automatically connected to the Cubbi network (`cubbi-network` by default)
|
- Each container is automatically connected to the MC network (`mc-network` by default)
|
||||||
- This ensures containers can communicate with each other
|
- This ensures containers can communicate with each other
|
||||||
|
|
||||||
2. **External Network Connection**:
|
2. **External Network Connection**:
|
||||||
- Containers can be connected to one or more external Docker networks
|
- Containers can be connected to one or more external Docker networks
|
||||||
- This allows integration with existing infrastructure (e.g., databases, web servers)
|
- This allows integration with existing infrastructure (e.g., databases, web servers)
|
||||||
- Networks can be specified at session creation time: `cubbi session create --network mynetwork`
|
- Networks can be specified at session creation time: `mc session create --network mynetwork`
|
||||||
|
|
||||||
3. **Default Networks Configuration**:
|
3. **Default Networks Configuration**:
|
||||||
- Users can configure default networks in their configuration
|
- Users can configure default networks in their configuration
|
||||||
- These networks will be used for all new sessions unless overridden
|
- These networks will be used for all new sessions unless overridden
|
||||||
- Managed with `cubbi config network` commands
|
- Managed with `mc config network` commands
|
||||||
|
|
||||||
4. **Network Command Examples**:
|
4. **Network Command Examples**:
|
||||||
```bash
|
```bash
|
||||||
# Use with session creation
|
# Use with session creation
|
||||||
cubbi session create --network teamnet
|
mc session create --network teamnet
|
||||||
|
|
||||||
# Use with multiple networks
|
# Use with multiple networks
|
||||||
cubbi session create --network teamnet --network dbnet
|
mc session create --network teamnet --network dbnet
|
||||||
|
|
||||||
# Configure default networks
|
# Configure default networks
|
||||||
cubbi config network add teamnet
|
mc config network add teamnet
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
@@ -606,15 +607,15 @@ Cubbi provides flexible network management for containers:
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Cubbi Service Deployment
|
### MC Service Deployment
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml for Cubbi Service
|
# docker-compose.yml for MC Service
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cubbi-service:
|
mc-service:
|
||||||
image: monadical/cubbi-service:latest
|
image: monadical/mc-service:latest
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
@@ -624,10 +625,10 @@ services:
|
|||||||
- AUTH_URL=https://authentik.monadical.io
|
- AUTH_URL=https://authentik.monadical.io
|
||||||
- LANGFUSE_API_KEY=your_api_key
|
- LANGFUSE_API_KEY=your_api_key
|
||||||
networks:
|
networks:
|
||||||
- cubbi-network
|
- mc-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cubbi-network:
|
mc-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -637,33 +638,33 @@ networks:
|
|||||||
|
|
||||||
1. User adds project repository with authentication:
|
1. User adds project repository with authentication:
|
||||||
```bash
|
```bash
|
||||||
cubbi project add github.com/hello/private --ssh-key ~/.ssh/id_ed25519
|
mc project add github.com/hello/private --ssh-key ~/.ssh/id_ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Cubbi CLI reads the SSH key, encrypts it, and sends to Cubbi Service
|
2. MC CLI reads the SSH key, encrypts it, and sends to MC Service
|
||||||
|
|
||||||
3. Cubbi Service stores the project configuration securely
|
3. MC Service stores the project configuration securely
|
||||||
|
|
||||||
### Using a Project in a Session
|
### Using a Project in a Session
|
||||||
|
|
||||||
1. User creates a session with a project:
|
1. User creates a session with a project:
|
||||||
```bash
|
```bash
|
||||||
cubbi -r monadical git@github.com:hello/private
|
mc -r monadical git@github.com:hello/private
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Cubbi Service:
|
2. MC Service:
|
||||||
- Identifies the project from the URL
|
- Identifies the project from the URL
|
||||||
- Retrieves project authentication details
|
- Retrieves project authentication details
|
||||||
- Sets up environment variables:
|
- Sets up environment variables:
|
||||||
```
|
```
|
||||||
CUBBI_PROJECT_URL=git@github.com:hello/private
|
MC_PROJECT_URL=git@github.com:hello/private
|
||||||
CUBBI_PROJECT_TYPE=git
|
MC_PROJECT_TYPE=git
|
||||||
CUBBI_GIT_SSH_KEY=<contents of the SSH key>
|
MC_GIT_SSH_KEY=<contents of the SSH key>
|
||||||
```
|
```
|
||||||
- Creates container with these environment variables
|
- Creates container with these environment variables
|
||||||
|
|
||||||
3. Container initialization:
|
3. Container initialization:
|
||||||
- The standardized `cubbi-init.sh` script detects the project environment variables
|
- The standardized `mc-init.sh` script detects the project environment variables
|
||||||
- Sets up SSH key or token authentication
|
- Sets up SSH key or token authentication
|
||||||
- Clones the repository to `/app`
|
- Clones the repository to `/app`
|
||||||
- Runs any project-specific initialization scripts
|
- Runs any project-specific initialization scripts
|
||||||
@@ -673,10 +674,10 @@ networks:
|
|||||||
## Implementation Roadmap
|
## Implementation Roadmap
|
||||||
|
|
||||||
1. **Phase 1**: Local CLI tool with Docker integration
|
1. **Phase 1**: Local CLI tool with Docker integration
|
||||||
2. **Phase 2**: Cubbi Service REST API with basic container management
|
2. **Phase 2**: MC Service REST API with basic container management
|
||||||
3. **Phase 3**: Authentication and secure connections
|
3. **Phase 3**: Authentication and secure connections
|
||||||
4. **Phase 4**: Project management functionality
|
4. **Phase 4**: Project management functionality
|
||||||
5. **Phase 5**: Image implementation (Goose, Aider, Claude Code)
|
5. **Phase 5**: Driver implementation (Goose, Aider, Claude Code)
|
||||||
6. **Phase 6**: Logging integration with Fluentd and Langfuse
|
6. **Phase 6**: Logging integration with Fluentd and Langfuse
|
||||||
7. **Phase 7**: CLI remote connectivity improvements
|
7. **Phase 7**: CLI remote connectivity improvements
|
||||||
8. **Phase 8**: Additional images and extensibility features
|
8. **Phase 8**: Additional drivers and extensibility features
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This document specifies the implementation for Model Control Protocol (MCP) server support in the Cubbi system. The MCP server feature allows users to connect, build, and manage external MCP servers that can be attached to Cubbi sessions.
|
This document specifies the implementation for Model Control Protocol (MCP) server support in the Monadical Container (MC) system. The MCP server feature allows users to connect, build, and manage external MCP servers that can be attached to MC sessions.
|
||||||
|
|
||||||
An MCP server is a service that can be accessed by a image (such as Goose or Claude Code) to extend the LLM's capabilities through tool calls. It can be either:
|
An MCP server is a service that can be accessed by a driver (such as Goose or Claude Code) to extend the LLM's capabilities through tool calls. It can be either:
|
||||||
- A local stdio-based MCP server running in a container (accessed via an SSE proxy)
|
- A local stdio-based MCP server running in a container (accessed via an SSE proxy)
|
||||||
- A remote HTTP SSE server accessed directly via its URL
|
- A remote HTTP SSE server accessed directly via its URL
|
||||||
|
|
||||||
@@ -53,48 +53,48 @@ mcps:
|
|||||||
### MCP Management
|
### MCP Management
|
||||||
|
|
||||||
```
|
```
|
||||||
cubbi mcp list # List all configured MCP servers and their status
|
mc mcp list # List all configured MCP servers and their status
|
||||||
cubbi mcp status <name> # Show detailed status of a specific MCP server
|
mc mcp status <name> # Show detailed status of a specific MCP server
|
||||||
cubbi mcp start <name> # Start an MCP server container
|
mc mcp start <name> # Start an MCP server container
|
||||||
cubbi mcp stop <name> # Stop and remove an MCP server container
|
mc mcp stop <name> # Stop and remove an MCP server container
|
||||||
cubbi mcp restart <name> # Restart an MCP server container
|
mc mcp restart <name> # Restart an MCP server container
|
||||||
cubbi mcp start --all # Start all MCP server containers
|
mc mcp start --all # Start all MCP server containers
|
||||||
cubbi mcp stop --all # Stop and remove all MCP server containers
|
mc mcp stop --all # Stop and remove all MCP server containers
|
||||||
cubbi mcp inspector # Run the MCP Inspector UI with network connectivity to all MCP servers
|
mc mcp inspector # Run the MCP Inspector UI with network connectivity to all MCP servers
|
||||||
cubbi mcp inspector --client-port <cp> --server-port <sp> # Run with custom client port (default: 5173) and server port (default: 3000)
|
mc mcp inspector --client-port <cp> --server-port <sp> # Run with custom client port (default: 5173) and server port (default: 3000)
|
||||||
cubbi mcp inspector --detach # Run the inspector in detached mode
|
mc mcp inspector --detach # Run the inspector in detached mode
|
||||||
cubbi mcp inspector --stop # Stop the running inspector
|
mc mcp inspector --stop # Stop the running inspector
|
||||||
cubbi mcp logs <name> # Show logs for an MCP server container
|
mc mcp logs <name> # Show logs for an MCP server container
|
||||||
```
|
```
|
||||||
|
|
||||||
### MCP Configuration
|
### MCP Configuration
|
||||||
|
|
||||||
```
|
```
|
||||||
# Add a proxy-based MCP server (default)
|
# Add a proxy-based MCP server (default)
|
||||||
cubbi mcp add <name> <base_image> [--command CMD] [--proxy-image IMG] [--sse-port PORT] [--sse-host HOST] [--allow-origin ORIGIN] [--env KEY=VALUE...]
|
mc mcp add <name> <base_image> [--command CMD] [--proxy-image IMG] [--sse-port PORT] [--sse-host HOST] [--allow-origin ORIGIN] [--env KEY=VALUE...]
|
||||||
|
|
||||||
# Add a remote MCP server
|
# Add a remote MCP server
|
||||||
cubbi mcp add-remote <name> <url> [--header KEY=VALUE...]
|
mc mcp add-remote <name> <url> [--header KEY=VALUE...]
|
||||||
|
|
||||||
# Remove an MCP configuration
|
# Remove an MCP configuration
|
||||||
cubbi mcp remove <name>
|
mc mcp remove <name>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Session Integration
|
### Session Integration
|
||||||
|
|
||||||
```
|
```
|
||||||
cubbi session create [--mcp <name>] # Create a session with an MCP server attached
|
mc session create [--mcp <name>] # Create a session with an MCP server attached
|
||||||
```
|
```
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
### MCP Container Management
|
### MCP Container Management
|
||||||
|
|
||||||
1. MCP containers will have their own dedicated Docker network (`cubbi-mcp-network`)
|
1. MCP containers will have their own dedicated Docker network (`mc-mcp-network`)
|
||||||
2. Session containers will be attached to both their session network and the MCP network when using an MCP
|
2. Session containers will be attached to both their session network and the MCP network when using an MCP
|
||||||
3. MCP containers will be persistent across sessions unless explicitly stopped
|
3. MCP containers will be persistent across sessions unless explicitly stopped
|
||||||
4. MCP containers will be named with a prefix to identify them (`cubbi_mcp_<name>`)
|
4. MCP containers will be named with a prefix to identify them (`mc_mcp_<name>`)
|
||||||
5. Each MCP container will have a network alias matching its name without the prefix (e.g., `cubbi_mcp_github` will have the alias `github`)
|
5. Each MCP container will have a network alias matching its name without the prefix (e.g., `mc_mcp_github` will have the alias `github`)
|
||||||
6. Network aliases enable DNS-based service discovery between containers
|
6. Network aliases enable DNS-based service discovery between containers
|
||||||
|
|
||||||
### MCP Inspector
|
### MCP Inspector
|
||||||
@@ -157,4 +157,4 @@ When a session is created with an MCP server:
|
|||||||
1. Support for MCP server version management
|
1. Support for MCP server version management
|
||||||
2. Health checking and automatic restart capabilities
|
2. Health checking and automatic restart capabilities
|
||||||
3. Support for MCP server clusters or load balancing
|
3. Support for MCP server clusters or load balancing
|
||||||
4. Integration with monitoring systems
|
4. Integration with monitoring systems
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
# Cubbi Image Specifications
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document defines the specifications and requirements for building Cubbi-compatible container images. These images serve as isolated development environments for AI tools within the Cubbi platform.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Cubbi images use a Python-based initialization system with a plugin architecture that separates core Cubbi functionality from tool-specific configuration.
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
1. **Image Metadata File** (`cubbi_image.yaml`) - *Tool-specific*
|
|
||||||
2. **Container Definition** (`Dockerfile`) - *Tool-specific*
|
|
||||||
3. **Python Initialization Script** (`cubbi_init.py`) - *Shared across all images*
|
|
||||||
4. **Tool-specific Plugins** (e.g., `goose_plugin.py`) - *Tool-specific*
|
|
||||||
5. **Status Tracking Scripts** (`init-status.sh`) - *Shared across all images*
|
|
||||||
|
|
||||||
## Image Metadata Specification
|
|
||||||
|
|
||||||
### Required Fields
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: string # Unique identifier for the image
|
|
||||||
description: string # Human-readable description
|
|
||||||
version: string # Semantic version (e.g., "1.0.0")
|
|
||||||
maintainer: string # Contact information
|
|
||||||
image: string # Docker image name and tag
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- name: string # Variable name
|
|
||||||
description: string # Human-readable description
|
|
||||||
required: boolean # Whether variable is mandatory
|
|
||||||
sensitive: boolean # Whether variable contains secrets
|
|
||||||
default: string # Default value (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Standard Environment Variables
|
|
||||||
|
|
||||||
All images MUST support these standard environment variables:
|
|
||||||
|
|
||||||
- `CUBBI_USER_ID`: UID for the container user (default: 1000)
|
|
||||||
- `CUBBI_GROUP_ID`: GID for the container user (default: 1000)
|
|
||||||
- `CUBBI_RUN_COMMAND`: Command to execute after initialization
|
|
||||||
- `CUBBI_NO_SHELL`: Exit after command execution ("true"/"false")
|
|
||||||
- `CUBBI_CONFIG_DIR`: Directory for persistent configurations (default: "/cubbi-config")
|
|
||||||
- `CUBBI_MODEL`: Model to use for the tool
|
|
||||||
- `CUBBI_PROVIDER`: Provider to use for the tool
|
|
||||||
|
|
||||||
#### MCP Integration Variables
|
|
||||||
|
|
||||||
For MCP (Model Context Protocol) integration:
|
|
||||||
|
|
||||||
- `MCP_COUNT`: Number of available MCP servers
|
|
||||||
- `MCP_{idx}_NAME`: Name of MCP server at index
|
|
||||||
- `MCP_{idx}_TYPE`: Type of MCP server
|
|
||||||
- `MCP_{idx}_HOST`: Hostname of MCP server
|
|
||||||
- `MCP_{idx}_URL`: Full URL for remote MCP servers
|
|
||||||
|
|
||||||
### Network Configuration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
ports:
|
|
||||||
- number # Port to expose (e.g., 8000)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Storage Configuration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- mountPath: string # Path inside container
|
|
||||||
description: string # Purpose of the volume
|
|
||||||
|
|
||||||
persistent_configs:
|
|
||||||
- source: string # Path inside container
|
|
||||||
target: string # Path in persistent storage
|
|
||||||
type: string # "file" or "directory"
|
|
||||||
description: string # Purpose of the configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Container Requirements
|
|
||||||
|
|
||||||
### Base System Dependencies
|
|
||||||
|
|
||||||
All images MUST include:
|
|
||||||
|
|
||||||
- `python3` - For the initialization system
|
|
||||||
- `gosu` - For secure user switching
|
|
||||||
- `bash` - For script execution
|
|
||||||
|
|
||||||
### Python Dependencies
|
|
||||||
|
|
||||||
The Cubbi initialization system requires:
|
|
||||||
|
|
||||||
- `ruamel.yaml` - For YAML configuration parsing
|
|
||||||
|
|
||||||
### User Management
|
|
||||||
|
|
||||||
Images MUST:
|
|
||||||
|
|
||||||
1. Run as root initially for setup
|
|
||||||
2. Create a non-root user (`cubbi`) with configurable UID/GID
|
|
||||||
3. Switch to the non-root user for tool execution
|
|
||||||
4. Handle user ID mapping for volume permissions
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
|
|
||||||
Standard directories:
|
|
||||||
|
|
||||||
- `/app` - Primary working directory (owned by cubbi user)
|
|
||||||
- `/home/cubbi` - User home directory
|
|
||||||
- `/cubbi-config` - Persistent configuration storage
|
|
||||||
- `/cubbi/init.log` - Initialization log file
|
|
||||||
- `/cubbi/init.status` - Initialization status tracking
|
|
||||||
- `/cubbi/cubbi_image.yaml` - Image configuration
|
|
||||||
|
|
||||||
## Initialization System
|
|
||||||
|
|
||||||
### Shared Scripts
|
|
||||||
|
|
||||||
The following scripts are **shared across all Cubbi images** and should be copied from the main Cubbi repository:
|
|
||||||
|
|
||||||
#### Main Script (`cubbi_init.py`) - *Shared*
|
|
||||||
|
|
||||||
The standalone initialization script that:
|
|
||||||
|
|
||||||
1. Sets up user and group with proper IDs
|
|
||||||
2. Creates standard directories with correct permissions
|
|
||||||
3. Sets up persistent configuration symlinks
|
|
||||||
4. Runs tool-specific initialization
|
|
||||||
5. Executes user commands or starts interactive shell
|
|
||||||
|
|
||||||
The script supports:
|
|
||||||
- `--help` for usage information
|
|
||||||
- Argument passing to final command
|
|
||||||
- Environment variable configuration
|
|
||||||
- Plugin-based tool initialization
|
|
||||||
|
|
||||||
#### Status Tracking Script (`init-status.sh`) - *Shared*
|
|
||||||
|
|
||||||
A bash script that:
|
|
||||||
- Monitors initialization progress
|
|
||||||
- Displays logs during setup
|
|
||||||
- Ensures files exist before operations
|
|
||||||
- Switches to user shell when complete
|
|
||||||
|
|
||||||
### Tool-Specific Components
|
|
||||||
|
|
||||||
#### Tool Plugins (`{tool}_plugin.py`) - *Tool-specific*
|
|
||||||
|
|
||||||
Each tool MUST provide a plugin (`{tool}_plugin.py`) implementing:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from cubbi_init import ToolPlugin
|
|
||||||
|
|
||||||
class MyToolPlugin(ToolPlugin):
|
|
||||||
@property
|
|
||||||
def tool_name(self) -> str:
|
|
||||||
return "mytool"
|
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""Main tool initialization logic"""
|
|
||||||
# Tool-specific setup
|
|
||||||
return True
|
|
||||||
|
|
||||||
def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool:
|
|
||||||
"""Integrate with available MCP servers"""
|
|
||||||
# MCP integration logic
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Image Configuration (`cubbi_image.yaml`) - *Tool-specific*
|
|
||||||
|
|
||||||
Each tool provides its own metadata file defining:
|
|
||||||
- Tool-specific environment variables
|
|
||||||
- Port configurations
|
|
||||||
- Volume mounts
|
|
||||||
- Persistent configuration mappings
|
|
||||||
|
|
||||||
## Plugin Architecture
|
|
||||||
|
|
||||||
### Plugin Discovery
|
|
||||||
|
|
||||||
Plugins are automatically discovered by:
|
|
||||||
|
|
||||||
1. Looking for `{image_name}_plugin.py` in the same directory as `cubbi_init.py`
|
|
||||||
2. Loading classes that inherit from `ToolPlugin`
|
|
||||||
3. Executing initialization and MCP integration
|
|
||||||
|
|
||||||
### Plugin Requirements
|
|
||||||
|
|
||||||
Tool plugins MUST:
|
|
||||||
- Inherit from `ToolPlugin` base class
|
|
||||||
- Implement `tool_name` property
|
|
||||||
- Implement `initialize()` method
|
|
||||||
- Optionally implement `integrate_mcp_servers()` method
|
|
||||||
- Use ruamel.yaml for configuration file operations
|
|
||||||
|
|
||||||
## Security Requirements
|
|
||||||
|
|
||||||
### User Isolation
|
|
||||||
|
|
||||||
- Container MUST NOT run processes as root after initialization
|
|
||||||
- All user processes MUST run as the `cubbi` user
|
|
||||||
- Proper file ownership and permissions MUST be maintained
|
|
||||||
|
|
||||||
### Secrets Management
|
|
||||||
|
|
||||||
- Sensitive environment variables MUST be marked as `sensitive: true`
|
|
||||||
- SSH keys and tokens MUST have restricted permissions (600)
|
|
||||||
- No secrets SHOULD be logged or exposed in configuration files
|
|
||||||
|
|
||||||
### Network Security
|
|
||||||
|
|
||||||
- Only necessary ports SHOULD be exposed
|
|
||||||
- Network services should be properly configured and secured
|
|
||||||
|
|
||||||
## Integration Requirements
|
|
||||||
|
|
||||||
### MCP Server Integration
|
|
||||||
|
|
||||||
Images MUST support dynamic MCP server discovery and configuration through:
|
|
||||||
|
|
||||||
1. Environment variable parsing for server count and details
|
|
||||||
2. Automatic tool configuration updates
|
|
||||||
3. Standard MCP communication protocols
|
|
||||||
|
|
||||||
### Persistent Configuration
|
|
||||||
|
|
||||||
Images MUST support:
|
|
||||||
|
|
||||||
1. Configuration persistence through volume mounts
|
|
||||||
2. Symlink creation for tool configuration directories
|
|
||||||
3. Proper ownership and permission handling
|
|
||||||
|
|
||||||
## Docker Integration
|
|
||||||
|
|
||||||
### Dockerfile Requirements
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Copy shared scripts from main Cubbi repository
|
|
||||||
COPY cubbi_init.py /cubbi_init.py # Shared
|
|
||||||
COPY init-status.sh /init-status.sh # Shared
|
|
||||||
|
|
||||||
# Copy tool-specific files
|
|
||||||
COPY {tool}_plugin.py /{tool}_plugin.py # Tool-specific
|
|
||||||
COPY cubbi_image.yaml /cubbi/cubbi_image.yaml # Tool-specific
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
RUN pip install ruamel.yaml
|
|
||||||
|
|
||||||
# Make scripts executable
|
|
||||||
RUN chmod +x /cubbi_init.py /init-status.sh
|
|
||||||
|
|
||||||
# Set entrypoint
|
|
||||||
ENTRYPOINT ["/cubbi_init.py"]
|
|
||||||
CMD ["tail", "-f", "/dev/null"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Init Container Support
|
|
||||||
|
|
||||||
For complex initialization, use:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Use init-status.sh as entrypoint for monitoring
|
|
||||||
ENTRYPOINT ["/init-status.sh"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Use multi-stage builds to minimize image size
|
|
||||||
- Clean up package caches and temporary files
|
|
||||||
- Use specific base image versions for reproducibility
|
|
||||||
|
|
||||||
### Maintainability
|
|
||||||
|
|
||||||
- Follow consistent naming conventions
|
|
||||||
- Include comprehensive documentation
|
|
||||||
- Use semantic versioning for image releases
|
|
||||||
- Provide clear error messages and logging
|
|
||||||
|
|
||||||
### Compatibility
|
|
||||||
|
|
||||||
- Support common development workflows
|
|
||||||
- Maintain backward compatibility when possible
|
|
||||||
- Test with various project types and configurations
|
|
||||||
|
|
||||||
## Validation Checklist
|
|
||||||
|
|
||||||
Before releasing a Cubbi image, verify:
|
|
||||||
|
|
||||||
- [ ] All required metadata fields are present in `cubbi_image.yaml`
|
|
||||||
- [ ] Standard environment variables are supported
|
|
||||||
- [ ] `cubbi_init.py` script is properly installed and executable
|
|
||||||
- [ ] Tool plugin is discovered and loads correctly
|
|
||||||
- [ ] User management works correctly
|
|
||||||
- [ ] Persistent configurations are properly handled
|
|
||||||
- [ ] MCP integration functions (if applicable)
|
|
||||||
- [ ] Tool-specific functionality works as expected
|
|
||||||
- [ ] Security requirements are met
|
|
||||||
- [ ] Python dependencies are satisfied
|
|
||||||
- [ ] Status tracking works correctly
|
|
||||||
- [ ] Documentation is complete and accurate
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Complete Goose Example
|
|
||||||
|
|
||||||
See the `/cubbi/images/goose/` directory for a complete implementation including:
|
|
||||||
- `Dockerfile` - Container definition
|
|
||||||
- `cubbi_image.yaml` - Image metadata
|
|
||||||
- `goose_plugin.py` - Tool-specific initialization
|
|
||||||
- `README.md` - Tool-specific documentation
|
|
||||||
|
|
||||||
### Migration Notes
|
|
||||||
|
|
||||||
The current Python-based system uses:
|
|
||||||
- `cubbi_init.py` - Standalone initialization script with plugin support
|
|
||||||
- `{tool}_plugin.py` - Tool-specific configuration and MCP integration
|
|
||||||
- `init-status.sh` - Status monitoring and log display
|
|
||||||
- `cubbi_image.yaml` - Image metadata and configuration
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Cubbi - Cubbi Container Tool
|
MC - Monadical Container Tool
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
CLI for Cubbi Container Tool.
|
CLI for Monadical Container Tool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import logging
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from .config import ConfigManager
|
from .config import ConfigManager
|
||||||
from .container import ContainerManager
|
from .container import ContainerManager
|
||||||
from .mcp import MCPManager
|
|
||||||
from .models import SessionStatus
|
from .models import SessionStatus
|
||||||
from .session import SessionManager
|
|
||||||
from .user_config import UserConfigManager
|
from .user_config import UserConfigManager
|
||||||
|
from .session import SessionManager
|
||||||
|
from .mcp import MCPManager
|
||||||
|
|
||||||
# Configure logging - will only show logs if --verbose flag is used
|
# Configure logging - will only show logs if --verbose flag is used
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -27,13 +23,13 @@ logging.basicConfig(
|
|||||||
handlers=[logging.StreamHandler()],
|
handlers=[logging.StreamHandler()],
|
||||||
)
|
)
|
||||||
|
|
||||||
app = typer.Typer(help="Cubbi Container Tool", no_args_is_help=True)
|
app = typer.Typer(help="Monadical Container Tool", no_args_is_help=True)
|
||||||
session_app = typer.Typer(help="Manage Cubbi sessions", no_args_is_help=True)
|
session_app = typer.Typer(help="Manage MC sessions", no_args_is_help=True)
|
||||||
image_app = typer.Typer(help="Manage Cubbi images", no_args_is_help=True)
|
driver_app = typer.Typer(help="Manage MC drivers", no_args_is_help=True)
|
||||||
config_app = typer.Typer(help="Manage Cubbi configuration", no_args_is_help=True)
|
config_app = typer.Typer(help="Manage MC configuration", no_args_is_help=True)
|
||||||
mcp_app = typer.Typer(help="Manage MCP servers", no_args_is_help=True)
|
mcp_app = typer.Typer(help="Manage MCP servers", no_args_is_help=True)
|
||||||
app.add_typer(session_app, name="session", no_args_is_help=True)
|
app.add_typer(session_app, name="session", no_args_is_help=True)
|
||||||
app.add_typer(image_app, name="image", no_args_is_help=True)
|
app.add_typer(driver_app, name="driver", no_args_is_help=True)
|
||||||
app.add_typer(config_app, name="config", no_args_is_help=True)
|
app.add_typer(config_app, name="config", no_args_is_help=True)
|
||||||
app.add_typer(mcp_app, name="mcp", no_args_is_help=True)
|
app.add_typer(mcp_app, name="mcp", no_args_is_help=True)
|
||||||
|
|
||||||
@@ -48,12 +44,14 @@ mcp_manager = MCPManager(config_manager=user_config)
|
|||||||
@app.callback()
|
@app.callback()
|
||||||
def main(
|
def main(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"),
|
verbose: bool = typer.Option(
|
||||||
|
False, "--verbose", "-v", help="Enable verbose logging"
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Cubbi Container Tool
|
"""Monadical Container Tool
|
||||||
|
|
||||||
Run 'cubbi session create' to create a new session.
|
Run 'mc session create' to create a new session.
|
||||||
Use 'cubbix' as a shortcut for 'cubbi session create'.
|
Use 'mcx' as a shortcut for 'mc session create'.
|
||||||
"""
|
"""
|
||||||
# Set log level based on verbose flag
|
# Set log level based on verbose flag
|
||||||
if verbose:
|
if verbose:
|
||||||
@@ -62,19 +60,19 @@ def main(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def version() -> None:
|
def version() -> None:
|
||||||
"""Show Cubbi version information"""
|
"""Show MC version information"""
|
||||||
from importlib.metadata import version as get_version
|
from importlib.metadata import version as get_version
|
||||||
|
|
||||||
try:
|
try:
|
||||||
version_str = get_version("cubbi")
|
version_str = get_version("mcontainer")
|
||||||
console.print(f"Cubbi - Cubbi Container Tool v{version_str}")
|
console.print(f"MC - Monadical Container Tool v{version_str}")
|
||||||
except Exception:
|
except Exception:
|
||||||
console.print("Cubbi - Cubbi Container Tool (development version)")
|
console.print("MC - Monadical Container Tool (development version)")
|
||||||
|
|
||||||
|
|
||||||
@session_app.command("list")
|
@session_app.command("list")
|
||||||
def list_sessions() -> None:
|
def list_sessions() -> None:
|
||||||
"""List active Cubbi sessions"""
|
"""List active MC sessions"""
|
||||||
sessions = container_manager.list_sessions()
|
sessions = container_manager.list_sessions()
|
||||||
|
|
||||||
if not sessions:
|
if not sessions:
|
||||||
@@ -84,7 +82,7 @@ def list_sessions() -> None:
|
|||||||
table = Table(show_header=True, header_style="bold")
|
table = Table(show_header=True, header_style="bold")
|
||||||
table.add_column("ID")
|
table.add_column("ID")
|
||||||
table.add_column("Name")
|
table.add_column("Name")
|
||||||
table.add_column("Image")
|
table.add_column("Driver")
|
||||||
table.add_column("Status")
|
table.add_column("Status")
|
||||||
table.add_column("Ports")
|
table.add_column("Ports")
|
||||||
|
|
||||||
@@ -112,7 +110,7 @@ def list_sessions() -> None:
|
|||||||
table.add_row(
|
table.add_row(
|
||||||
session.id,
|
session.id,
|
||||||
session.name,
|
session.name,
|
||||||
session.image,
|
session.driver,
|
||||||
f"[{status_color}]{status_name}[/{status_color}]",
|
f"[{status_color}]{status_name}[/{status_color}]",
|
||||||
ports_str,
|
ports_str,
|
||||||
)
|
)
|
||||||
@@ -122,7 +120,7 @@ def list_sessions() -> None:
|
|||||||
|
|
||||||
@session_app.command("create")
|
@session_app.command("create")
|
||||||
def create_session(
|
def create_session(
|
||||||
image: Optional[str] = typer.Option(None, "--image", "-i", help="Image to use"),
|
driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"),
|
||||||
path_or_url: Optional[str] = typer.Argument(
|
path_or_url: Optional[str] = typer.Argument(
|
||||||
None,
|
None,
|
||||||
help="Local directory path to mount or repository URL to clone",
|
help="Local directory path to mount or repository URL to clone",
|
||||||
@@ -142,22 +140,12 @@ def create_session(
|
|||||||
network: List[str] = typer.Option(
|
network: List[str] = typer.Option(
|
||||||
[], "--network", "-N", help="Connect to additional Docker networks"
|
[], "--network", "-N", help="Connect to additional Docker networks"
|
||||||
),
|
),
|
||||||
port: List[str] = typer.Option(
|
|
||||||
[],
|
|
||||||
"--port",
|
|
||||||
help="Forward ports (e.g., '8000' or '8000,3000' or multiple --port flags)",
|
|
||||||
),
|
|
||||||
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
|
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
|
||||||
run_command: Optional[str] = typer.Option(
|
run_command: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--run",
|
"--run",
|
||||||
help="Command to execute inside the container before starting the shell",
|
help="Command to execute inside the container before starting the shell",
|
||||||
),
|
),
|
||||||
no_shell: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
"--no-shell",
|
|
||||||
help="Close container after '--run' command finishes (only valid with --run)",
|
|
||||||
),
|
|
||||||
no_connect: bool = typer.Option(
|
no_connect: bool = typer.Option(
|
||||||
False, "--no-connect", help="Don't automatically connect to the session"
|
False, "--no-connect", help="Don't automatically connect to the session"
|
||||||
),
|
),
|
||||||
@@ -173,25 +161,13 @@ def create_session(
|
|||||||
gid: Optional[int] = typer.Option(
|
gid: Optional[int] = typer.Option(
|
||||||
None, "--gid", help="Group ID to run the container as (defaults to host user)"
|
None, "--gid", help="Group ID to run the container as (defaults to host user)"
|
||||||
),
|
),
|
||||||
model: Optional[str] = typer.Option(None, "--model", help="Model to use"),
|
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
|
||||||
provider: Optional[str] = typer.Option(
|
provider: Optional[str] = typer.Option(
|
||||||
None, "--provider", "-p", help="Provider to use"
|
None, "--provider", "-p", help="Provider to use"
|
||||||
),
|
),
|
||||||
ssh: bool = typer.Option(False, "--ssh", help="Start SSH server in the container"),
|
ssh: bool = typer.Option(False, "--ssh", help="Start SSH server in the container"),
|
||||||
config: List[str] = typer.Option(
|
|
||||||
[],
|
|
||||||
"--config",
|
|
||||||
"-c",
|
|
||||||
help="Override configuration values (KEY=VALUE) for this session only",
|
|
||||||
),
|
|
||||||
domains: List[str] = typer.Option(
|
|
||||||
[],
|
|
||||||
"--domains",
|
|
||||||
help="Restrict network access to specified domains/ports (e.g., 'example.com:443', 'api.github.com')",
|
|
||||||
),
|
|
||||||
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"),
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a new Cubbi session
|
"""Create a new MC session
|
||||||
|
|
||||||
If a local directory path is provided, it will be mounted at /app in the container.
|
If a local directory path is provided, it will be mounted at /app in the container.
|
||||||
If a repository URL is provided, it will be cloned into /app during initialization.
|
If a repository URL is provided, it will be cloned into /app during initialization.
|
||||||
@@ -205,66 +181,14 @@ def create_session(
|
|||||||
target_gid = gid if gid is not None else os.getgid()
|
target_gid = gid if gid is not None else os.getgid()
|
||||||
console.print(f"Using UID: {target_uid}, GID: {target_gid}")
|
console.print(f"Using UID: {target_uid}, GID: {target_gid}")
|
||||||
|
|
||||||
# Create a temporary user config manager with overrides
|
# Use default driver from user configuration
|
||||||
temp_user_config = UserConfigManager()
|
if not driver:
|
||||||
|
driver = user_config.get(
|
||||||
# Parse and apply config overrides
|
"defaults.driver", config_manager.config.defaults.get("driver", "goose")
|
||||||
config_overrides = {}
|
|
||||||
for config_item in config:
|
|
||||||
if "=" in config_item:
|
|
||||||
key, value = config_item.split("=", 1)
|
|
||||||
# Convert string value to appropriate type
|
|
||||||
if value.lower() == "true":
|
|
||||||
typed_value = True
|
|
||||||
elif value.lower() == "false":
|
|
||||||
typed_value = False
|
|
||||||
elif value.isdigit():
|
|
||||||
typed_value = int(value)
|
|
||||||
else:
|
|
||||||
typed_value = value
|
|
||||||
config_overrides[key] = typed_value
|
|
||||||
else:
|
|
||||||
console.print(
|
|
||||||
f"[yellow]Warning: Ignoring invalid config format: {config_item}. Use KEY=VALUE.[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply overrides to temp config (without saving)
|
|
||||||
for key, value in config_overrides.items():
|
|
||||||
# Handle shorthand service paths (e.g., "langfuse.url")
|
|
||||||
if (
|
|
||||||
"." in key
|
|
||||||
and not key.startswith("services.")
|
|
||||||
and not any(
|
|
||||||
key.startswith(section + ".")
|
|
||||||
for section in ["defaults", "docker", "remote", "ui"]
|
|
||||||
)
|
|
||||||
):
|
|
||||||
service, setting = key.split(".", 1)
|
|
||||||
key = f"services.{service}.{setting}"
|
|
||||||
|
|
||||||
# Split the key path and navigate to set the value
|
|
||||||
parts = key.split(".")
|
|
||||||
config_dict = temp_user_config.config
|
|
||||||
|
|
||||||
# Navigate to the containing dictionary
|
|
||||||
for part in parts[:-1]:
|
|
||||||
if part not in config_dict:
|
|
||||||
config_dict[part] = {}
|
|
||||||
config_dict = config_dict[part]
|
|
||||||
|
|
||||||
# Set the value without saving
|
|
||||||
config_dict[parts[-1]] = value
|
|
||||||
|
|
||||||
# Use default image from user configuration (with overrides applied)
|
|
||||||
if not image:
|
|
||||||
image_name = temp_user_config.get(
|
|
||||||
"defaults.image", config_manager.config.defaults.get("image", "goose")
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
image_name = image
|
|
||||||
|
|
||||||
# Start with environment variables from user configuration (with overrides applied)
|
# Start with environment variables from user configuration
|
||||||
environment = temp_user_config.get_environment_variables()
|
environment = user_config.get_environment_variables()
|
||||||
|
|
||||||
# Override with environment variables from command line
|
# Override with environment variables from command line
|
||||||
for var in env:
|
for var in env:
|
||||||
@@ -280,7 +204,7 @@ def create_session(
|
|||||||
volume_mounts = {}
|
volume_mounts = {}
|
||||||
|
|
||||||
# Get default volumes from user config
|
# Get default volumes from user config
|
||||||
default_volumes = temp_user_config.get("defaults.volumes", [])
|
default_volumes = user_config.get("defaults.volumes", [])
|
||||||
|
|
||||||
# Combine default volumes with user-specified volumes
|
# Combine default volumes with user-specified volumes
|
||||||
all_volumes = default_volumes + list(volume)
|
all_volumes = default_volumes + list(volume)
|
||||||
@@ -307,56 +231,15 @@ def create_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get default networks from user config
|
# Get default networks from user config
|
||||||
default_networks = temp_user_config.get("defaults.networks", [])
|
default_networks = user_config.get("defaults.networks", [])
|
||||||
|
|
||||||
# Combine default networks with user-specified networks, removing duplicates
|
# Combine default networks with user-specified networks, removing duplicates
|
||||||
all_networks = list(set(default_networks + network))
|
all_networks = list(set(default_networks + network))
|
||||||
|
|
||||||
# Get default domains from user config
|
|
||||||
default_domains = temp_user_config.get("defaults.domains", [])
|
|
||||||
|
|
||||||
# Combine default domains with user-specified domains
|
|
||||||
all_domains = default_domains + list(domains)
|
|
||||||
|
|
||||||
# Check for conflict between network and domains
|
|
||||||
if all_domains and all_networks:
|
|
||||||
console.print(
|
|
||||||
"[yellow]Warning: --domains cannot be used with --network. Network restrictions will take precedence.[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get default ports from user config
|
|
||||||
default_ports = temp_user_config.get("defaults.ports", [])
|
|
||||||
|
|
||||||
# Parse and combine ports from command line
|
|
||||||
session_ports = []
|
|
||||||
for port_arg in port:
|
|
||||||
try:
|
|
||||||
parsed_ports = [int(p.strip()) for p in port_arg.split(",")]
|
|
||||||
|
|
||||||
# Validate port ranges
|
|
||||||
invalid_ports = [p for p in parsed_ports if not (1 <= p <= 65535)]
|
|
||||||
if invalid_ports:
|
|
||||||
console.print(
|
|
||||||
f"[red]Error: Invalid ports {invalid_ports}. Ports must be between 1 and 65535[/red]"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
session_ports.extend(parsed_ports)
|
|
||||||
except ValueError:
|
|
||||||
console.print(
|
|
||||||
f"[yellow]Warning: Ignoring invalid port format: {port_arg}. Use integers only.[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Combine default ports with session ports, removing duplicates
|
|
||||||
all_ports = list(set(default_ports + session_ports))
|
|
||||||
|
|
||||||
if all_ports:
|
|
||||||
console.print(f"Forwarding ports: {', '.join(map(str, all_ports))}")
|
|
||||||
|
|
||||||
# Get default MCPs from user config if none specified
|
# Get default MCPs from user config if none specified
|
||||||
all_mcps = mcp if isinstance(mcp, list) else []
|
all_mcps = mcp if isinstance(mcp, list) else []
|
||||||
if not all_mcps:
|
if not all_mcps:
|
||||||
default_mcps = temp_user_config.get("defaults.mcps", [])
|
default_mcps = user_config.get("defaults.mcps", [])
|
||||||
all_mcps = default_mcps
|
all_mcps = default_mcps
|
||||||
|
|
||||||
if default_mcps:
|
if default_mcps:
|
||||||
@@ -365,40 +248,21 @@ def create_session(
|
|||||||
if all_networks:
|
if all_networks:
|
||||||
console.print(f"Networks: {', '.join(all_networks)}")
|
console.print(f"Networks: {', '.join(all_networks)}")
|
||||||
|
|
||||||
if all_domains:
|
|
||||||
console.print(f"Domain restrictions: {', '.join(all_domains)}")
|
|
||||||
|
|
||||||
# Show volumes that will be mounted
|
# Show volumes that will be mounted
|
||||||
if volume_mounts:
|
if volume_mounts:
|
||||||
console.print("Volumes:")
|
console.print("Volumes:")
|
||||||
for host_path, mount_info in volume_mounts.items():
|
for host_path, mount_info in volume_mounts.items():
|
||||||
console.print(f" {host_path} -> {mount_info['bind']}")
|
console.print(f" {host_path} -> {mount_info['bind']}")
|
||||||
|
|
||||||
with console.status(f"Creating session with image '{image_name}'..."):
|
with console.status(f"Creating session with driver '{driver}'..."):
|
||||||
# If path_or_url is a local directory, we should mount it
|
# If path_or_url is a local directory, we should mount it
|
||||||
# If it's a Git URL or doesn't exist, handle accordingly
|
# If it's a Git URL or doesn't exist, handle accordingly
|
||||||
mount_local = False
|
mount_local = False
|
||||||
if path_or_url and os.path.isdir(os.path.expanduser(path_or_url)):
|
if path_or_url and os.path.isdir(os.path.expanduser(path_or_url)):
|
||||||
mount_local = True
|
mount_local = True
|
||||||
|
|
||||||
# Check if --no-shell is used without --run
|
|
||||||
if no_shell and not run_command:
|
|
||||||
console.print(
|
|
||||||
"[yellow]Warning: --no-shell is ignored without --run[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use model and provider from config overrides if not explicitly provided
|
|
||||||
final_model = (
|
|
||||||
model if model is not None else temp_user_config.get("defaults.model")
|
|
||||||
)
|
|
||||||
final_provider = (
|
|
||||||
provider
|
|
||||||
if provider is not None
|
|
||||||
else temp_user_config.get("defaults.provider")
|
|
||||||
)
|
|
||||||
|
|
||||||
session = container_manager.create_session(
|
session = container_manager.create_session(
|
||||||
image_name=image_name,
|
driver_name=driver,
|
||||||
project=path_or_url,
|
project=path_or_url,
|
||||||
project_name=project,
|
project_name=project,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
@@ -406,84 +270,43 @@ def create_session(
|
|||||||
mount_local=mount_local,
|
mount_local=mount_local,
|
||||||
volumes=volume_mounts,
|
volumes=volume_mounts,
|
||||||
networks=all_networks,
|
networks=all_networks,
|
||||||
ports=all_ports,
|
|
||||||
mcp=all_mcps,
|
mcp=all_mcps,
|
||||||
run_command=run_command,
|
run_command=run_command,
|
||||||
no_shell=no_shell,
|
|
||||||
uid=target_uid,
|
uid=target_uid,
|
||||||
gid=target_gid,
|
gid=target_gid,
|
||||||
ssh=ssh,
|
ssh=ssh,
|
||||||
model=final_model,
|
model=model,
|
||||||
provider=final_provider,
|
provider=provider,
|
||||||
domains=all_domains,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
console.print("[green]Session created successfully![/green]")
|
console.print("[green]Session created successfully![/green]")
|
||||||
console.print(f"Session ID: {session.id}")
|
console.print(f"Session ID: {session.id}")
|
||||||
console.print(f"Image: {session.image}")
|
console.print(f"Driver: {session.driver}")
|
||||||
|
|
||||||
if session.ports:
|
if session.ports:
|
||||||
console.print("Ports:")
|
console.print("Ports:")
|
||||||
for container_port, host_port in session.ports.items():
|
for container_port, host_port in session.ports.items():
|
||||||
console.print(f" {container_port} -> {host_port}")
|
console.print(f" {container_port} -> {host_port}")
|
||||||
|
|
||||||
# Auto-connect based on user config, unless overridden by --no-connect flag or --no-shell
|
# Auto-connect based on user config, unless overridden by --no-connect flag
|
||||||
auto_connect = temp_user_config.get("defaults.connect", True)
|
auto_connect = user_config.get("defaults.connect", True)
|
||||||
|
# Connect if auto_connect is enabled and --no-connect wasn't used.
|
||||||
# When --no-shell is used with --run, show logs instead of connecting
|
# The --run command no longer prevents connection.
|
||||||
if no_shell and run_command:
|
should_connect = not no_connect and auto_connect
|
||||||
console.print(
|
if should_connect:
|
||||||
"[yellow]Executing command and waiting for completion...[/yellow]"
|
container_manager.connect_session(session.id)
|
||||||
)
|
|
||||||
console.print("Container will exit after command completes.")
|
|
||||||
console.print("[bold]Command logs:[/bold]")
|
|
||||||
# Stream logs from the container until it exits
|
|
||||||
container_manager.get_session_logs(session.id, follow=True)
|
|
||||||
# At this point the command and container should have finished
|
|
||||||
|
|
||||||
# Clean up the session entry to avoid leaving stale entries
|
|
||||||
with console.status("Cleaning up session..."):
|
|
||||||
# Give a short delay to ensure container has fully exited
|
|
||||||
import time
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
# Remove the session from session manager
|
|
||||||
session_manager.remove_session(session.id)
|
|
||||||
try:
|
|
||||||
# Also try to remove the container to ensure no resources are left behind
|
|
||||||
container = container_manager.client.containers.get(
|
|
||||||
session.container_id
|
|
||||||
)
|
|
||||||
if container.status != "running":
|
|
||||||
container.remove(force=False)
|
|
||||||
except Exception as e:
|
|
||||||
# Container might already be gone or in the process of exiting
|
|
||||||
# This is fine, just log it
|
|
||||||
if verbose:
|
|
||||||
console.print(f"[yellow]Note: {e}[/yellow]")
|
|
||||||
|
|
||||||
console.print(
|
|
||||||
"[green]Command execution complete. Container has exited.[/green]"
|
|
||||||
)
|
|
||||||
console.print("[green]Session has been cleaned up.[/green]")
|
|
||||||
else:
|
else:
|
||||||
# Connect if auto_connect is enabled and --no-connect wasn't used.
|
# Explain why connection was skipped
|
||||||
# The --run command no longer prevents connection.
|
if no_connect:
|
||||||
should_connect = not no_connect and auto_connect
|
console.print("\nConnection skipped due to --no-connect.")
|
||||||
if should_connect:
|
console.print(
|
||||||
container_manager.connect_session(session.id)
|
f"Connect manually with:\n mc session connect {session.id}"
|
||||||
else:
|
)
|
||||||
# Explain why connection was skipped
|
elif not auto_connect:
|
||||||
if no_connect:
|
console.print(
|
||||||
console.print("\nConnection skipped due to --no-connect.")
|
f"\nAuto-connect disabled. Connect with:\n mc session connect {session.id}"
|
||||||
console.print(
|
)
|
||||||
f"Connect manually with:\n cubbi session connect {session.id}"
|
|
||||||
)
|
|
||||||
elif not auto_connect:
|
|
||||||
console.print(
|
|
||||||
f"\nAuto-connect disabled. Connect with:\n cubbi session connect {session.id}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
console.print("[red]Failed to create session[/red]")
|
console.print("[red]Failed to create session[/red]")
|
||||||
|
|
||||||
@@ -492,11 +315,8 @@ def create_session(
|
|||||||
def close_session(
|
def close_session(
|
||||||
session_id: Optional[str] = typer.Argument(None, help="Session ID to close"),
|
session_id: Optional[str] = typer.Argument(None, help="Session ID to close"),
|
||||||
all_sessions: bool = typer.Option(False, "--all", help="Close all active sessions"),
|
all_sessions: bool = typer.Option(False, "--all", help="Close all active sessions"),
|
||||||
kill: bool = typer.Option(
|
|
||||||
False, "--kill", help="Forcefully kill containers instead of graceful stop"
|
|
||||||
),
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Close a Cubbi session or all sessions"""
|
"""Close a MC session or all sessions"""
|
||||||
if all_sessions:
|
if all_sessions:
|
||||||
# Get sessions first to display them
|
# Get sessions first to display them
|
||||||
sessions = container_manager.list_sessions()
|
sessions = container_manager.list_sessions()
|
||||||
@@ -518,9 +338,7 @@ def close_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Start closing sessions with progress updates
|
# Start closing sessions with progress updates
|
||||||
count, success = container_manager.close_all_sessions(
|
count, success = container_manager.close_all_sessions(update_progress)
|
||||||
update_progress, kill=kill
|
|
||||||
)
|
|
||||||
|
|
||||||
# Final result
|
# Final result
|
||||||
if success:
|
if success:
|
||||||
@@ -529,7 +347,7 @@ def close_session(
|
|||||||
console.print("[red]Failed to close all sessions[/red]")
|
console.print("[red]Failed to close all sessions[/red]")
|
||||||
elif session_id:
|
elif session_id:
|
||||||
with console.status(f"Closing session {session_id}..."):
|
with console.status(f"Closing session {session_id}..."):
|
||||||
success = container_manager.close_session(session_id, kill=kill)
|
success = container_manager.close_session(session_id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
console.print(f"[green]Session {session_id} closed successfully[/green]")
|
console.print(f"[green]Session {session_id} closed successfully[/green]")
|
||||||
@@ -543,7 +361,7 @@ def close_session(
|
|||||||
def connect_session(
|
def connect_session(
|
||||||
session_id: str = typer.Argument(..., help="Session ID to connect to"),
|
session_id: str = typer.Argument(..., help="Session ID to connect to"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Connect to a Cubbi session"""
|
"""Connect to a MC session"""
|
||||||
console.print(f"Connecting to session {session_id}...")
|
console.print(f"Connecting to session {session_id}...")
|
||||||
success = container_manager.connect_session(session_id)
|
success = container_manager.connect_session(session_id)
|
||||||
|
|
||||||
@@ -559,7 +377,7 @@ def session_logs(
|
|||||||
False, "--init", "-i", help="Show initialization logs instead of container logs"
|
False, "--init", "-i", help="Show initialization logs instead of container logs"
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stream logs from a Cubbi session"""
|
"""Stream logs from a MC session"""
|
||||||
if init:
|
if init:
|
||||||
# Show initialization logs
|
# Show initialization logs
|
||||||
if follow:
|
if follow:
|
||||||
@@ -584,13 +402,13 @@ def session_logs(
|
|||||||
console.print(logs)
|
console.print(logs)
|
||||||
|
|
||||||
|
|
||||||
@image_app.command("list")
|
@driver_app.command("list")
|
||||||
def list_images() -> None:
|
def list_drivers() -> None:
|
||||||
"""List available Cubbi images"""
|
"""List available MC drivers"""
|
||||||
images = config_manager.list_images()
|
drivers = config_manager.list_drivers()
|
||||||
|
|
||||||
if not images:
|
if not drivers:
|
||||||
console.print("No images found")
|
console.print("No drivers found")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = Table(show_header=True, header_style="bold")
|
table = Table(show_header=True, header_style="bold")
|
||||||
@@ -600,143 +418,92 @@ def list_images() -> None:
|
|||||||
table.add_column("Maintainer")
|
table.add_column("Maintainer")
|
||||||
table.add_column("Image")
|
table.add_column("Image")
|
||||||
|
|
||||||
for name, image in images.items():
|
for name, driver in drivers.items():
|
||||||
table.add_row(
|
table.add_row(
|
||||||
image.name,
|
driver.name,
|
||||||
image.description,
|
driver.description,
|
||||||
image.version,
|
driver.version,
|
||||||
image.maintainer,
|
driver.maintainer,
|
||||||
image.image,
|
driver.image,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@image_app.command("build")
|
@driver_app.command("build")
|
||||||
def build_image(
|
def build_driver(
|
||||||
image_name: str = typer.Argument(..., help="Image name to build"),
|
driver_name: str = typer.Argument(..., help="Driver name to build"),
|
||||||
tag: str = typer.Option("latest", "--tag", "-t", help="Image tag"),
|
tag: str = typer.Option("latest", "--tag", "-t", help="Image tag"),
|
||||||
push: bool = typer.Option(
|
push: bool = typer.Option(
|
||||||
False, "--push", "-p", help="Push image to registry after building"
|
False, "--push", "-p", help="Push image to registry after building"
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Build an image Docker image"""
|
"""Build a driver Docker image"""
|
||||||
# Get image path
|
# Get driver path
|
||||||
image_path = config_manager.get_image_path(image_name)
|
driver_path = config_manager.get_driver_path(driver_name)
|
||||||
if not image_path:
|
if not driver_path:
|
||||||
console.print(f"[red]Image '{image_name}' not found[/red]")
|
console.print(f"[red]Driver '{driver_name}' not found[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if Dockerfile exists
|
# Check if Dockerfile exists
|
||||||
dockerfile_path = image_path / "Dockerfile"
|
dockerfile_path = driver_path / "Dockerfile"
|
||||||
if not dockerfile_path.exists():
|
if not dockerfile_path.exists():
|
||||||
console.print(f"[red]Dockerfile not found in {image_path}[/red]")
|
console.print(f"[red]Dockerfile not found in {driver_path}[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build image name
|
# Build image name
|
||||||
docker_image_name = f"monadical/cubbi-{image_name}:{tag}"
|
image_name = f"monadical/mc-{driver_name}:{tag}"
|
||||||
|
|
||||||
# Create temporary build directory
|
# Build the image
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with console.status(f"Building image {image_name}..."):
|
||||||
temp_path = Path(temp_dir)
|
result = os.system(f"cd {driver_path} && docker build -t {image_name} .")
|
||||||
console.print(f"Using temporary build directory: {temp_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Copy all files from the image directory to temp directory
|
|
||||||
for item in image_path.iterdir():
|
|
||||||
if item.is_file():
|
|
||||||
shutil.copy2(item, temp_path / item.name)
|
|
||||||
elif item.is_dir():
|
|
||||||
shutil.copytree(item, temp_path / item.name)
|
|
||||||
|
|
||||||
# Copy shared cubbi_init.py to temp directory
|
|
||||||
shared_init_path = Path(__file__).parent / "images" / "cubbi_init.py"
|
|
||||||
if shared_init_path.exists():
|
|
||||||
shutil.copy2(shared_init_path, temp_path / "cubbi_init.py")
|
|
||||||
console.print("Copied shared cubbi_init.py to build context")
|
|
||||||
else:
|
|
||||||
console.print(
|
|
||||||
f"[yellow]Warning: Shared cubbi_init.py not found at {shared_init_path}[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy shared init-status.sh to temp directory
|
|
||||||
shared_status_path = Path(__file__).parent / "images" / "init-status.sh"
|
|
||||||
if shared_status_path.exists():
|
|
||||||
shutil.copy2(shared_status_path, temp_path / "init-status.sh")
|
|
||||||
console.print("Copied shared init-status.sh to build context")
|
|
||||||
else:
|
|
||||||
console.print(
|
|
||||||
f"[yellow]Warning: Shared init-status.sh not found at {shared_status_path}[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy image-specific plugin if it exists
|
|
||||||
plugin_path = image_path / f"{image_name.lower()}_plugin.py"
|
|
||||||
if plugin_path.exists():
|
|
||||||
shutil.copy2(plugin_path, temp_path / f"{image_name.lower()}_plugin.py")
|
|
||||||
console.print(f"Copied {image_name.lower()}_plugin.py to build context")
|
|
||||||
|
|
||||||
# Copy init-status.sh if it exists (for backward compatibility with shell connection)
|
|
||||||
init_status_path = image_path / "init-status.sh"
|
|
||||||
if init_status_path.exists():
|
|
||||||
shutil.copy2(init_status_path, temp_path / "init-status.sh")
|
|
||||||
console.print("Copied init-status.sh to build context")
|
|
||||||
|
|
||||||
# Build the image from temporary directory
|
|
||||||
with console.status(f"Building image {docker_image_name}..."):
|
|
||||||
result = os.system(
|
|
||||||
f"cd {temp_path} && docker build -t {docker_image_name} ."
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"[red]Error preparing build context: {e}[/red]")
|
|
||||||
return
|
|
||||||
|
|
||||||
if result != 0:
|
if result != 0:
|
||||||
console.print("[red]Failed to build image[/red]")
|
console.print("[red]Failed to build driver image[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print(f"[green]Successfully built image: {docker_image_name}[/green]")
|
console.print(f"[green]Successfully built image: {image_name}[/green]")
|
||||||
|
|
||||||
# Push if requested
|
# Push if requested
|
||||||
if push:
|
if push:
|
||||||
with console.status(f"Pushing image {docker_image_name}..."):
|
with console.status(f"Pushing image {image_name}..."):
|
||||||
result = os.system(f"docker push {docker_image_name}")
|
result = os.system(f"docker push {image_name}")
|
||||||
|
|
||||||
if result != 0:
|
if result != 0:
|
||||||
console.print("[red]Failed to push image[/red]")
|
console.print("[red]Failed to push driver image[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print(f"[green]Successfully pushed image: {docker_image_name}[/green]")
|
console.print(f"[green]Successfully pushed image: {image_name}[/green]")
|
||||||
|
|
||||||
|
|
||||||
@image_app.command("info")
|
@driver_app.command("info")
|
||||||
def image_info(
|
def driver_info(
|
||||||
image_name: str = typer.Argument(..., help="Image name to get info for"),
|
driver_name: str = typer.Argument(..., help="Driver name to get info for"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show detailed information about an image"""
|
"""Show detailed information about a driver"""
|
||||||
image = config_manager.get_image(image_name)
|
driver = config_manager.get_driver(driver_name)
|
||||||
if not image:
|
if not driver:
|
||||||
console.print(f"[red]Image '{image_name}' not found[/red]")
|
console.print(f"[red]Driver '{driver_name}' not found[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print(f"[bold]Image: {image.name}[/bold]")
|
console.print(f"[bold]Driver: {driver.name}[/bold]")
|
||||||
console.print(f"Description: {image.description}")
|
console.print(f"Description: {driver.description}")
|
||||||
console.print(f"Version: {image.version}")
|
console.print(f"Version: {driver.version}")
|
||||||
console.print(f"Maintainer: {image.maintainer}")
|
console.print(f"Maintainer: {driver.maintainer}")
|
||||||
console.print(f"Docker Image: {image.image}")
|
console.print(f"Image: {driver.image}")
|
||||||
|
|
||||||
if image.ports:
|
if driver.ports:
|
||||||
console.print("\n[bold]Ports:[/bold]")
|
console.print("\n[bold]Ports:[/bold]")
|
||||||
for port in image.ports:
|
for port in driver.ports:
|
||||||
console.print(f" {port}")
|
console.print(f" {port}")
|
||||||
|
|
||||||
# Get image path
|
# Get driver path
|
||||||
image_path = config_manager.get_image_path(image_name)
|
driver_path = config_manager.get_driver_path(driver_name)
|
||||||
if image_path:
|
if driver_path:
|
||||||
console.print(f"\n[bold]Path:[/bold] {image_path}")
|
console.print(f"\n[bold]Path:[/bold] {driver_path}")
|
||||||
|
|
||||||
# Check for README
|
# Check for README
|
||||||
readme_path = image_path / "README.md"
|
readme_path = driver_path / "README.md"
|
||||||
if readme_path.exists():
|
if readme_path.exists():
|
||||||
console.print("\n[bold]README:[/bold]")
|
console.print("\n[bold]README:[/bold]")
|
||||||
with open(readme_path, "r") as f:
|
with open(readme_path, "r") as f:
|
||||||
@@ -751,10 +518,6 @@ config_app.add_typer(network_app, name="network", no_args_is_help=True)
|
|||||||
volume_app = typer.Typer(help="Manage default volumes")
|
volume_app = typer.Typer(help="Manage default volumes")
|
||||||
config_app.add_typer(volume_app, name="volume", no_args_is_help=True)
|
config_app.add_typer(volume_app, name="volume", no_args_is_help=True)
|
||||||
|
|
||||||
# Create a port subcommand for config
|
|
||||||
port_app = typer.Typer(help="Manage default ports")
|
|
||||||
config_app.add_typer(port_app, name="port", no_args_is_help=True)
|
|
||||||
|
|
||||||
# Create an MCP subcommand for config
|
# Create an MCP subcommand for config
|
||||||
config_mcp_app = typer.Typer(help="Manage default MCP servers")
|
config_mcp_app = typer.Typer(help="Manage default MCP servers")
|
||||||
config_app.add_typer(config_mcp_app, name="mcp", no_args_is_help=True)
|
config_app.add_typer(config_mcp_app, name="mcp", no_args_is_help=True)
|
||||||
@@ -1065,91 +828,6 @@ def remove_volume(
|
|||||||
console.print(f"[green]Removed volume '{volume_to_remove}' from defaults[/green]")
|
console.print(f"[green]Removed volume '{volume_to_remove}' from defaults[/green]")
|
||||||
|
|
||||||
|
|
||||||
# Port configuration commands
|
|
||||||
@port_app.command("list")
|
|
||||||
def list_ports() -> None:
|
|
||||||
"""List all default ports"""
|
|
||||||
ports = user_config.get("defaults.ports", [])
|
|
||||||
|
|
||||||
if not ports:
|
|
||||||
console.print("No default ports configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
table = Table(show_header=True, header_style="bold")
|
|
||||||
table.add_column("Port")
|
|
||||||
|
|
||||||
for port in ports:
|
|
||||||
table.add_row(str(port))
|
|
||||||
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
|
|
||||||
@port_app.command("add")
|
|
||||||
def add_port(
|
|
||||||
ports_arg: str = typer.Argument(
|
|
||||||
..., help="Port(s) to add to defaults (e.g., '8000' or '8000,3000,5173')"
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""Add port(s) to default ports"""
|
|
||||||
current_ports = user_config.get("defaults.ports", [])
|
|
||||||
|
|
||||||
# Parse ports (support comma-separated)
|
|
||||||
try:
|
|
||||||
if "," in ports_arg:
|
|
||||||
new_ports = [int(p.strip()) for p in ports_arg.split(",")]
|
|
||||||
else:
|
|
||||||
new_ports = [int(ports_arg)]
|
|
||||||
except ValueError:
|
|
||||||
console.print(
|
|
||||||
"[red]Error: Invalid port format. Use integers only (e.g., '8000' or '8000,3000')[/red]"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate port ranges
|
|
||||||
invalid_ports = [p for p in new_ports if not (1 <= p <= 65535)]
|
|
||||||
if invalid_ports:
|
|
||||||
console.print(
|
|
||||||
f"[red]Error: Invalid ports {invalid_ports}. Ports must be between 1 and 65535[/red]"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add new ports, avoiding duplicates
|
|
||||||
added_ports = []
|
|
||||||
for port in new_ports:
|
|
||||||
if port not in current_ports:
|
|
||||||
current_ports.append(port)
|
|
||||||
added_ports.append(port)
|
|
||||||
|
|
||||||
if not added_ports:
|
|
||||||
if len(new_ports) == 1:
|
|
||||||
console.print(f"Port {new_ports[0]} is already in defaults")
|
|
||||||
else:
|
|
||||||
console.print(f"All ports {new_ports} are already in defaults")
|
|
||||||
return
|
|
||||||
|
|
||||||
user_config.set("defaults.ports", current_ports)
|
|
||||||
if len(added_ports) == 1:
|
|
||||||
console.print(f"[green]Added port {added_ports[0]} to defaults[/green]")
|
|
||||||
else:
|
|
||||||
console.print(f"[green]Added ports {added_ports} to defaults[/green]")
|
|
||||||
|
|
||||||
|
|
||||||
@port_app.command("remove")
|
|
||||||
def remove_port(
|
|
||||||
port: int = typer.Argument(..., help="Port to remove from defaults"),
|
|
||||||
) -> None:
|
|
||||||
"""Remove a port from default ports"""
|
|
||||||
ports = user_config.get("defaults.ports", [])
|
|
||||||
|
|
||||||
if port not in ports:
|
|
||||||
console.print(f"Port {port} is not in defaults")
|
|
||||||
return
|
|
||||||
|
|
||||||
ports.remove(port)
|
|
||||||
user_config.set("defaults.ports", ports)
|
|
||||||
console.print(f"[green]Removed port {port} from defaults[/green]")
|
|
||||||
|
|
||||||
|
|
||||||
# MCP Management Commands
|
# MCP Management Commands
|
||||||
|
|
||||||
|
|
||||||
@@ -1327,7 +1005,9 @@ def mcp_status(name: str = typer.Argument(..., help="MCP server name")) -> None:
|
|||||||
def start_mcp(
|
def start_mcp(
|
||||||
name: Optional[str] = typer.Argument(None, help="MCP server name"),
|
name: Optional[str] = typer.Argument(None, help="MCP server name"),
|
||||||
all_servers: bool = typer.Option(False, "--all", help="Start all MCP servers"),
|
all_servers: bool = typer.Option(False, "--all", help="Start all MCP servers"),
|
||||||
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"),
|
verbose: bool = typer.Option(
|
||||||
|
False, "--verbose", "-v", help="Enable verbose logging"
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start an MCP server or all servers"""
|
"""Start an MCP server or all servers"""
|
||||||
# Set log level based on verbose flag
|
# Set log level based on verbose flag
|
||||||
@@ -1722,11 +1402,6 @@ def add_mcp(
|
|||||||
def add_remote_mcp(
|
def add_remote_mcp(
|
||||||
name: str = typer.Argument(..., help="MCP server name"),
|
name: str = typer.Argument(..., help="MCP server name"),
|
||||||
url: str = typer.Argument(..., help="URL of the remote MCP server"),
|
url: str = typer.Argument(..., help="URL of the remote MCP server"),
|
||||||
mcp_type: str = typer.Option(
|
|
||||||
"auto",
|
|
||||||
"--mcp-type",
|
|
||||||
help="MCP connection type: sse, streamable_http, stdio, or auto (default: auto)",
|
|
||||||
),
|
|
||||||
header: List[str] = typer.Option(
|
header: List[str] = typer.Option(
|
||||||
[], "--header", "-H", help="HTTP headers (format: KEY=VALUE)"
|
[], "--header", "-H", help="HTTP headers (format: KEY=VALUE)"
|
||||||
),
|
),
|
||||||
@@ -1735,22 +1410,6 @@ def add_remote_mcp(
|
|||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a remote MCP server"""
|
"""Add a remote MCP server"""
|
||||||
if mcp_type == "auto":
|
|
||||||
if url.endswith("/sse"):
|
|
||||||
mcp_type = "sse"
|
|
||||||
elif url.endswith("/mcp"):
|
|
||||||
mcp_type = "streamable_http"
|
|
||||||
else:
|
|
||||||
console.print(
|
|
||||||
f"[red]Cannot auto-detect MCP type from URL '{url}'. Please specify --mcp-type (sse, streamable_http, or stdio)[/red]"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif mcp_type not in ["sse", "streamable_http", "stdio"]:
|
|
||||||
console.print(
|
|
||||||
f"[red]Invalid MCP type '{mcp_type}'. Must be: sse, streamable_http, stdio, or auto[/red]"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Parse headers
|
# Parse headers
|
||||||
headers = {}
|
headers = {}
|
||||||
for h in header:
|
for h in header:
|
||||||
@@ -1765,7 +1424,7 @@ def add_remote_mcp(
|
|||||||
try:
|
try:
|
||||||
with console.status(f"Adding remote MCP server '{name}'..."):
|
with console.status(f"Adding remote MCP server '{name}'..."):
|
||||||
mcp_manager.add_remote_mcp(
|
mcp_manager.add_remote_mcp(
|
||||||
name, url, headers, mcp_type=mcp_type, add_as_default=not no_default
|
name, url, headers, add_as_default=not no_default
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(f"[green]Added remote MCP server '{name}'[/green]")
|
console.print(f"[green]Added remote MCP server '{name}'[/green]")
|
||||||
@@ -1810,7 +1469,7 @@ def run_mcp_inspector(
|
|||||||
# If stop flag is set, stop all running MCP Inspectors
|
# If stop flag is set, stop all running MCP Inspectors
|
||||||
if stop:
|
if stop:
|
||||||
containers = client.containers.list(
|
containers = client.containers.list(
|
||||||
all=True, filters={"label": "cubbi.mcp.inspector=true"}
|
all=True, filters={"label": "mc.mcp.inspector=true"}
|
||||||
)
|
)
|
||||||
if not containers:
|
if not containers:
|
||||||
console.print("[yellow]No running MCP Inspector instances found[/yellow]")
|
console.print("[yellow]No running MCP Inspector instances found[/yellow]")
|
||||||
@@ -1829,7 +1488,7 @@ def run_mcp_inspector(
|
|||||||
|
|
||||||
# Check if inspector is already running
|
# Check if inspector is already running
|
||||||
all_inspectors = client.containers.list(
|
all_inspectors = client.containers.list(
|
||||||
all=True, filters={"label": "cubbi.mcp.inspector=true"}
|
all=True, filters={"label": "mc.mcp.inspector=true"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stop any existing inspectors first
|
# Stop any existing inspectors first
|
||||||
@@ -1873,7 +1532,7 @@ def run_mcp_inspector(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Container name with timestamp to avoid conflicts
|
# Container name with timestamp to avoid conflicts
|
||||||
container_name = f"cubbi_mcp_inspector_{int(time.time())}"
|
container_name = f"mc_mcp_inspector_{int(time.time())}"
|
||||||
|
|
||||||
with console.status("Starting MCP Inspector..."):
|
with console.status("Starting MCP Inspector..."):
|
||||||
# Get MCP servers from configuration
|
# Get MCP servers from configuration
|
||||||
@@ -1906,7 +1565,7 @@ def run_mcp_inspector(
|
|||||||
mcp_name = mcp.get("name")
|
mcp_name = mcp.get("name")
|
||||||
try:
|
try:
|
||||||
# Get the container name for this MCP
|
# Get the container name for this MCP
|
||||||
container_name = f"cubbi_mcp_{mcp_name}"
|
container_name = f"mc_mcp_{mcp_name}"
|
||||||
container = None
|
container = None
|
||||||
|
|
||||||
# Try to find the container
|
# Try to find the container
|
||||||
@@ -1957,7 +1616,7 @@ def run_mcp_inspector(
|
|||||||
# Make sure we have at least one network to connect to
|
# Make sure we have at least one network to connect to
|
||||||
if not mcp_networks_to_connect:
|
if not mcp_networks_to_connect:
|
||||||
# Create an MCP-specific network if none exists
|
# Create an MCP-specific network if none exists
|
||||||
network_name = "cubbi-mcp-network"
|
network_name = "mc-mcp-network"
|
||||||
console.print("No MCP networks found, creating a default one")
|
console.print("No MCP networks found, creating a default one")
|
||||||
try:
|
try:
|
||||||
networks = client.networks.list(names=[network_name])
|
networks = client.networks.list(names=[network_name])
|
||||||
@@ -2017,8 +1676,7 @@ exec npm start
|
|||||||
|
|
||||||
# Write the script to a temp file
|
# Write the script to a temp file
|
||||||
script_path = os.path.join(
|
script_path = os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
os.path.dirname(os.path.abspath(__file__)), "mc_inspector_entrypoint.sh"
|
||||||
"cubbi_inspector_entrypoint.sh",
|
|
||||||
)
|
)
|
||||||
with open(script_path, "w") as f:
|
with open(script_path, "w") as f:
|
||||||
f.write(script_content)
|
f.write(script_content)
|
||||||
@@ -2035,7 +1693,7 @@ exec npm start
|
|||||||
|
|
||||||
# Check if existing container with the same name exists, and remove it
|
# Check if existing container with the same name exists, and remove it
|
||||||
try:
|
try:
|
||||||
existing = client.containers.get("cubbi_mcp_inspector")
|
existing = client.containers.get("mc_mcp_inspector")
|
||||||
if existing.status == "running":
|
if existing.status == "running":
|
||||||
existing.stop(timeout=1)
|
existing.stop(timeout=1)
|
||||||
existing.remove(force=True)
|
existing.remove(force=True)
|
||||||
@@ -2061,7 +1719,7 @@ exec npm start
|
|||||||
for mcp in all_mcps:
|
for mcp in all_mcps:
|
||||||
if mcp.get("type") in ["docker", "proxy"]:
|
if mcp.get("type") in ["docker", "proxy"]:
|
||||||
mcp_name = mcp.get("name")
|
mcp_name = mcp.get("name")
|
||||||
container_name = f"cubbi_mcp_{mcp_name}"
|
container_name = f"mc_mcp_{mcp_name}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if this container exists
|
# Check if this container exists
|
||||||
@@ -2081,7 +1739,7 @@ exec npm start
|
|||||||
|
|
||||||
container = client.containers.run(
|
container = client.containers.run(
|
||||||
image="mcp/inspector",
|
image="mcp/inspector",
|
||||||
name="cubbi_mcp_inspector", # Use a fixed name
|
name="mc_mcp_inspector", # Use a fixed name
|
||||||
detach=True,
|
detach=True,
|
||||||
network=initial_network,
|
network=initial_network,
|
||||||
ports={
|
ports={
|
||||||
@@ -2104,8 +1762,8 @@ exec npm start
|
|||||||
},
|
},
|
||||||
entrypoint="/entrypoint.sh",
|
entrypoint="/entrypoint.sh",
|
||||||
labels={
|
labels={
|
||||||
"cubbi.mcp.inspector": "true",
|
"mc.mcp.inspector": "true",
|
||||||
"cubbi.managed": "true",
|
"mc.managed": "true",
|
||||||
},
|
},
|
||||||
network_mode=None, # Don't use network_mode as we're using network with aliases
|
network_mode=None, # Don't use network_mode as we're using network with aliases
|
||||||
networking_config=client.api.create_networking_config(network_config),
|
networking_config=client.api.create_networking_config(network_config),
|
||||||
@@ -2141,7 +1799,7 @@ exec npm start
|
|||||||
for mcp in all_mcps:
|
for mcp in all_mcps:
|
||||||
if mcp.get("type") in ["docker", "proxy"]:
|
if mcp.get("type") in ["docker", "proxy"]:
|
||||||
mcp_name = mcp.get("name")
|
mcp_name = mcp.get("name")
|
||||||
container_name = f"cubbi_mcp_{mcp_name}"
|
container_name = f"mc_mcp_{mcp_name}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if this container exists
|
# Check if this container exists
|
||||||
@@ -2213,7 +1871,7 @@ exec npm start
|
|||||||
"[yellow]Warning: No MCP servers found or started. The Inspector will run but won't have any servers to connect to.[/yellow]"
|
"[yellow]Warning: No MCP servers found or started. The Inspector will run but won't have any servers to connect to.[/yellow]"
|
||||||
)
|
)
|
||||||
console.print(
|
console.print(
|
||||||
"Start MCP servers using 'cubbi mcp start --all' and then restart the Inspector."
|
"Start MCP servers using 'mc mcp start --all' and then restart the Inspector."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not detach:
|
if not detach:
|
||||||
@@ -2229,19 +1887,19 @@ exec npm start
|
|||||||
|
|
||||||
|
|
||||||
def session_create_entry_point():
|
def session_create_entry_point():
|
||||||
"""Entry point that directly invokes 'cubbi session create'.
|
"""Entry point that directly invokes 'mc session create'.
|
||||||
|
|
||||||
This provides a convenient shortcut:
|
This provides a convenient shortcut:
|
||||||
- 'cubbix' runs as if you typed 'cubbi session create'
|
- 'mcx' runs as if you typed 'mc session create'
|
||||||
- 'cubbix .' mounts the current directory
|
- 'mcx .' mounts the current directory
|
||||||
- 'cubbix /path/to/project' mounts the specified directory
|
- 'mcx /path/to/project' mounts the specified directory
|
||||||
- 'cubbix repo-url' clones the repository
|
- 'mcx repo-url' clones the repository
|
||||||
|
|
||||||
All command-line options are passed through to 'session create'.
|
All command-line options are passed through to 'session create'.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Save the program name (e.g., 'cubbix')
|
# Save the program name (e.g., 'mcx')
|
||||||
prog_name = sys.argv[0]
|
prog_name = sys.argv[0]
|
||||||
# Insert 'session' and 'create' commands before any other arguments
|
# Insert 'session' and 'create' commands before any other arguments
|
||||||
sys.argv.insert(1, "session")
|
sys.argv.insert(1, "session")
|
||||||
175
mcontainer/config.py
Normal file
175
mcontainer/config.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from .models import Config, Driver
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "mc"
|
||||||
|
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.yaml"
|
||||||
|
DEFAULT_DRIVERS_DIR = Path.home() / ".config" / "mc" / "drivers"
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
BUILTIN_DRIVERS_DIR = Path(__file__).parent / "drivers" # mcontainer/drivers
|
||||||
|
|
||||||
|
# Dynamically loaded from drivers directory at runtime
|
||||||
|
DEFAULT_DRIVERS = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
self.config_path = config_path or DEFAULT_CONFIG_FILE
|
||||||
|
self.config_dir = self.config_path.parent
|
||||||
|
self.drivers_dir = DEFAULT_DRIVERS_DIR
|
||||||
|
self.config = self._load_or_create_config()
|
||||||
|
|
||||||
|
# Always load package drivers on initialization
|
||||||
|
# These are separate from the user config
|
||||||
|
self.builtin_drivers = self._load_package_drivers()
|
||||||
|
|
||||||
|
def _load_or_create_config(self) -> Config:
|
||||||
|
"""Load existing config or create a new one with defaults"""
|
||||||
|
if self.config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(self.config_path, "r") as f:
|
||||||
|
config_data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Create a new config from scratch, then update with data from file
|
||||||
|
config = Config(
|
||||||
|
docker=config_data.get("docker", {}),
|
||||||
|
defaults=config_data.get("defaults", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add drivers
|
||||||
|
if "drivers" in config_data:
|
||||||
|
for driver_name, driver_data in config_data["drivers"].items():
|
||||||
|
config.drivers[driver_name] = Driver.model_validate(driver_data)
|
||||||
|
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading config: {e}")
|
||||||
|
return self._create_default_config()
|
||||||
|
else:
|
||||||
|
return self._create_default_config()
|
||||||
|
|
||||||
|
def _create_default_config(self) -> Config:
|
||||||
|
"""Create a default configuration"""
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.drivers_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Initial config without drivers
|
||||||
|
config = Config(
|
||||||
|
docker={
|
||||||
|
"socket": "/var/run/docker.sock",
|
||||||
|
"network": "mc-network",
|
||||||
|
},
|
||||||
|
defaults={
|
||||||
|
"driver": "goose",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save_config(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
def save_config(self, config: Optional[Config] = None) -> None:
|
||||||
|
"""Save the current config to disk"""
|
||||||
|
if config:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Use model_dump with mode="json" for proper serialization of enums
|
||||||
|
config_dict = self.config.model_dump(mode="json")
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
with open(self.config_path, "w") as f:
|
||||||
|
yaml.dump(config_dict, f)
|
||||||
|
|
||||||
|
def get_driver(self, name: str) -> Optional[Driver]:
|
||||||
|
"""Get a driver by name, checking builtin drivers first, then user-configured ones"""
|
||||||
|
# Check builtin drivers first (package drivers take precedence)
|
||||||
|
if name in self.builtin_drivers:
|
||||||
|
return self.builtin_drivers[name]
|
||||||
|
# If not found, check user-configured drivers
|
||||||
|
return self.config.drivers.get(name)
|
||||||
|
|
||||||
|
def list_drivers(self) -> Dict[str, Driver]:
|
||||||
|
"""List all available drivers (both builtin and user-configured)"""
|
||||||
|
# Start with user config drivers
|
||||||
|
all_drivers = dict(self.config.drivers)
|
||||||
|
|
||||||
|
# Add builtin drivers, overriding any user drivers with the same name
|
||||||
|
# This ensures that package-provided drivers always take precedence
|
||||||
|
all_drivers.update(self.builtin_drivers)
|
||||||
|
|
||||||
|
return all_drivers
|
||||||
|
|
||||||
|
# Session management has been moved to SessionManager in session.py
|
||||||
|
|
||||||
|
def load_driver_from_dir(self, driver_dir: Path) -> Optional[Driver]:
|
||||||
|
"""Load a driver configuration from a directory"""
|
||||||
|
# Try with mc-driver.yaml first (new format), then mai-driver.yaml (legacy)
|
||||||
|
yaml_path = driver_dir / "mc-driver.yaml"
|
||||||
|
if not yaml_path.exists():
|
||||||
|
yaml_path = driver_dir / "mai-driver.yaml" # Backward compatibility
|
||||||
|
if not yaml_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(yaml_path, "r") as f:
|
||||||
|
driver_data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Extract required fields
|
||||||
|
if not all(
|
||||||
|
k in driver_data
|
||||||
|
for k in ["name", "description", "version", "maintainer"]
|
||||||
|
):
|
||||||
|
print(f"Driver config {yaml_path} missing required fields")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use Driver.model_validate to handle all fields from YAML
|
||||||
|
# This will map all fields according to the Driver model structure
|
||||||
|
try:
|
||||||
|
# Ensure image field is set if not in YAML
|
||||||
|
if "image" not in driver_data:
|
||||||
|
driver_data["image"] = f"monadical/mc-{driver_data['name']}:latest"
|
||||||
|
|
||||||
|
driver = Driver.model_validate(driver_data)
|
||||||
|
return driver
|
||||||
|
except Exception as validation_error:
|
||||||
|
print(
|
||||||
|
f"Error validating driver data from {yaml_path}: {validation_error}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading driver from {yaml_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_package_drivers(self) -> Dict[str, Driver]:
|
||||||
|
"""Load all package drivers from the mcontainer/drivers directory"""
|
||||||
|
drivers = {}
|
||||||
|
|
||||||
|
if not BUILTIN_DRIVERS_DIR.exists():
|
||||||
|
return drivers
|
||||||
|
|
||||||
|
# Search for mc-driver.yaml files in each subdirectory
|
||||||
|
for driver_dir in BUILTIN_DRIVERS_DIR.iterdir():
|
||||||
|
if driver_dir.is_dir():
|
||||||
|
driver = self.load_driver_from_dir(driver_dir)
|
||||||
|
if driver:
|
||||||
|
drivers[driver.name] = driver
|
||||||
|
|
||||||
|
return drivers
|
||||||
|
|
||||||
|
def get_driver_path(self, driver_name: str) -> Optional[Path]:
|
||||||
|
"""Get the directory path for a driver"""
|
||||||
|
# Check package drivers first (these are the bundled ones)
|
||||||
|
package_path = BUILTIN_DRIVERS_DIR / driver_name
|
||||||
|
if package_path.exists() and package_path.is_dir():
|
||||||
|
return package_path
|
||||||
|
|
||||||
|
# Then check user drivers
|
||||||
|
user_path = self.drivers_dir / driver_name
|
||||||
|
if user_path.exists() and user_path.is_dir():
|
||||||
|
return user_path
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
import concurrent.futures
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
|
import hashlib
|
||||||
|
import pathlib
|
||||||
|
import concurrent.futures
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
from docker.errors import DockerException, ImageNotFound
|
from docker.errors import DockerException, ImageNotFound
|
||||||
|
|
||||||
|
from .models import Session, SessionStatus
|
||||||
from .config import ConfigManager
|
from .config import ConfigManager
|
||||||
from .mcp import MCPManager
|
|
||||||
from .models import Image, Session, SessionStatus
|
|
||||||
from .session import SessionManager
|
from .session import SessionManager
|
||||||
|
from .mcp import MCPManager
|
||||||
from .user_config import UserConfigManager
|
from .user_config import UserConfigManager
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -42,8 +41,8 @@ class ContainerManager:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def _ensure_network(self) -> None:
|
def _ensure_network(self) -> None:
|
||||||
"""Ensure the Cubbi network exists"""
|
"""Ensure the MC network exists"""
|
||||||
network_name = self.config_manager.config.docker.get("network", "cubbi-network")
|
network_name = self.config_manager.config.docker.get("network", "mc-network")
|
||||||
networks = self.client.networks.list(names=[network_name])
|
networks = self.client.networks.list(names=[network_name])
|
||||||
if not networks:
|
if not networks:
|
||||||
self.client.networks.create(network_name, driver="bridge")
|
self.client.networks.create(network_name, driver="bridge")
|
||||||
@@ -64,8 +63,8 @@ class ContainerManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Path to the project configuration directory, or None if no project_name is provided
|
Path to the project configuration directory, or None if no project_name is provided
|
||||||
"""
|
"""
|
||||||
# Get home directory for the Cubbi config
|
# Get home directory for the MC config
|
||||||
cubbi_home = pathlib.Path.home() / ".cubbi"
|
mc_home = pathlib.Path.home() / ".mc"
|
||||||
|
|
||||||
# Only use project_name if explicitly provided
|
# Only use project_name if explicitly provided
|
||||||
if project_name:
|
if project_name:
|
||||||
@@ -73,7 +72,7 @@ class ContainerManager:
|
|||||||
project_hash = hashlib.md5(project_name.encode()).hexdigest()
|
project_hash = hashlib.md5(project_name.encode()).hexdigest()
|
||||||
|
|
||||||
# Create the project config directory path
|
# Create the project config directory path
|
||||||
config_path = cubbi_home / "projects" / project_hash / "config"
|
config_path = mc_home / "projects" / project_hash / "config"
|
||||||
|
|
||||||
# Create the directory if it doesn't exist
|
# Create the directory if it doesn't exist
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -82,22 +81,22 @@ class ContainerManager:
|
|||||||
return config_path
|
return config_path
|
||||||
else:
|
else:
|
||||||
# If no project_name is provided, don't create any config directory
|
# If no project_name is provided, don't create any config directory
|
||||||
# This ensures we don't mount the /cubbi-config volume for project-less sessions
|
# This ensures we don't mount the /mc-config volume for project-less sessions
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_sessions(self) -> List[Session]:
|
def list_sessions(self) -> List[Session]:
|
||||||
"""List all active Cubbi sessions"""
|
"""List all active MC sessions"""
|
||||||
sessions = []
|
sessions = []
|
||||||
try:
|
try:
|
||||||
containers = self.client.containers.list(
|
containers = self.client.containers.list(
|
||||||
all=True, filters={"label": "cubbi.session"}
|
all=True, filters={"label": "mc.session"}
|
||||||
)
|
)
|
||||||
|
|
||||||
for container in containers:
|
for container in containers:
|
||||||
container_id = container.id
|
container_id = container.id
|
||||||
labels = container.labels
|
labels = container.labels
|
||||||
|
|
||||||
session_id = labels.get("cubbi.session.id")
|
session_id = labels.get("mc.session.id")
|
||||||
if not session_id:
|
if not session_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -107,21 +106,12 @@ class ContainerManager:
|
|||||||
elif container.status == "created":
|
elif container.status == "created":
|
||||||
status = SessionStatus.CREATING
|
status = SessionStatus.CREATING
|
||||||
|
|
||||||
# Get MCP list from container labels
|
|
||||||
mcps_str = labels.get("cubbi.mcps", "")
|
|
||||||
mcps = (
|
|
||||||
[mcp.strip() for mcp in mcps_str.split(",") if mcp.strip()]
|
|
||||||
if mcps_str
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
session = Session(
|
session = Session(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
name=labels.get("cubbi.session.name", f"cubbi-{session_id}"),
|
name=labels.get("mc.session.name", f"mc-{session_id}"),
|
||||||
image=labels.get("cubbi.image", "unknown"),
|
driver=labels.get("mc.driver", "unknown"),
|
||||||
status=status,
|
status=status,
|
||||||
container_id=container_id,
|
container_id=container_id,
|
||||||
mcps=mcps,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get port mappings
|
# Get port mappings
|
||||||
@@ -146,7 +136,7 @@ class ContainerManager:
|
|||||||
|
|
||||||
def create_session(
|
def create_session(
|
||||||
self,
|
self,
|
||||||
image_name: str,
|
driver_name: str,
|
||||||
project: Optional[str] = None,
|
project: Optional[str] = None,
|
||||||
project_name: Optional[str] = None,
|
project_name: Optional[str] = None,
|
||||||
environment: Optional[Dict[str, str]] = None,
|
environment: Optional[Dict[str, str]] = None,
|
||||||
@@ -154,21 +144,18 @@ class ContainerManager:
|
|||||||
mount_local: bool = False,
|
mount_local: bool = False,
|
||||||
volumes: Optional[Dict[str, Dict[str, str]]] = None,
|
volumes: Optional[Dict[str, Dict[str, str]]] = None,
|
||||||
networks: Optional[List[str]] = None,
|
networks: Optional[List[str]] = None,
|
||||||
ports: Optional[List[int]] = None,
|
|
||||||
mcp: Optional[List[str]] = None,
|
mcp: Optional[List[str]] = None,
|
||||||
run_command: Optional[str] = None,
|
run_command: Optional[str] = None,
|
||||||
no_shell: bool = False,
|
|
||||||
uid: Optional[int] = None,
|
uid: Optional[int] = None,
|
||||||
gid: Optional[int] = None,
|
gid: Optional[int] = None,
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
provider: Optional[str] = None,
|
provider: Optional[str] = None,
|
||||||
ssh: bool = False,
|
ssh: bool = False,
|
||||||
domains: Optional[List[str]] = None,
|
|
||||||
) -> Optional[Session]:
|
) -> Optional[Session]:
|
||||||
"""Create a new Cubbi session
|
"""Create a new MC session
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_name: The name of the image to use
|
driver_name: The name of the driver to use
|
||||||
project: Optional project repository URL or local directory path
|
project: Optional project repository URL or local directory path
|
||||||
project_name: Optional explicit project name for configuration persistence
|
project_name: Optional explicit project name for configuration persistence
|
||||||
environment: Optional environment variables
|
environment: Optional environment variables
|
||||||
@@ -176,39 +163,23 @@ class ContainerManager:
|
|||||||
mount_local: Whether to mount the specified local directory to /app (ignored if project is None)
|
mount_local: Whether to mount the specified local directory to /app (ignored if project is None)
|
||||||
volumes: Optional additional volumes to mount (dict of {host_path: {"bind": container_path, "mode": mode}})
|
volumes: Optional additional volumes to mount (dict of {host_path: {"bind": container_path, "mode": mode}})
|
||||||
run_command: Optional command to execute before starting the shell
|
run_command: Optional command to execute before starting the shell
|
||||||
no_shell: Whether to close the container after run_command completes (requires run_command)
|
|
||||||
networks: Optional list of additional Docker networks to connect to
|
networks: Optional list of additional Docker networks to connect to
|
||||||
mcp: Optional list of MCP server names to attach to the session
|
mcp: Optional list of MCP server names to attach to the session
|
||||||
uid: Optional user ID for the container process
|
uid: Optional user ID for the container process
|
||||||
gid: Optional group ID for the container process
|
gid: Optional group ID for the container process
|
||||||
model: Optional model to use
|
|
||||||
provider: Optional provider to use
|
|
||||||
ssh: Whether to start the SSH server in the container (default: False)
|
ssh: Whether to start the SSH server in the container (default: False)
|
||||||
domains: Optional list of domains to restrict network access to (uses network-filter)
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try to get image from config first
|
# Validate driver exists
|
||||||
image = self.config_manager.get_image(image_name)
|
driver = self.config_manager.get_driver(driver_name)
|
||||||
if not image:
|
if not driver:
|
||||||
# If not found in config, treat it as a Docker image name
|
print(f"Driver '{driver_name}' not found")
|
||||||
print(
|
return None
|
||||||
f"Image '{image_name}' not found in Cubbi config, using as Docker image..."
|
|
||||||
)
|
|
||||||
image = Image(
|
|
||||||
name=image_name,
|
|
||||||
description=f"Docker image: {image_name}",
|
|
||||||
version="latest",
|
|
||||||
maintainer="unknown",
|
|
||||||
image=image_name,
|
|
||||||
ports=[],
|
|
||||||
volumes=[],
|
|
||||||
persistent_configs=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate session ID and name
|
# Generate session ID and name
|
||||||
session_id = self._generate_session_id()
|
session_id = self._generate_session_id()
|
||||||
if not session_name:
|
if not session_name:
|
||||||
session_name = f"cubbi-{session_id}"
|
session_name = f"mc-{session_id}"
|
||||||
|
|
||||||
# Ensure network exists
|
# Ensure network exists
|
||||||
self._ensure_network()
|
self._ensure_network()
|
||||||
@@ -216,36 +187,33 @@ class ContainerManager:
|
|||||||
# Prepare environment variables
|
# Prepare environment variables
|
||||||
env_vars = environment or {}
|
env_vars = environment or {}
|
||||||
|
|
||||||
# Add CUBBI_USER_ID and CUBBI_GROUP_ID for entrypoint script
|
# Add MC_USER_ID and MC_GROUP_ID for entrypoint script
|
||||||
env_vars["CUBBI_USER_ID"] = str(uid) if uid is not None else "1000"
|
env_vars["MC_USER_ID"] = str(uid) if uid is not None else "1000"
|
||||||
env_vars["CUBBI_GROUP_ID"] = str(gid) if gid is not None else "1000"
|
env_vars["MC_GROUP_ID"] = str(gid) if gid is not None else "1000"
|
||||||
|
|
||||||
# Set SSH environment variable
|
# Set SSH environment variable
|
||||||
env_vars["CUBBI_SSH_ENABLED"] = "true" if ssh else "false"
|
env_vars["MC_SSH_ENABLED"] = "true" if ssh else "false"
|
||||||
|
|
||||||
# Pass some environment from host environment to container for local development
|
# Pass API keys from host environment to container for local development
|
||||||
keys = [
|
api_keys = [
|
||||||
"OPENAI_API_KEY",
|
"OPENAI_API_KEY",
|
||||||
"OPENAI_URL",
|
|
||||||
"ANTHROPIC_API_KEY",
|
"ANTHROPIC_API_KEY",
|
||||||
"ANTHROPIC_AUTH_TOKEN",
|
|
||||||
"ANTHROPIC_CUSTOM_HEADERS",
|
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"GOOGLE_API_KEY",
|
"GOOGLE_API_KEY",
|
||||||
"LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
|
"LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
|
||||||
"LANGFUSE_INIT_PROJECT_SECRET_KEY",
|
"LANGFUSE_INIT_PROJECT_SECRET_KEY",
|
||||||
"LANGFUSE_URL",
|
"LANGFUSE_URL",
|
||||||
]
|
]
|
||||||
for key in keys:
|
for key in api_keys:
|
||||||
if key in os.environ and key not in env_vars:
|
if key in os.environ and key not in env_vars:
|
||||||
env_vars[key] = os.environ[key]
|
env_vars[key] = os.environ[key]
|
||||||
|
|
||||||
# Pull image if needed
|
# Pull image if needed
|
||||||
try:
|
try:
|
||||||
self.client.images.get(image.image)
|
self.client.images.get(driver.image)
|
||||||
except ImageNotFound:
|
except ImageNotFound:
|
||||||
print(f"Pulling image {image.image}...")
|
print(f"Pulling image {driver.image}...")
|
||||||
self.client.images.pull(image.image)
|
self.client.images.pull(driver.image)
|
||||||
|
|
||||||
# Set up volume mounts
|
# Set up volume mounts
|
||||||
session_volumes = {}
|
session_volumes = {}
|
||||||
@@ -271,7 +239,7 @@ class ContainerManager:
|
|||||||
# Clear project for container environment since we're mounting
|
# Clear project for container environment since we're mounting
|
||||||
project = None
|
project = None
|
||||||
elif is_git_repo:
|
elif is_git_repo:
|
||||||
env_vars["CUBBI_PROJECT_URL"] = project
|
env_vars["MC_PROJECT_URL"] = project
|
||||||
print(
|
print(
|
||||||
f"Git repository URL provided - container will clone {project} into /app during initialization"
|
f"Git repository URL provided - container will clone {project} into /app during initialization"
|
||||||
)
|
)
|
||||||
@@ -300,22 +268,22 @@ class ContainerManager:
|
|||||||
|
|
||||||
# Mount the project configuration directory
|
# Mount the project configuration directory
|
||||||
session_volumes[str(project_config_path)] = {
|
session_volumes[str(project_config_path)] = {
|
||||||
"bind": "/cubbi-config",
|
"bind": "/mc-config",
|
||||||
"mode": "rw",
|
"mode": "rw",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add environment variables for config path
|
# Add environment variables for config path
|
||||||
env_vars["CUBBI_CONFIG_DIR"] = "/cubbi-config"
|
env_vars["MC_CONFIG_DIR"] = "/mc-config"
|
||||||
env_vars["CUBBI_IMAGE_CONFIG_DIR"] = f"/cubbi-config/{image_name}"
|
env_vars["MC_DRIVER_CONFIG_DIR"] = f"/mc-config/{driver_name}"
|
||||||
|
|
||||||
# Create image-specific config directories and set up direct volume mounts
|
# Create driver-specific config directories and set up direct volume mounts
|
||||||
if image.persistent_configs:
|
if driver.persistent_configs:
|
||||||
persistent_links_data = [] # To store "source:target" pairs for symlinks
|
persistent_links_data = [] # To store "source:target" pairs for symlinks
|
||||||
print("Setting up persistent configuration directories:")
|
print("Setting up persistent configuration directories:")
|
||||||
for config in image.persistent_configs:
|
for config in driver.persistent_configs:
|
||||||
# Get target directory path on host
|
# Get target directory path on host
|
||||||
target_dir = project_config_path / config.target.removeprefix(
|
target_dir = project_config_path / config.target.removeprefix(
|
||||||
"/cubbi-config/"
|
"/mc-config/"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create directory if it's a directory type config
|
# Create directory if it's a directory type config
|
||||||
@@ -330,7 +298,7 @@ class ContainerManager:
|
|||||||
# File will be created by the container if needed
|
# File will be created by the container if needed
|
||||||
|
|
||||||
# Store the source and target paths for the init script
|
# Store the source and target paths for the init script
|
||||||
# Note: config.target is the path *within* /cubbi-config
|
# Note: config.target is the path *within* /mc-config
|
||||||
persistent_links_data.append(f"{config.source}:{config.target}")
|
persistent_links_data.append(f"{config.source}:{config.target}")
|
||||||
|
|
||||||
print(
|
print(
|
||||||
@@ -339,20 +307,20 @@ class ContainerManager:
|
|||||||
|
|
||||||
# Set up persistent links
|
# Set up persistent links
|
||||||
if persistent_links_data:
|
if persistent_links_data:
|
||||||
env_vars["CUBBI_PERSISTENT_LINKS"] = ";".join(
|
env_vars["MC_PERSISTENT_LINKS"] = ";".join(
|
||||||
persistent_links_data
|
persistent_links_data
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"Setting CUBBI_PERSISTENT_LINKS={env_vars['CUBBI_PERSISTENT_LINKS']}"
|
f"Setting MC_PERSISTENT_LINKS={env_vars['MC_PERSISTENT_LINKS']}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
"No project_name provided - skipping configuration directory setup."
|
"No project_name provided - skipping configuration directory setup."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default Cubbi network
|
# Default MC network
|
||||||
default_network = self.config_manager.config.docker.get(
|
default_network = self.config_manager.config.docker.get(
|
||||||
"network", "cubbi-network"
|
"network", "mc-network"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get network list
|
# Get network list
|
||||||
@@ -458,7 +426,7 @@ class ContainerManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Set type-specific information
|
# Set type-specific information
|
||||||
env_vars[f"MCP_{idx}_TYPE"] = mcp_config.get("mcp_type", "sse")
|
env_vars[f"MCP_{idx}_TYPE"] = "remote"
|
||||||
env_vars[f"MCP_{idx}_NAME"] = mcp_name
|
env_vars[f"MCP_{idx}_NAME"] = mcp_name
|
||||||
|
|
||||||
# Set environment variables for MCP count if we have any
|
# Set environment variables for MCP count if we have any
|
||||||
@@ -471,9 +439,9 @@ class ContainerManager:
|
|||||||
env_vars["MCP_NAMES"] = json.dumps(mcp_names)
|
env_vars["MCP_NAMES"] = json.dumps(mcp_names)
|
||||||
|
|
||||||
# Add user-specified networks
|
# Add user-specified networks
|
||||||
# Default Cubbi network
|
# Default MC network
|
||||||
default_network = self.config_manager.config.docker.get(
|
default_network = self.config_manager.config.docker.get(
|
||||||
"network", "cubbi-network"
|
"network", "mc-network"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get network list, ensuring default is first and no duplicates
|
# Get network list, ensuring default is first and no duplicates
|
||||||
@@ -499,20 +467,12 @@ class ContainerManager:
|
|||||||
target_shell = "/bin/bash"
|
target_shell = "/bin/bash"
|
||||||
|
|
||||||
if run_command:
|
if run_command:
|
||||||
# Set environment variable for cubbi-init.sh to pick up
|
# Set environment variable for mc-init.sh to pick up
|
||||||
env_vars["CUBBI_RUN_COMMAND"] = run_command
|
env_vars["MC_RUN_COMMAND"] = run_command
|
||||||
|
# Set the container's command to be the final shell
|
||||||
# If no_shell is true, set CUBBI_NO_SHELL environment variable
|
|
||||||
if no_shell:
|
|
||||||
env_vars["CUBBI_NO_SHELL"] = "true"
|
|
||||||
logger.info(
|
|
||||||
"Setting CUBBI_NO_SHELL=true, container will exit after run command"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set the container's command to be the final shell (or exit if no_shell is true)
|
|
||||||
container_command = [target_shell]
|
container_command = [target_shell]
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Setting CUBBI_RUN_COMMAND and targeting shell {target_shell}"
|
f"Setting MC_RUN_COMMAND and targeting shell {target_shell}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Use default behavior (often defined by image's ENTRYPOINT/CMD)
|
# Use default behavior (often defined by image's ENTRYPOINT/CMD)
|
||||||
@@ -525,140 +485,43 @@ class ContainerManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Set default model/provider from user config if not explicitly provided
|
# Set default model/provider from user config if not explicitly provided
|
||||||
env_vars["CUBBI_MODEL"] = model or self.user_config_manager.get(
|
env_vars["MC_MODEL"] = model or self.user_config_manager.get(
|
||||||
"defaults.model", ""
|
"defaults.model", ""
|
||||||
)
|
)
|
||||||
env_vars["CUBBI_PROVIDER"] = provider or self.user_config_manager.get(
|
env_vars["MC_PROVIDER"] = provider or self.user_config_manager.get(
|
||||||
"defaults.provider", ""
|
"defaults.provider", ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle network-filter if domains are specified
|
|
||||||
network_filter_container = None
|
|
||||||
network_mode = None
|
|
||||||
|
|
||||||
if domains:
|
|
||||||
# Check for conflicts
|
|
||||||
if networks:
|
|
||||||
print(
|
|
||||||
"[yellow]Warning: Cannot use --domains with --network. Using domain restrictions only.[/yellow]"
|
|
||||||
)
|
|
||||||
networks = []
|
|
||||||
network_list = [default_network]
|
|
||||||
|
|
||||||
# Create network-filter container
|
|
||||||
network_filter_name = f"cubbi-network-filter-{session_id}"
|
|
||||||
|
|
||||||
# Pull network-filter image if needed
|
|
||||||
network_filter_image = "monadicalsas/network-filter:latest"
|
|
||||||
try:
|
|
||||||
self.client.images.get(network_filter_image)
|
|
||||||
except ImageNotFound:
|
|
||||||
print(f"Pulling network-filter image {network_filter_image}...")
|
|
||||||
self.client.images.pull(network_filter_image)
|
|
||||||
|
|
||||||
# Create and start network-filter container
|
|
||||||
print("Creating network-filter container for domain restrictions...")
|
|
||||||
try:
|
|
||||||
# First check if a network-filter container already exists with this name
|
|
||||||
try:
|
|
||||||
existing = self.client.containers.get(network_filter_name)
|
|
||||||
print(
|
|
||||||
f"Removing existing network-filter container {network_filter_name}"
|
|
||||||
)
|
|
||||||
existing.stop()
|
|
||||||
existing.remove()
|
|
||||||
except DockerException:
|
|
||||||
pass # Container doesn't exist, which is fine
|
|
||||||
|
|
||||||
network_filter_container = self.client.containers.run(
|
|
||||||
image=network_filter_image,
|
|
||||||
name=network_filter_name,
|
|
||||||
hostname=network_filter_name,
|
|
||||||
detach=True,
|
|
||||||
environment={"ALLOWED_DOMAINS": ",".join(domains)},
|
|
||||||
labels={
|
|
||||||
"cubbi.network-filter": "true",
|
|
||||||
"cubbi.session.id": session_id,
|
|
||||||
"cubbi.session.name": session_name,
|
|
||||||
},
|
|
||||||
cap_add=["NET_ADMIN"], # Required for iptables
|
|
||||||
remove=False, # Don't auto-remove on stop
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for container to be running
|
|
||||||
import time
|
|
||||||
|
|
||||||
for i in range(10): # Wait up to 10 seconds
|
|
||||||
network_filter_container.reload()
|
|
||||||
if network_filter_container.status == "running":
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
raise Exception(
|
|
||||||
f"Network-filter container failed to start. Status: {network_filter_container.status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use container ID instead of name for network_mode
|
|
||||||
network_mode = f"container:{network_filter_container.id}"
|
|
||||||
print(
|
|
||||||
f"Network restrictions enabled for domains: {', '.join(domains)}"
|
|
||||||
)
|
|
||||||
print(f"Using network mode: {network_mode}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[red]Error creating network-filter container: {e}[/red]")
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Warn about MCP limitations when using network-filter
|
|
||||||
if mcp_names:
|
|
||||||
print(
|
|
||||||
"[yellow]Warning: MCP servers may not be accessible when using domain restrictions.[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create container
|
# Create container
|
||||||
container_params = {
|
container = self.client.containers.create(
|
||||||
"image": image.image,
|
image=driver.image,
|
||||||
"name": session_name,
|
name=session_name,
|
||||||
"detach": True,
|
hostname=session_name,
|
||||||
"tty": True,
|
detach=True,
|
||||||
"stdin_open": True,
|
tty=True,
|
||||||
"environment": env_vars,
|
stdin_open=True,
|
||||||
"volumes": session_volumes,
|
environment=env_vars,
|
||||||
"labels": {
|
volumes=session_volumes,
|
||||||
"cubbi.session": "true",
|
labels={
|
||||||
"cubbi.session.id": session_id,
|
"mc.session": "true",
|
||||||
"cubbi.session.name": session_name,
|
"mc.session.id": session_id,
|
||||||
"cubbi.image": image_name,
|
"mc.session.name": session_name,
|
||||||
"cubbi.project": project or "",
|
"mc.driver": driver_name,
|
||||||
"cubbi.project_name": project_name or "",
|
"mc.project": project or "",
|
||||||
"cubbi.mcps": ",".join(mcp_names) if mcp_names else "",
|
"mc.project_name": project_name or "",
|
||||||
|
"mc.mcps": ",".join(mcp_names) if mcp_names else "",
|
||||||
},
|
},
|
||||||
"command": container_command, # Set the command
|
network=network_list[0], # Connect to the first network initially
|
||||||
"entrypoint": entrypoint, # Set the entrypoint (might be None)
|
command=container_command, # Set the command
|
||||||
}
|
entrypoint=entrypoint, # Set the entrypoint (might be None)
|
||||||
|
ports={f"{port}/tcp": None for port in driver.ports},
|
||||||
# Add port forwarding if ports are specified
|
)
|
||||||
if ports:
|
|
||||||
container_params["ports"] = {f"{port}/tcp": None for port in ports}
|
|
||||||
|
|
||||||
# Use network_mode if domains are specified, otherwise use regular network
|
|
||||||
if network_mode:
|
|
||||||
container_params["network_mode"] = network_mode
|
|
||||||
# Cannot set hostname when using network_mode
|
|
||||||
else:
|
|
||||||
container_params["hostname"] = session_name
|
|
||||||
container_params["network"] = network_list[
|
|
||||||
0
|
|
||||||
] # Connect to the first network initially
|
|
||||||
|
|
||||||
container = self.client.containers.create(**container_params)
|
|
||||||
|
|
||||||
# Start container
|
# Start container
|
||||||
container.start()
|
container.start()
|
||||||
|
|
||||||
# Connect to additional networks (after the first one in network_list)
|
# Connect to additional networks (after the first one in network_list)
|
||||||
# Note: Cannot connect to networks when using network_mode
|
if len(network_list) > 1:
|
||||||
if len(network_list) > 1 and not network_mode:
|
|
||||||
for network_name in network_list[1:]:
|
for network_name in network_list[1:]:
|
||||||
try:
|
try:
|
||||||
# Get or create the network
|
# Get or create the network
|
||||||
@@ -672,6 +535,9 @@ class ContainerManager:
|
|||||||
|
|
||||||
# Connect the container to the network with session name as an alias
|
# Connect the container to the network with session name as an alias
|
||||||
network.connect(container, aliases=[session_name])
|
network.connect(container, aliases=[session_name])
|
||||||
|
print(
|
||||||
|
f"Connected to network: {network_name} with alias: {session_name}"
|
||||||
|
)
|
||||||
except DockerException as e:
|
except DockerException as e:
|
||||||
print(f"Error connecting to network {network_name}: {e}")
|
print(f"Error connecting to network {network_name}: {e}")
|
||||||
|
|
||||||
@@ -679,35 +545,29 @@ class ContainerManager:
|
|||||||
container.reload()
|
container.reload()
|
||||||
|
|
||||||
# Connect directly to each MCP's dedicated network
|
# Connect directly to each MCP's dedicated network
|
||||||
# Note: Cannot connect to networks when using network_mode
|
for mcp_name in mcp_names:
|
||||||
if not network_mode:
|
try:
|
||||||
for mcp_name in mcp_names:
|
# Get the dedicated network for this MCP
|
||||||
|
dedicated_network_name = f"mc-mcp-{mcp_name}-network"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the dedicated network for this MCP
|
network = self.client.networks.get(dedicated_network_name)
|
||||||
dedicated_network_name = f"cubbi-mcp-{mcp_name}-network"
|
|
||||||
|
|
||||||
try:
|
# Connect the session container to the MCP's dedicated network
|
||||||
network = self.client.networks.get(dedicated_network_name)
|
network.connect(container, aliases=[session_name])
|
||||||
|
print(
|
||||||
|
f"Connected session to MCP '{mcp_name}' via dedicated network: {dedicated_network_name}"
|
||||||
|
)
|
||||||
|
except DockerException as e:
|
||||||
|
print(
|
||||||
|
f"Error connecting to MCP dedicated network '{dedicated_network_name}': {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Connect the session container to the MCP's dedicated network
|
except Exception as e:
|
||||||
network.connect(container, aliases=[session_name])
|
print(f"Error connecting session to MCP '{mcp_name}': {e}")
|
||||||
print(
|
|
||||||
f"Connected session to MCP '{mcp_name}' via dedicated network: {dedicated_network_name}"
|
|
||||||
)
|
|
||||||
except DockerException:
|
|
||||||
# print(
|
|
||||||
# f"Error connecting to MCP dedicated network '{dedicated_network_name}': {e}"
|
|
||||||
# )
|
|
||||||
# commented out, may be accessible through another attached network, it's
|
|
||||||
# not mandatory here.
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error connecting session to MCP '{mcp_name}': {e}")
|
|
||||||
|
|
||||||
# Connect to additional user-specified networks
|
# Connect to additional user-specified networks
|
||||||
# Note: Cannot connect to networks when using network_mode
|
if networks:
|
||||||
if networks and not network_mode:
|
|
||||||
for network_name in networks:
|
for network_name in networks:
|
||||||
# Check if already connected to this network
|
# Check if already connected to this network
|
||||||
# NetworkSettings.Networks contains a dict where keys are network names
|
# NetworkSettings.Networks contains a dict where keys are network names
|
||||||
@@ -731,6 +591,9 @@ class ContainerManager:
|
|||||||
|
|
||||||
# Connect the container to the network with session name as an alias
|
# Connect the container to the network with session name as an alias
|
||||||
network.connect(container, aliases=[session_name])
|
network.connect(container, aliases=[session_name])
|
||||||
|
print(
|
||||||
|
f"Connected to network: {network_name} with alias: {session_name}"
|
||||||
|
)
|
||||||
except DockerException as e:
|
except DockerException as e:
|
||||||
print(f"Error connecting to network {network_name}: {e}")
|
print(f"Error connecting to network {network_name}: {e}")
|
||||||
|
|
||||||
@@ -750,7 +613,7 @@ class ContainerManager:
|
|||||||
session = Session(
|
session = Session(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
name=session_name,
|
name=session_name,
|
||||||
image=image_name,
|
driver=driver_name,
|
||||||
status=SessionStatus.RUNNING,
|
status=SessionStatus.RUNNING,
|
||||||
container_id=container.id,
|
container_id=container.id,
|
||||||
ports=ports,
|
ports=ports,
|
||||||
@@ -766,29 +629,15 @@ class ContainerManager:
|
|||||||
|
|
||||||
except DockerException as e:
|
except DockerException as e:
|
||||||
print(f"Error creating session: {e}")
|
print(f"Error creating session: {e}")
|
||||||
|
|
||||||
# Clean up network-filter container if it was created
|
|
||||||
if network_filter_container:
|
|
||||||
try:
|
|
||||||
network_filter_container.stop()
|
|
||||||
network_filter_container.remove()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def close_session(self, session_id: str, kill: bool = False) -> bool:
|
def close_session(self, session_id: str) -> bool:
|
||||||
"""Close a Cubbi session
|
"""Close a MC session"""
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: The ID of the session to close
|
|
||||||
kill: If True, forcefully kill the container instead of graceful stop
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
sessions = self.list_sessions()
|
sessions = self.list_sessions()
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
if session.id == session_id:
|
if session.id == session_id:
|
||||||
return self._close_single_session(session, kill=kill)
|
return self._close_single_session(session)
|
||||||
|
|
||||||
print(f"Session '{session_id}' not found")
|
print(f"Session '{session_id}' not found")
|
||||||
return False
|
return False
|
||||||
@@ -798,7 +647,7 @@ class ContainerManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def connect_session(self, session_id: str) -> bool:
|
def connect_session(self, session_id: str) -> bool:
|
||||||
"""Connect to a running Cubbi session"""
|
"""Connect to a running MC session"""
|
||||||
# Retrieve full session data which should include uid/gid
|
# Retrieve full session data which should include uid/gid
|
||||||
session_data = self.session_manager.get_session(session_id)
|
session_data = self.session_manager.get_session(session_id)
|
||||||
|
|
||||||
@@ -865,12 +714,11 @@ class ContainerManager:
|
|||||||
print(f"Error connecting to session: {e}")
|
print(f"Error connecting to session: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _close_single_session(self, session: Session, kill: bool = False) -> bool:
|
def _close_single_session(self, session: Session) -> bool:
|
||||||
"""Close a single session (helper for parallel processing)
|
"""Close a single session (helper for parallel processing)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: The session to close
|
session: The session to close
|
||||||
kill: If True, forcefully kill the container instead of graceful stop
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: Whether the session was successfully closed
|
bool: Whether the session was successfully closed
|
||||||
@@ -879,45 +727,21 @@ class ContainerManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First, close the main session container
|
|
||||||
container = self.client.containers.get(session.container_id)
|
container = self.client.containers.get(session.container_id)
|
||||||
if kill:
|
container.stop()
|
||||||
container.kill()
|
|
||||||
else:
|
|
||||||
container.stop()
|
|
||||||
container.remove()
|
container.remove()
|
||||||
|
|
||||||
# Check for and close any associated network-filter container
|
|
||||||
network_filter_name = f"cubbi-network-filter-{session.id}"
|
|
||||||
try:
|
|
||||||
network_filter_container = self.client.containers.get(
|
|
||||||
network_filter_name
|
|
||||||
)
|
|
||||||
logger.info(f"Stopping network-filter container {network_filter_name}")
|
|
||||||
if kill:
|
|
||||||
network_filter_container.kill()
|
|
||||||
else:
|
|
||||||
network_filter_container.stop()
|
|
||||||
network_filter_container.remove()
|
|
||||||
except DockerException:
|
|
||||||
# Network-filter container might not exist, which is fine
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.session_manager.remove_session(session.id)
|
self.session_manager.remove_session(session.id)
|
||||||
return True
|
return True
|
||||||
except DockerException as e:
|
except DockerException as e:
|
||||||
print(f"Error closing session {session.id}: {e}")
|
print(f"Error closing session {session.id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def close_all_sessions(
|
def close_all_sessions(self, progress_callback=None) -> Tuple[int, bool]:
|
||||||
self, progress_callback=None, kill: bool = False
|
"""Close all MC sessions with parallel processing and progress reporting
|
||||||
) -> Tuple[int, bool]:
|
|
||||||
"""Close all Cubbi sessions with parallel processing and progress reporting
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
progress_callback: Optional callback function to report progress
|
progress_callback: Optional callback function to report progress
|
||||||
The callback should accept (session_id, status, message)
|
The callback should accept (session_id, status, message)
|
||||||
kill: If True, forcefully kill containers instead of graceful stop
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (number of sessions closed, success)
|
tuple: (number of sessions closed, success)
|
||||||
@@ -937,27 +761,8 @@ class ContainerManager:
|
|||||||
try:
|
try:
|
||||||
container = self.client.containers.get(session.container_id)
|
container = self.client.containers.get(session.container_id)
|
||||||
# Stop and remove container
|
# Stop and remove container
|
||||||
if kill:
|
container.stop()
|
||||||
container.kill()
|
|
||||||
else:
|
|
||||||
container.stop()
|
|
||||||
container.remove()
|
container.remove()
|
||||||
|
|
||||||
# Check for and close any associated network-filter container
|
|
||||||
network_filter_name = f"cubbi-network-filter-{session.id}"
|
|
||||||
try:
|
|
||||||
network_filter_container = self.client.containers.get(
|
|
||||||
network_filter_name
|
|
||||||
)
|
|
||||||
if kill:
|
|
||||||
network_filter_container.kill()
|
|
||||||
else:
|
|
||||||
network_filter_container.stop()
|
|
||||||
network_filter_container.remove()
|
|
||||||
except DockerException:
|
|
||||||
# Network-filter container might not exist, which is fine
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Remove from session storage
|
# Remove from session storage
|
||||||
self.session_manager.remove_session(session.id)
|
self.session_manager.remove_session(session.id)
|
||||||
|
|
||||||
@@ -1005,56 +810,15 @@ class ContainerManager:
|
|||||||
return 0, False
|
return 0, False
|
||||||
|
|
||||||
def get_session_logs(self, session_id: str, follow: bool = False) -> Optional[str]:
|
def get_session_logs(self, session_id: str, follow: bool = False) -> Optional[str]:
|
||||||
"""Get logs from a Cubbi session"""
|
"""Get logs from a MC session"""
|
||||||
try:
|
try:
|
||||||
sessions = self.list_sessions()
|
sessions = self.list_sessions()
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
if session.id == session_id and session.container_id:
|
if session.id == session_id and session.container_id:
|
||||||
container = self.client.containers.get(session.container_id)
|
container = self.client.containers.get(session.container_id)
|
||||||
if follow:
|
if follow:
|
||||||
# For streamed logs, we'll buffer by line to avoid character-by-character output
|
for line in container.logs(stream=True, follow=True):
|
||||||
import io
|
print(line.decode().strip())
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
def process_log_stream(
|
|
||||||
stream: Iterator[bytes],
|
|
||||||
) -> Iterator[str]:
|
|
||||||
buffer = io.StringIO()
|
|
||||||
for chunk in stream:
|
|
||||||
chunk_str = chunk.decode("utf-8", errors="replace")
|
|
||||||
buffer.write(chunk_str)
|
|
||||||
|
|
||||||
# Process complete lines
|
|
||||||
while True:
|
|
||||||
line = buffer.getvalue()
|
|
||||||
newline_pos = line.find("\n")
|
|
||||||
if newline_pos == -1:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Extract complete line and yield it
|
|
||||||
complete_line = line[:newline_pos].rstrip()
|
|
||||||
yield complete_line
|
|
||||||
|
|
||||||
# Update buffer to contain only the remaining content
|
|
||||||
new_buffer = io.StringIO()
|
|
||||||
new_buffer.write(line[newline_pos + 1 :])
|
|
||||||
buffer = new_buffer
|
|
||||||
|
|
||||||
# Don't forget to yield any remaining content at the end
|
|
||||||
final_content = buffer.getvalue().strip()
|
|
||||||
if final_content:
|
|
||||||
yield final_content
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Process the log stream line by line
|
|
||||||
for line in process_log_stream(
|
|
||||||
container.logs(stream=True, follow=True)
|
|
||||||
):
|
|
||||||
print(line)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
# Handle Ctrl+C gracefully
|
|
||||||
print("\nStopped following logs.")
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return container.logs().decode()
|
return container.logs().decode()
|
||||||
@@ -1067,7 +831,7 @@ class ContainerManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_init_logs(self, session_id: str, follow: bool = False) -> Optional[str]:
|
def get_init_logs(self, session_id: str, follow: bool = False) -> Optional[str]:
|
||||||
"""Get initialization logs from a Cubbi session
|
"""Get initialization logs from a MC session
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_id: The session ID
|
session_id: The session ID
|
||||||
@@ -1097,52 +861,9 @@ class ContainerManager:
|
|||||||
f"Following initialization logs for session {session_id}..."
|
f"Following initialization logs for session {session_id}..."
|
||||||
)
|
)
|
||||||
print("Press Ctrl+C to stop following")
|
print("Press Ctrl+C to stop following")
|
||||||
|
container.exec_run(
|
||||||
import io
|
"tail -f /init.log", stream=True, demux=True, tty=True
|
||||||
|
)
|
||||||
def process_exec_stream(stream):
|
|
||||||
buffer = io.StringIO()
|
|
||||||
for chunk_type, chunk_bytes in stream:
|
|
||||||
if chunk_type != 1: # Skip stderr (type 2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
chunk_str = chunk_bytes.decode(
|
|
||||||
"utf-8", errors="replace"
|
|
||||||
)
|
|
||||||
buffer.write(chunk_str)
|
|
||||||
|
|
||||||
# Process complete lines
|
|
||||||
while True:
|
|
||||||
line = buffer.getvalue()
|
|
||||||
newline_pos = line.find("\n")
|
|
||||||
if newline_pos == -1:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Extract complete line and yield it
|
|
||||||
complete_line = line[:newline_pos].rstrip()
|
|
||||||
yield complete_line
|
|
||||||
|
|
||||||
# Update buffer to contain only the remaining content
|
|
||||||
new_buffer = io.StringIO()
|
|
||||||
new_buffer.write(line[newline_pos + 1 :])
|
|
||||||
buffer = new_buffer
|
|
||||||
|
|
||||||
# Don't forget to yield any remaining content at the end
|
|
||||||
final_content = buffer.getvalue().strip()
|
|
||||||
if final_content:
|
|
||||||
yield final_content
|
|
||||||
|
|
||||||
try:
|
|
||||||
exec_result = container.exec_run(
|
|
||||||
"tail -f /init.log", stream=True, demux=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process the exec stream line by line
|
|
||||||
for line in process_exec_stream(exec_result[1]):
|
|
||||||
print(line)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nStopped following logs.")
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
exit_code, output = container.exec_run("cat /init.log")
|
exit_code, output = container.exec_run("cat /init.log")
|
||||||
28
mcontainer/drivers/base.py
Normal file
28
mcontainer/drivers/base.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Base driver implementation for MAI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from ..models import Driver
|
||||||
|
|
||||||
|
|
||||||
|
class DriverManager:
|
||||||
|
"""Manager for MAI drivers"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_default_drivers() -> Dict[str, Driver]:
|
||||||
|
"""Get the default built-in drivers"""
|
||||||
|
from ..config import DEFAULT_DRIVERS
|
||||||
|
|
||||||
|
return DEFAULT_DRIVERS
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_driver_metadata(driver_name: str) -> Optional[Dict]:
|
||||||
|
"""Get metadata for a specific driver"""
|
||||||
|
from ..config import DEFAULT_DRIVERS
|
||||||
|
|
||||||
|
if driver_name in DEFAULT_DRIVERS:
|
||||||
|
return DEFAULT_DRIVERS[driver_name].model_dump()
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
LABEL maintainer="team@monadical.com"
|
LABEL maintainer="team@monadical.com"
|
||||||
LABEL description="Goose for Cubbi"
|
LABEL description="Goose with MCP servers"
|
||||||
|
|
||||||
# Install system dependencies including gosu for user switching and shadow for useradd/groupadd
|
# Install system dependencies including gosu for user switching and shadow for useradd/groupadd
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gosu \
|
gosu \
|
||||||
sudo \
|
|
||||||
passwd \
|
passwd \
|
||||||
|
git \
|
||||||
|
openssh-server \
|
||||||
bash \
|
bash \
|
||||||
curl \
|
curl \
|
||||||
bzip2 \
|
bzip2 \
|
||||||
@@ -16,13 +17,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libxcb1 \
|
libxcb1 \
|
||||||
libdbus-1-3 \
|
libdbus-1-3 \
|
||||||
nano \
|
nano \
|
||||||
tmux \
|
|
||||||
git-core \
|
|
||||||
ripgrep \
|
|
||||||
openssh-client \
|
|
||||||
vim \
|
vim \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up SSH server directory (configuration will be handled by entrypoint if needed)
|
||||||
|
RUN mkdir -p /var/run/sshd && chmod 0755 /var/run/sshd
|
||||||
|
# Do NOT enable root login or set root password here
|
||||||
|
|
||||||
# Install deps
|
# Install deps
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \
|
RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \
|
||||||
@@ -39,24 +40,32 @@ RUN curl -fsSL https://github.com/block/goose/releases/download/stable/download_
|
|||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy initialization system
|
# Copy initialization scripts
|
||||||
COPY cubbi_init.py /cubbi/cubbi_init.py
|
COPY mc-init.sh /mc-init.sh
|
||||||
COPY goose_plugin.py /cubbi/goose_plugin.py
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
COPY cubbi_image.yaml /cubbi/cubbi_image.yaml
|
COPY mc-driver.yaml /mc-driver.yaml
|
||||||
COPY init-status.sh /cubbi/init-status.sh
|
COPY init-status.sh /init-status.sh
|
||||||
RUN chmod +x /cubbi/cubbi_init.py /cubbi/init-status.sh
|
COPY update-goose-config.py /usr/local/bin/update-goose-config.py
|
||||||
RUN echo '[ -x /cubbi/init-status.sh ] && /cubbi/init-status.sh' >> /etc/bash.bashrc
|
|
||||||
|
# Extend env via bashrc
|
||||||
|
|
||||||
|
# Make scripts executable
|
||||||
|
RUN chmod +x /mc-init.sh /entrypoint.sh /init-status.sh \
|
||||||
|
/usr/local/bin/update-goose-config.py
|
||||||
|
|
||||||
|
# Set up initialization status check on login
|
||||||
|
RUN echo '[ -x /init-status.sh ] && /init-status.sh' >> /etc/bash.bashrc
|
||||||
|
|
||||||
# Set up environment
|
# Set up environment
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV UV_LINK_MODE=copy
|
# Set WORKDIR to /app, common practice and expected by mc-init.sh
|
||||||
|
|
||||||
# Pre-install the cubbi_init
|
|
||||||
RUN /cubbi/cubbi_init.py --help
|
|
||||||
|
|
||||||
# Set WORKDIR to /app, common practice and expected by cubbi-init.sh
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENTRYPOINT ["/cubbi/cubbi_init.py"]
|
# Expose ports
|
||||||
|
EXPOSE 8000 22
|
||||||
|
|
||||||
|
# Set entrypoint - container starts as root, entrypoint handles user switching
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
# Default command if none is provided (entrypoint will run this via gosu)
|
||||||
CMD ["tail", "-f", "/dev/null"]
|
CMD ["tail", "-f", "/dev/null"]
|
||||||
41
mcontainer/drivers/goose/README.md
Normal file
41
mcontainer/drivers/goose/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Goose Driver for MC
|
||||||
|
|
||||||
|
This driver provides a containerized environment for running [Goose](https://goose.ai).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Pre-configured environment for Goose AI
|
||||||
|
- Self-hosted instance integration
|
||||||
|
- SSH access
|
||||||
|
- Git repository integration
|
||||||
|
- Langfuse logging support
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Required |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `LANGFUSE_INIT_PROJECT_PUBLIC_KEY` | Langfuse public key | No |
|
||||||
|
| `LANGFUSE_INIT_PROJECT_SECRET_KEY` | Langfuse secret key | No |
|
||||||
|
| `LANGFUSE_URL` | Langfuse API URL | No |
|
||||||
|
| `MC_PROJECT_URL` | Project repository URL | No |
|
||||||
|
| `MC_GIT_SSH_KEY` | SSH key for Git authentication | No |
|
||||||
|
| `MC_GIT_TOKEN` | Token for Git authentication | No |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
To build this driver:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd drivers/goose
|
||||||
|
docker build -t monadical/mc-goose:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new session with this driver
|
||||||
|
mc session create --driver goose
|
||||||
|
|
||||||
|
# Create with project repository
|
||||||
|
mc session create --driver goose --project github.com/username/repo
|
||||||
|
```
|
||||||
7
mcontainer/drivers/goose/entrypoint.sh
Executable file
7
mcontainer/drivers/goose/entrypoint.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Entrypoint script for Goose driver
|
||||||
|
# Executes the standard initialization script, which handles user setup,
|
||||||
|
# service startup (like sshd), and switching to the non-root user
|
||||||
|
# before running the container's command (CMD).
|
||||||
|
|
||||||
|
exec /mc-init.sh "$@"
|
||||||
13
cubbi/images/init-status.sh → mcontainer/drivers/goose/init-status.sh
Executable file → Normal file
13
cubbi/images/init-status.sh → mcontainer/drivers/goose/init-status.sh
Executable file → Normal file
@@ -6,20 +6,17 @@ if [ "$(id -u)" != "0" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure files exist before checking them
|
|
||||||
touch /cubbi/init.status /cubbi/init.log
|
|
||||||
|
|
||||||
# Quick check instead of full logic
|
# Quick check instead of full logic
|
||||||
if ! grep -q "INIT_COMPLETE=true" "/cubbi/init.status" 2>/dev/null; then
|
if ! grep -q "INIT_COMPLETE=true" "/init.status" 2>/dev/null; then
|
||||||
# Only follow logs if initialization is incomplete
|
# Only follow logs if initialization is incomplete
|
||||||
if [ -f "/cubbi/init.log" ]; then
|
if [ -f "/init.log" ]; then
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
tail -f /cubbi/init.log &
|
tail -f /init.log &
|
||||||
tail_pid=$!
|
tail_pid=$!
|
||||||
|
|
||||||
# Check every second if initialization has completed
|
# Check every second if initialization has completed
|
||||||
while true; do
|
while true; do
|
||||||
if grep -q "INIT_COMPLETE=true" "/cubbi/init.status" 2>/dev/null; then
|
if grep -q "INIT_COMPLETE=true" "/init.status" 2>/dev/null; then
|
||||||
kill $tail_pid 2>/dev/null
|
kill $tail_pid 2>/dev/null
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
break
|
break
|
||||||
@@ -31,4 +28,4 @@ if ! grep -q "INIT_COMPLETE=true" "/cubbi/init.status" 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec gosu cubbi /bin/bash -i
|
exec gosu mcuser /bin/bash -il
|
||||||
63
mcontainer/drivers/goose/mc-driver.yaml
Normal file
63
mcontainer/drivers/goose/mc-driver.yaml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: goose
|
||||||
|
description: Goose AI environment
|
||||||
|
version: 1.0.0
|
||||||
|
maintainer: team@monadical.com
|
||||||
|
image: monadical/mc-goose:latest
|
||||||
|
|
||||||
|
init:
|
||||||
|
pre_command: /mc-init.sh
|
||||||
|
command: /entrypoint.sh
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- name: LANGFUSE_INIT_PROJECT_PUBLIC_KEY
|
||||||
|
description: Langfuse public key
|
||||||
|
required: false
|
||||||
|
sensitive: true
|
||||||
|
|
||||||
|
- name: LANGFUSE_INIT_PROJECT_SECRET_KEY
|
||||||
|
description: Langfuse secret key
|
||||||
|
required: false
|
||||||
|
sensitive: true
|
||||||
|
|
||||||
|
- name: LANGFUSE_URL
|
||||||
|
description: Langfuse API URL
|
||||||
|
required: false
|
||||||
|
default: https://cloud.langfuse.com
|
||||||
|
|
||||||
|
# Project environment variables
|
||||||
|
- name: MC_PROJECT_URL
|
||||||
|
description: Project repository URL
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- name: MC_PROJECT_TYPE
|
||||||
|
description: Project repository type (git, svn, etc.)
|
||||||
|
required: false
|
||||||
|
default: git
|
||||||
|
|
||||||
|
- name: MC_GIT_SSH_KEY
|
||||||
|
description: SSH key for Git authentication
|
||||||
|
required: false
|
||||||
|
sensitive: true
|
||||||
|
|
||||||
|
- name: MC_GIT_TOKEN
|
||||||
|
description: Token for Git authentication
|
||||||
|
required: false
|
||||||
|
sensitive: true
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- 8000 # Main application
|
||||||
|
- 22 # SSH server
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- mountPath: /app
|
||||||
|
description: Application directory
|
||||||
|
|
||||||
|
persistent_configs:
|
||||||
|
- source: "/app/.goose"
|
||||||
|
target: "/mc-config/goose-app"
|
||||||
|
type: "directory"
|
||||||
|
description: "Goose memory"
|
||||||
|
- source: "/home/mcuser/.config/goose"
|
||||||
|
target: "/mc-config/goose-config"
|
||||||
|
type: "directory"
|
||||||
|
description: "Goose configuration"
|
||||||
180
mcontainer/drivers/goose/mc-init.sh
Executable file
180
mcontainer/drivers/goose/mc-init.sh
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Standardized initialization script for MC drivers
|
||||||
|
|
||||||
|
# Redirect all output to both stdout and the log file
|
||||||
|
exec > >(tee -a /init.log) 2>&1
|
||||||
|
|
||||||
|
# Mark initialization as started
|
||||||
|
echo "=== MC Initialization started at $(date) ==="
|
||||||
|
|
||||||
|
# --- START INSERTED BLOCK ---
|
||||||
|
|
||||||
|
# Default UID/GID if not provided (should be passed by mc tool)
|
||||||
|
MC_USER_ID=${MC_USER_ID:-1000}
|
||||||
|
MC_GROUP_ID=${MC_GROUP_ID:-1000}
|
||||||
|
|
||||||
|
echo "Using UID: $MC_USER_ID, GID: $MC_GROUP_ID"
|
||||||
|
|
||||||
|
# Create group if it doesn't exist
|
||||||
|
if ! getent group mcuser > /dev/null; then
|
||||||
|
groupadd -g $MC_GROUP_ID mcuser
|
||||||
|
else
|
||||||
|
# If group exists but has different GID, modify it
|
||||||
|
EXISTING_GID=$(getent group mcuser | cut -d: -f3)
|
||||||
|
if [ "$EXISTING_GID" != "$MC_GROUP_ID" ]; then
|
||||||
|
groupmod -g $MC_GROUP_ID mcuser
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create user if it doesn't exist
|
||||||
|
if ! getent passwd mcuser > /dev/null; then
|
||||||
|
useradd --shell /bin/bash --uid $MC_USER_ID --gid $MC_GROUP_ID --no-create-home mcuser
|
||||||
|
else
|
||||||
|
# If user exists but has different UID/GID, modify it
|
||||||
|
EXISTING_UID=$(getent passwd mcuser | cut -d: -f3)
|
||||||
|
EXISTING_GID=$(getent passwd mcuser | cut -d: -f4)
|
||||||
|
if [ "$EXISTING_UID" != "$MC_USER_ID" ] || [ "$EXISTING_GID" != "$MC_GROUP_ID" ]; then
|
||||||
|
usermod --uid $MC_USER_ID --gid $MC_GROUP_ID mcuser
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create home directory and set permissions
|
||||||
|
mkdir -p /home/mcuser
|
||||||
|
chown $MC_USER_ID:$MC_GROUP_ID /home/mcuser
|
||||||
|
mkdir -p /app
|
||||||
|
chown $MC_USER_ID:$MC_GROUP_ID /app
|
||||||
|
|
||||||
|
# Copy /root/.local/bin to the user's home directory
|
||||||
|
if [ -d /root/.local/bin ]; then
|
||||||
|
echo "Copying /root/.local/bin to /home/mcuser/.local/bin..."
|
||||||
|
mkdir -p /home/mcuser/.local/bin
|
||||||
|
cp -r /root/.local/bin/* /home/mcuser/.local/bin/
|
||||||
|
chown -R $MC_USER_ID:$MC_GROUP_ID /home/mcuser/.local
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start SSH server only if explicitly enabled
|
||||||
|
if [ "$MC_SSH_ENABLED" = "true" ]; then
|
||||||
|
echo "Starting SSH server..."
|
||||||
|
/usr/sbin/sshd
|
||||||
|
else
|
||||||
|
echo "SSH server disabled (use --ssh flag to enable)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- END INSERTED BLOCK ---
|
||||||
|
|
||||||
|
echo "INIT_COMPLETE=false" > /init.status
|
||||||
|
|
||||||
|
# Project initialization
|
||||||
|
if [ -n "$MC_PROJECT_URL" ]; then
|
||||||
|
echo "Initializing project: $MC_PROJECT_URL"
|
||||||
|
|
||||||
|
# Set up SSH key if provided
|
||||||
|
if [ -n "$MC_GIT_SSH_KEY" ]; then
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$MC_GIT_SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
ssh-keyscan gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
ssh-keyscan bitbucket.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set up token if provided
|
||||||
|
if [ -n "$MC_GIT_TOKEN" ]; then
|
||||||
|
git config --global credential.helper store
|
||||||
|
echo "https://$MC_GIT_TOKEN:x-oauth-basic@github.com" > ~/.git-credentials
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clone repository
|
||||||
|
git clone $MC_PROJECT_URL /app
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
# Run project-specific initialization if present
|
||||||
|
if [ -f "/app/.mc/init.sh" ]; then
|
||||||
|
bash /app/.mc/init.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persistent configs are now directly mounted as volumes
|
||||||
|
# No need to create symlinks anymore
|
||||||
|
if [ -n "$MC_CONFIG_DIR" ] && [ -d "$MC_CONFIG_DIR" ]; then
|
||||||
|
echo "Using persistent configuration volumes (direct mounts)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Goose uses self-hosted instance, no API key required
|
||||||
|
|
||||||
|
# Set up Langfuse logging if credentials are provided
|
||||||
|
if [ -n "$LANGFUSE_INIT_PROJECT_SECRET_KEY" ] && [ -n "$LANGFUSE_INIT_PROJECT_PUBLIC_KEY" ]; then
|
||||||
|
echo "Setting up Langfuse logging"
|
||||||
|
export LANGFUSE_INIT_PROJECT_SECRET_KEY="$LANGFUSE_INIT_PROJECT_SECRET_KEY"
|
||||||
|
export LANGFUSE_INIT_PROJECT_PUBLIC_KEY="$LANGFUSE_INIT_PROJECT_PUBLIC_KEY"
|
||||||
|
export LANGFUSE_URL="${LANGFUSE_URL:-https://cloud.langfuse.com}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure /mc-config directory exists (required for symlinks)
|
||||||
|
if [ ! -d "/mc-config" ]; then
|
||||||
|
echo "Creating /mc-config directory since it doesn't exist"
|
||||||
|
mkdir -p /mc-config
|
||||||
|
chown $MC_USER_ID:$MC_GROUP_ID /mc-config
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create symlinks for persistent configurations defined in the driver
|
||||||
|
if [ -n "$MC_PERSISTENT_LINKS" ]; then
|
||||||
|
echo "Creating persistent configuration symlinks..."
|
||||||
|
# Split by semicolon
|
||||||
|
IFS=';' read -ra LINKS <<< "$MC_PERSISTENT_LINKS"
|
||||||
|
for link_pair in "${LINKS[@]}"; do
|
||||||
|
# Split by colon
|
||||||
|
IFS=':' read -r source_path target_path <<< "$link_pair"
|
||||||
|
|
||||||
|
if [ -z "$source_path" ] || [ -z "$target_path" ]; then
|
||||||
|
echo "Warning: Invalid link pair format '$link_pair', skipping."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Processing link: $source_path -> $target_path"
|
||||||
|
parent_dir=$(dirname "$source_path")
|
||||||
|
|
||||||
|
# Ensure parent directory of the link source exists and is owned by mcuser
|
||||||
|
if [ ! -d "$parent_dir" ]; then
|
||||||
|
echo "Creating parent directory: $parent_dir"
|
||||||
|
mkdir -p "$parent_dir"
|
||||||
|
echo "Changing ownership of parent $parent_dir to $MC_USER_ID:$MC_GROUP_ID"
|
||||||
|
chown "$MC_USER_ID:$MC_GROUP_ID" "$parent_dir" || echo "Warning: Could not chown parent $parent_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the symlink (force, no-dereference)
|
||||||
|
echo "Creating symlink: ln -sfn $target_path $source_path"
|
||||||
|
ln -sfn "$target_path" "$source_path"
|
||||||
|
|
||||||
|
# Optionally, change ownership of the symlink itself
|
||||||
|
echo "Changing ownership of symlink $source_path to $MC_USER_ID:$MC_GROUP_ID"
|
||||||
|
chown -h "$MC_USER_ID:$MC_GROUP_ID" "$source_path" || echo "Warning: Could not chown symlink $source_path"
|
||||||
|
|
||||||
|
done
|
||||||
|
echo "Persistent configuration symlinks created."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update Goose configuration with available MCP servers (run as mcuser after symlinks are created)
|
||||||
|
if [ -f "/usr/local/bin/update-goose-config.py" ]; then
|
||||||
|
echo "Updating Goose configuration with MCP servers as mcuser..."
|
||||||
|
gosu mcuser /usr/local/bin/update-goose-config.py
|
||||||
|
elif [ -f "$(dirname "$0")/update-goose-config.py" ]; then
|
||||||
|
echo "Updating Goose configuration with MCP servers as mcuser..."
|
||||||
|
gosu mcuser "$(dirname "$0")/update-goose-config.py"
|
||||||
|
else
|
||||||
|
echo "Warning: update-goose-config.py script not found. Goose configuration will not be updated."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the user command first, if set, as mcuser
|
||||||
|
if [ -n "$MC_RUN_COMMAND" ]; then
|
||||||
|
echo "--- Executing initial command: $MC_RUN_COMMAND ---";
|
||||||
|
gosu mcuser sh -c "$MC_RUN_COMMAND"; # Run user command as mcuser
|
||||||
|
COMMAND_EXIT_CODE=$?;
|
||||||
|
echo "--- Initial command finished (exit code: $COMMAND_EXIT_CODE) ---";
|
||||||
|
fi;
|
||||||
|
|
||||||
|
# Mark initialization as complete
|
||||||
|
echo "=== MC Initialization completed at $(date) ==="
|
||||||
|
echo "INIT_COMPLETE=true" > /init.status
|
||||||
|
|
||||||
|
exec gosu mcuser "$@"
|
||||||
106
mcontainer/drivers/goose/update-goose-config.py
Normal file
106
mcontainer/drivers/goose/update-goose-config.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = ["ruamel.yaml"]
|
||||||
|
# ///
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
# Path to goose config
|
||||||
|
GOOSE_CONFIG = Path.home() / ".config/goose/config.yaml"
|
||||||
|
CONFIG_DIR = GOOSE_CONFIG.parent
|
||||||
|
|
||||||
|
# Create config directory if it doesn't exist
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_config():
|
||||||
|
"""Update Goose configuration based on environment variables and config file"""
|
||||||
|
|
||||||
|
yaml = YAML()
|
||||||
|
|
||||||
|
# Load or initialize the YAML configuration
|
||||||
|
if not GOOSE_CONFIG.exists():
|
||||||
|
config_data = {"extensions": {}}
|
||||||
|
else:
|
||||||
|
with GOOSE_CONFIG.open("r") as f:
|
||||||
|
config_data = yaml.load(f)
|
||||||
|
if "extensions" not in config_data:
|
||||||
|
config_data["extensions"] = {}
|
||||||
|
|
||||||
|
# Add default developer extension
|
||||||
|
config_data["extensions"]["developer"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"name": "developer",
|
||||||
|
"timeout": 300,
|
||||||
|
"type": "builtin",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update goose configuration with model and provider from environment variables
|
||||||
|
goose_model = os.environ.get("MC_MODEL")
|
||||||
|
goose_provider = os.environ.get("MC_PROVIDER")
|
||||||
|
|
||||||
|
if goose_model:
|
||||||
|
config_data["GOOSE_MODEL"] = goose_model
|
||||||
|
print(f"Set GOOSE_MODEL to {goose_model}")
|
||||||
|
|
||||||
|
if goose_provider:
|
||||||
|
config_data["GOOSE_PROVIDER"] = goose_provider
|
||||||
|
print(f"Set GOOSE_PROVIDER to {goose_provider}")
|
||||||
|
|
||||||
|
# Get MCP information from environment variables
|
||||||
|
mcp_count = int(os.environ.get("MCP_COUNT", "0"))
|
||||||
|
mcp_names_str = os.environ.get("MCP_NAMES", "[]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
mcp_names = json.loads(mcp_names_str)
|
||||||
|
print(f"Found {mcp_count} MCP servers: {', '.join(mcp_names)}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
mcp_names = []
|
||||||
|
print("Error parsing MCP_NAMES environment variable")
|
||||||
|
|
||||||
|
# Process each MCP - collect the MCP configs to add or update
|
||||||
|
for idx in range(mcp_count):
|
||||||
|
mcp_name = os.environ.get(f"MCP_{idx}_NAME")
|
||||||
|
mcp_type = os.environ.get(f"MCP_{idx}_TYPE")
|
||||||
|
mcp_host = os.environ.get(f"MCP_{idx}_HOST")
|
||||||
|
|
||||||
|
# Always use container's SSE port (8080) not the host-bound port
|
||||||
|
if mcp_name and mcp_host:
|
||||||
|
# Use standard MCP SSE port (8080)
|
||||||
|
mcp_url = f"http://{mcp_host}:8080/sse"
|
||||||
|
print(f"Processing MCP extension: {mcp_name} ({mcp_type}) - {mcp_url}")
|
||||||
|
config_data["extensions"][mcp_name] = {
|
||||||
|
"enabled": True,
|
||||||
|
"name": mcp_name,
|
||||||
|
"timeout": 60,
|
||||||
|
"type": "sse",
|
||||||
|
"uri": mcp_url,
|
||||||
|
"envs": {},
|
||||||
|
}
|
||||||
|
elif mcp_name and os.environ.get(f"MCP_{idx}_URL"):
|
||||||
|
# For remote MCPs, use the URL provided in environment
|
||||||
|
mcp_url = os.environ.get(f"MCP_{idx}_URL")
|
||||||
|
print(
|
||||||
|
f"Processing remote MCP extension: {mcp_name} ({mcp_type}) - {mcp_url}"
|
||||||
|
)
|
||||||
|
config_data["extensions"][mcp_name] = {
|
||||||
|
"enabled": True,
|
||||||
|
"name": mcp_name,
|
||||||
|
"timeout": 60,
|
||||||
|
"type": "sse",
|
||||||
|
"uri": mcp_url,
|
||||||
|
"envs": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write the updated configuration back to the file
|
||||||
|
with GOOSE_CONFIG.open("w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
print(f"Updated Goose configuration at {GOOSE_CONFIG}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
update_config()
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
MCP (Model Control Protocol) server management for Cubbi Container.
|
MCP (Model Control Protocol) server management for Monadical Container.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
from docker.errors import DockerException, ImageNotFound, NotFound
|
from docker.errors import DockerException, ImageNotFound, NotFound
|
||||||
|
|
||||||
from .models import DockerMCP, MCPContainer, MCPStatus, ProxyMCP, RemoteMCP
|
from .models import MCPStatus, RemoteMCP, DockerMCP, ProxyMCP, MCPContainer
|
||||||
from .user_config import UserConfigManager
|
from .user_config import UserConfigManager
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -38,7 +37,7 @@ class MCPManager:
|
|||||||
"""Ensure the MCP network exists and return its name.
|
"""Ensure the MCP network exists and return its name.
|
||||||
Note: This is used only by the inspector, not for session-to-MCP connections.
|
Note: This is used only by the inspector, not for session-to-MCP connections.
|
||||||
"""
|
"""
|
||||||
network_name = "cubbi-mcp-network"
|
network_name = "mc-mcp-network"
|
||||||
if self.client:
|
if self.client:
|
||||||
networks = self.client.networks.list(names=[network_name])
|
networks = self.client.networks.list(names=[network_name])
|
||||||
if not networks:
|
if not networks:
|
||||||
@@ -54,7 +53,7 @@ class MCPManager:
|
|||||||
Returns:
|
Returns:
|
||||||
The name of the dedicated network
|
The name of the dedicated network
|
||||||
"""
|
"""
|
||||||
network_name = f"cubbi-mcp-{mcp_name}-network"
|
network_name = f"mc-mcp-{mcp_name}-network"
|
||||||
if self.client:
|
if self.client:
|
||||||
networks = self.client.networks.list(names=[network_name])
|
networks = self.client.networks.list(names=[network_name])
|
||||||
if not networks:
|
if not networks:
|
||||||
@@ -79,7 +78,6 @@ class MCPManager:
|
|||||||
name: str,
|
name: str,
|
||||||
url: str,
|
url: str,
|
||||||
headers: Dict[str, str] = None,
|
headers: Dict[str, str] = None,
|
||||||
mcp_type: Optional[str] = None,
|
|
||||||
add_as_default: bool = True,
|
add_as_default: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Add a remote MCP server.
|
"""Add a remote MCP server.
|
||||||
@@ -98,7 +96,6 @@ class MCPManager:
|
|||||||
name=name,
|
name=name,
|
||||||
url=url,
|
url=url,
|
||||||
headers=headers or {},
|
headers=headers or {},
|
||||||
mcp_type=mcp_type,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to the configuration
|
# Add to the configuration
|
||||||
@@ -284,7 +281,7 @@ class MCPManager:
|
|||||||
|
|
||||||
def get_mcp_container_name(self, mcp_name: str) -> str:
|
def get_mcp_container_name(self, mcp_name: str) -> str:
|
||||||
"""Get the Docker container name for an MCP server."""
|
"""Get the Docker container name for an MCP server."""
|
||||||
return f"cubbi_mcp_{mcp_name}"
|
return f"mc_mcp_{mcp_name}"
|
||||||
|
|
||||||
def start_mcp(self, name: str) -> Dict[str, Any]:
|
def start_mcp(self, name: str) -> Dict[str, Any]:
|
||||||
"""Start an MCP server container."""
|
"""Start an MCP server container."""
|
||||||
@@ -376,9 +373,9 @@ class MCPManager:
|
|||||||
network=None, # Start without network, we'll add it with aliases
|
network=None, # Start without network, we'll add it with aliases
|
||||||
environment=mcp_config.get("env", {}),
|
environment=mcp_config.get("env", {}),
|
||||||
labels={
|
labels={
|
||||||
"cubbi.mcp": "true",
|
"mc.mcp": "true",
|
||||||
"cubbi.mcp.name": name,
|
"mc.mcp.name": name,
|
||||||
"cubbi.mcp.type": "docker",
|
"mc.mcp.type": "docker",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -542,7 +539,7 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
f.write(dockerfile_content)
|
f.write(dockerfile_content)
|
||||||
|
|
||||||
# Build the image
|
# Build the image
|
||||||
custom_image_name = f"cubbi_mcp_proxy_{name}"
|
custom_image_name = f"mc_mcp_proxy_{name}"
|
||||||
logger.info(f"Building custom proxy image: {custom_image_name}")
|
logger.info(f"Building custom proxy image: {custom_image_name}")
|
||||||
self.client.images.build(
|
self.client.images.build(
|
||||||
path=tmp_dir,
|
path=tmp_dir,
|
||||||
@@ -579,9 +576,9 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
labels={
|
labels={
|
||||||
"cubbi.mcp": "true",
|
"mc.mcp": "true",
|
||||||
"cubbi.mcp.name": name,
|
"mc.mcp.name": name,
|
||||||
"cubbi.mcp.type": "proxy",
|
"mc.mcp.type": "proxy",
|
||||||
},
|
},
|
||||||
ports=port_bindings, # Bind the SSE port to the host if configured
|
ports=port_bindings, # Bind the SSE port to the host if configured
|
||||||
)
|
)
|
||||||
@@ -818,10 +815,8 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
if not self.client:
|
if not self.client:
|
||||||
raise Exception("Docker client is not available")
|
raise Exception("Docker client is not available")
|
||||||
|
|
||||||
# Get all containers with the cubbi.mcp label
|
# Get all containers with the mc.mcp label
|
||||||
containers = self.client.containers.list(
|
containers = self.client.containers.list(all=True, filters={"label": "mc.mcp"})
|
||||||
all=True, filters={"label": "cubbi.mcp"}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for container in containers:
|
for container in containers:
|
||||||
@@ -862,13 +857,13 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
|
|
||||||
# Create MCPContainer object
|
# Create MCPContainer object
|
||||||
mcp_container = MCPContainer(
|
mcp_container = MCPContainer(
|
||||||
name=labels.get("cubbi.mcp.name", "unknown"),
|
name=labels.get("mc.mcp.name", "unknown"),
|
||||||
container_id=container.id,
|
container_id=container.id,
|
||||||
status=status,
|
status=status,
|
||||||
image=container_info["Config"]["Image"],
|
image=container_info["Config"]["Image"],
|
||||||
ports=ports,
|
ports=ports,
|
||||||
created_at=container_info["Created"],
|
created_at=container_info["Created"],
|
||||||
type=labels.get("cubbi.mcp.type", "unknown"),
|
type=labels.get("mc.mcp.type", "unknown"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result.append(mcp_container)
|
result.append(mcp_container)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +17,7 @@ class MCPStatus(str, Enum):
|
|||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
class ImageEnvironmentVariable(BaseModel):
|
class DriverEnvironmentVariable(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
required: bool = False
|
required: bool = False
|
||||||
@@ -38,19 +37,20 @@ class VolumeMount(BaseModel):
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ImageInit(BaseModel):
|
class DriverInit(BaseModel):
|
||||||
pre_command: Optional[str] = None
|
pre_command: Optional[str] = None
|
||||||
command: str
|
command: str
|
||||||
|
|
||||||
|
|
||||||
class Image(BaseModel):
|
class Driver(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
version: str
|
version: str
|
||||||
maintainer: str
|
maintainer: str
|
||||||
image: str
|
image: str
|
||||||
init: Optional[ImageInit] = None
|
init: Optional[DriverInit] = None
|
||||||
environment: List[ImageEnvironmentVariable] = []
|
environment: List[DriverEnvironmentVariable] = []
|
||||||
|
ports: List[int] = []
|
||||||
volumes: List[VolumeMount] = []
|
volumes: List[VolumeMount] = []
|
||||||
persistent_configs: List[PersistentConfig] = []
|
persistent_configs: List[PersistentConfig] = []
|
||||||
|
|
||||||
@@ -60,7 +60,6 @@ class RemoteMCP(BaseModel):
|
|||||||
type: str = "remote"
|
type: str = "remote"
|
||||||
url: str
|
url: str
|
||||||
headers: Dict[str, str] = Field(default_factory=dict)
|
headers: Dict[str, str] = Field(default_factory=dict)
|
||||||
mcp_type: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class DockerMCP(BaseModel):
|
class DockerMCP(BaseModel):
|
||||||
@@ -98,17 +97,16 @@ class MCPContainer(BaseModel):
|
|||||||
class Session(BaseModel):
|
class Session(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
image: str
|
driver: str
|
||||||
status: SessionStatus
|
status: SessionStatus
|
||||||
container_id: Optional[str] = None
|
container_id: Optional[str] = None
|
||||||
ports: Dict[int, int] = Field(default_factory=dict)
|
ports: Dict[int, int] = Field(default_factory=dict)
|
||||||
mcps: List[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
docker: Dict[str, str] = Field(default_factory=dict)
|
docker: Dict[str, str] = Field(default_factory=dict)
|
||||||
images: Dict[str, Image] = Field(default_factory=dict)
|
drivers: Dict[str, Driver] = Field(default_factory=dict)
|
||||||
defaults: Dict[str, object] = Field(
|
defaults: Dict[str, object] = Field(
|
||||||
default_factory=dict
|
default_factory=dict
|
||||||
) # Can store strings, booleans, lists, or other values
|
) # Can store strings, booleans, or other values
|
||||||
mcps: List[Dict[str, Any]] = Field(default_factory=list)
|
mcps: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
14
mcontainer/service.py
Normal file
14
mcontainer/service.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
MC Service - Container Management Web Service
|
||||||
|
(This is a placeholder for Phase 2)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the MC service"""
|
||||||
|
print("MC Service - Container Management Web Service")
|
||||||
|
print("This feature will be implemented in Phase 2")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Session storage management for Cubbi Container Tool.
|
Session storage management for Monadical Container Tool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
import yaml
|
DEFAULT_SESSIONS_FILE = Path.home() / ".config" / "mc" / "sessions.yaml"
|
||||||
|
|
||||||
DEFAULT_SESSIONS_FILE = Path.home() / ".config" / "cubbi" / "sessions.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
@@ -19,7 +18,7 @@ class SessionManager:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
sessions_path: Optional path to the sessions file.
|
sessions_path: Optional path to the sessions file.
|
||||||
Defaults to ~/.config/cubbi/sessions.yaml.
|
Defaults to ~/.config/mc/sessions.yaml.
|
||||||
"""
|
"""
|
||||||
self.sessions_path = sessions_path or DEFAULT_SESSIONS_FILE
|
self.sessions_path = sessions_path or DEFAULT_SESSIONS_FILE
|
||||||
self.sessions = self._load_sessions()
|
self.sessions = self._load_sessions()
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
User configuration manager for Cubbi Container Tool.
|
User configuration manager for Monadical Container Tool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, List, Tuple
|
||||||
|
|
||||||
# Define the environment variable mappings
|
# Define the environment variable mappings
|
||||||
ENV_MAPPINGS = {
|
ENV_MAPPINGS = {
|
||||||
@@ -14,7 +13,6 @@ ENV_MAPPINGS = {
|
|||||||
"services.langfuse.public_key": "LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
|
"services.langfuse.public_key": "LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
|
||||||
"services.langfuse.secret_key": "LANGFUSE_INIT_PROJECT_SECRET_KEY",
|
"services.langfuse.secret_key": "LANGFUSE_INIT_PROJECT_SECRET_KEY",
|
||||||
"services.openai.api_key": "OPENAI_API_KEY",
|
"services.openai.api_key": "OPENAI_API_KEY",
|
||||||
"services.openai.url": "OPENAI_URL",
|
|
||||||
"services.anthropic.api_key": "ANTHROPIC_API_KEY",
|
"services.anthropic.api_key": "ANTHROPIC_API_KEY",
|
||||||
"services.openrouter.api_key": "OPENROUTER_API_KEY",
|
"services.openrouter.api_key": "OPENROUTER_API_KEY",
|
||||||
"services.google.api_key": "GOOGLE_API_KEY",
|
"services.google.api_key": "GOOGLE_API_KEY",
|
||||||
@@ -29,11 +27,11 @@ class UserConfigManager:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_path: Optional path to the configuration file.
|
config_path: Optional path to the configuration file.
|
||||||
Defaults to ~/.config/cubbi/config.yaml.
|
Defaults to ~/.config/mc/config.yaml.
|
||||||
"""
|
"""
|
||||||
# Default to ~/.config/cubbi/config.yaml
|
# Default to ~/.config/mc/config.yaml
|
||||||
self.config_path = Path(
|
self.config_path = Path(
|
||||||
config_path or os.path.expanduser("~/.config/cubbi/config.yaml")
|
config_path or os.path.expanduser("~/.config/mc/config.yaml")
|
||||||
)
|
)
|
||||||
self.config = self._load_config()
|
self.config = self._load_config()
|
||||||
|
|
||||||
@@ -91,12 +89,11 @@ class UserConfigManager:
|
|||||||
"""Get the default configuration."""
|
"""Get the default configuration."""
|
||||||
return {
|
return {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"image": "goose",
|
"driver": "goose",
|
||||||
"connect": True,
|
"connect": True,
|
||||||
"mount_local": True,
|
"mount_local": True,
|
||||||
"networks": [], # Default networks to connect to (besides cubbi-network)
|
"networks": [], # Default networks to connect to (besides mc-network)
|
||||||
"volumes": [], # Default volumes to mount, format: "source:dest"
|
"volumes": [], # Default volumes to mount, format: "source:dest"
|
||||||
"ports": [], # Default ports to forward, format: list of integers
|
|
||||||
"mcps": [], # Default MCP servers to connect to
|
"mcps": [], # Default MCP servers to connect to
|
||||||
"model": "claude-3-5-sonnet-latest", # Default LLM model to use
|
"model": "claude-3-5-sonnet-latest", # Default LLM model to use
|
||||||
"provider": "anthropic", # Default LLM provider to use
|
"provider": "anthropic", # Default LLM provider to use
|
||||||
@@ -109,7 +106,7 @@ class UserConfigManager:
|
|||||||
"google": {},
|
"google": {},
|
||||||
},
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"network": "cubbi-network",
|
"network": "mc-network",
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"colors": True,
|
"colors": True,
|
||||||
@@ -136,7 +133,7 @@ class UserConfigManager:
|
|||||||
"""Get a configuration value by dot-notation path.
|
"""Get a configuration value by dot-notation path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key_path: The configuration path (e.g., "defaults.image")
|
key_path: The configuration path (e.g., "defaults.driver")
|
||||||
default: The default value to return if not found
|
default: The default value to return if not found
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -168,7 +165,7 @@ class UserConfigManager:
|
|||||||
"""Set a configuration value by dot-notation path.
|
"""Set a configuration value by dot-notation path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key_path: The configuration path (e.g., "defaults.image")
|
key_path: The configuration path (e.g., "defaults.driver")
|
||||||
value: The value to set
|
value: The value to set
|
||||||
"""
|
"""
|
||||||
# Handle shorthand service paths (e.g., "langfuse.url")
|
# Handle shorthand service paths (e.g., "langfuse.url")
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "cubbi"
|
name = "mcontainer"
|
||||||
version = "0.4.0"
|
version = "0.1.0"
|
||||||
description = "Cubbi Container Tool"
|
description = "Monadical Container Tool"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = "MIT"
|
|
||||||
authors = [
|
|
||||||
{name = "Monadical SAS", email = "contact@monadical.com"}
|
|
||||||
]
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typer>=0.9.0",
|
"typer>=0.9.0",
|
||||||
"docker>=7.0.0",
|
"docker>=7.0.0",
|
||||||
@@ -15,16 +11,6 @@ dependencies = [
|
|||||||
"rich>=13.6.0",
|
"rich>=13.6.0",
|
||||||
"pydantic>=2.5.0",
|
"pydantic>=2.5.0",
|
||||||
]
|
]
|
||||||
classifiers = [
|
|
||||||
"Development Status :: 3 - Alpha",
|
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Intended Audience :: Developers",
|
|
||||||
"Intended Audience :: Science/Research",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
"Topic :: Software Development",
|
|
||||||
"Topic :: Scientific/Engineering :: Artificial Intelligence"
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -38,8 +24,8 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
cubbi = "cubbi.cli:app"
|
mc = "mcontainer.cli:app"
|
||||||
cubbix = "cubbi.cli:session_create_entry_point"
|
mcx = "mcontainer.cli:session_create_entry_point"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
@@ -56,69 +42,3 @@ disallow_incomplete_defs = true
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3.5",
|
"pytest>=8.3.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.semantic_release]
|
|
||||||
assets = []
|
|
||||||
build_command_env = []
|
|
||||||
commit_message = "{version}\n\nAutomatically generated by python-semantic-release"
|
|
||||||
logging_use_named_masks = false
|
|
||||||
major_on_zero = true
|
|
||||||
allow_zero_version = true
|
|
||||||
no_git_verify = false
|
|
||||||
tag_format = "v{version}"
|
|
||||||
version_toml = [
|
|
||||||
"pyproject.toml:project.version:nf"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.semantic_release.branches.main]
|
|
||||||
match = "(main|master)"
|
|
||||||
prerelease_token = "rc"
|
|
||||||
prerelease = false
|
|
||||||
|
|
||||||
[tool.semantic_release.changelog]
|
|
||||||
exclude_commit_patterns = []
|
|
||||||
mode = "init"
|
|
||||||
insertion_flag = "<!-- version list -->"
|
|
||||||
template_dir = "templates"
|
|
||||||
|
|
||||||
[tool.semantic_release.changelog.default_templates]
|
|
||||||
changelog_file = "CHANGELOG.md"
|
|
||||||
output_format = "md"
|
|
||||||
mask_initial_release = false
|
|
||||||
|
|
||||||
[tool.semantic_release.changelog.environment]
|
|
||||||
block_start_string = "{%"
|
|
||||||
block_end_string = "%}"
|
|
||||||
variable_start_string = "{{"
|
|
||||||
variable_end_string = "}}"
|
|
||||||
comment_start_string = "{#"
|
|
||||||
comment_end_string = "#}"
|
|
||||||
trim_blocks = false
|
|
||||||
lstrip_blocks = false
|
|
||||||
newline_sequence = "\n"
|
|
||||||
keep_trailing_newline = false
|
|
||||||
extensions = []
|
|
||||||
autoescape = false
|
|
||||||
|
|
||||||
[tool.semantic_release.commit_author]
|
|
||||||
env = "GIT_COMMIT_AUTHOR"
|
|
||||||
default = "semantic-release <semantic-release>"
|
|
||||||
|
|
||||||
[tool.semantic_release.commit_parser_options]
|
|
||||||
minor_tags = ["feat"]
|
|
||||||
patch_tags = ["fix", "perf"]
|
|
||||||
other_allowed_tags = ["build", "chore", "ci", "docs", "style", "refactor", "test"]
|
|
||||||
allowed_tags = ["feat", "fix", "perf", "build", "chore", "ci", "docs", "style", "refactor", "test"]
|
|
||||||
default_bump_level = 0
|
|
||||||
parse_squash_commits = false
|
|
||||||
ignore_merge_commits = false
|
|
||||||
|
|
||||||
[tool.semantic_release.remote]
|
|
||||||
name = "origin"
|
|
||||||
type = "github"
|
|
||||||
ignore_token_for_push = false
|
|
||||||
insecure = false
|
|
||||||
|
|
||||||
[tool.semantic_release.publish]
|
|
||||||
dist_glob_patterns = ["dist/*"]
|
|
||||||
upload_to_vcs_release = true
|
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Common test fixtures for Cubbi Container tests.
|
Common test fixtures for Monadical Container tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
import docker
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import docker
|
from mcontainer.container import ContainerManager
|
||||||
import pytest
|
from mcontainer.session import SessionManager
|
||||||
|
from mcontainer.config import ConfigManager
|
||||||
from cubbi.config import ConfigManager
|
from mcontainer.models import Session, SessionStatus
|
||||||
from cubbi.container import ContainerManager
|
from mcontainer.user_config import UserConfigManager
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
from cubbi.session import SessionManager
|
|
||||||
from cubbi.user_config import UserConfigManager
|
|
||||||
|
|
||||||
|
|
||||||
# Check if Docker is available
|
# Check if Docker is available
|
||||||
@@ -42,6 +41,13 @@ requires_docker = pytest.mark.skipif(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Create a temporary directory for test files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
yield Path(tmp_dir)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_config_dir():
|
def temp_config_dir():
|
||||||
"""Create a temporary directory for configuration files."""
|
"""Create a temporary directory for configuration files."""
|
||||||
@@ -50,26 +56,76 @@ def temp_config_dir():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_container_manager(isolate_cubbi_config):
|
def isolated_config(temp_config_dir):
|
||||||
"""Mock the ContainerManager class with proper behaviors for testing."""
|
"""Provide an isolated UserConfigManager instance."""
|
||||||
|
config_path = temp_config_dir / "config.yaml"
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return UserConfigManager(str(config_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_session_manager(temp_config_dir):
|
||||||
|
"""Create an isolated session manager for testing."""
|
||||||
|
sessions_path = temp_config_dir / "sessions.yaml"
|
||||||
|
return SessionManager(sessions_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_config_manager():
|
||||||
|
"""Create an isolated config manager for testing."""
|
||||||
|
config_manager = ConfigManager()
|
||||||
|
# Ensure we're using the built-in drivers, not trying to load from user config
|
||||||
|
return config_manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session_manager():
|
||||||
|
"""Mock the SessionManager class."""
|
||||||
|
with patch("mcontainer.cli.session_manager") as mock_manager:
|
||||||
|
yield mock_manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_container_manager():
|
||||||
|
"""Mock the ContainerManager class with proper initialization."""
|
||||||
mock_session = Session(
|
mock_session = Session(
|
||||||
id="test-session-id",
|
id="test-session-id",
|
||||||
name="test-session",
|
name="test-session",
|
||||||
image="goose",
|
driver="goose",
|
||||||
status=SessionStatus.RUNNING,
|
status=SessionStatus.RUNNING,
|
||||||
ports={8080: 32768},
|
ports={"8080": "8080"},
|
||||||
)
|
)
|
||||||
|
|
||||||
container_manager = isolate_cubbi_config["container_manager"]
|
with patch("mcontainer.cli.container_manager") as mock_manager:
|
||||||
|
# Set behaviors to avoid TypeErrors
|
||||||
|
mock_manager.list_sessions.return_value = []
|
||||||
|
mock_manager.create_session.return_value = mock_session
|
||||||
|
mock_manager.close_session.return_value = True
|
||||||
|
mock_manager.close_all_sessions.return_value = (3, True)
|
||||||
|
# MCP-related mocks
|
||||||
|
mock_manager.get_mcp_status.return_value = {
|
||||||
|
"status": "running",
|
||||||
|
"container_id": "test-id",
|
||||||
|
}
|
||||||
|
mock_manager.start_mcp.return_value = {
|
||||||
|
"status": "running",
|
||||||
|
"container_id": "test-id",
|
||||||
|
}
|
||||||
|
mock_manager.stop_mcp.return_value = True
|
||||||
|
mock_manager.restart_mcp.return_value = {
|
||||||
|
"status": "running",
|
||||||
|
"container_id": "test-id",
|
||||||
|
}
|
||||||
|
mock_manager.get_mcp_logs.return_value = "Test log output"
|
||||||
|
yield mock_manager
|
||||||
|
|
||||||
# Patch the container manager methods for mocking
|
|
||||||
with (
|
@pytest.fixture
|
||||||
patch.object(container_manager, "list_sessions", return_value=[]),
|
def container_manager(isolated_session_manager, isolated_config_manager):
|
||||||
patch.object(container_manager, "create_session", return_value=mock_session),
|
"""Create a container manager with isolated components."""
|
||||||
patch.object(container_manager, "close_session", return_value=True),
|
return ContainerManager(
|
||||||
patch.object(container_manager, "close_all_sessions", return_value=(3, True)),
|
config_manager=isolated_config_manager, session_manager=isolated_session_manager
|
||||||
):
|
)
|
||||||
yield container_manager
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -81,23 +137,28 @@ def cli_runner():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_file_content(temp_config_dir):
|
def test_file_content(temp_dir):
|
||||||
"""Create a test file with content in a temporary directory."""
|
"""Create a test file with content in the temporary directory."""
|
||||||
test_content = "This is a test file for volume mounting"
|
test_content = "This is a test file for volume mounting"
|
||||||
test_file = temp_config_dir / "test_volume_file.txt"
|
test_file = temp_dir / "test_volume_file.txt"
|
||||||
with open(test_file, "w") as f:
|
with open(test_file, "w") as f:
|
||||||
f.write(test_content)
|
f.write(test_content)
|
||||||
return test_file, test_content
|
return test_file, test_content
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def docker_test_network():
|
def test_network_name():
|
||||||
|
"""Generate a unique network name for testing."""
|
||||||
|
return f"mc-test-network-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def docker_test_network(test_network_name):
|
||||||
"""Create a Docker network for testing and clean it up after."""
|
"""Create a Docker network for testing and clean it up after."""
|
||||||
if not is_docker_available():
|
if not is_docker_available():
|
||||||
pytest.skip("Docker is not available")
|
pytest.skip("Docker is not available")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
test_network_name = f"cubbi-test-network-{uuid.uuid4().hex[:8]}"
|
|
||||||
client = docker.from_env()
|
client = docker.from_env()
|
||||||
network = client.networks.create(test_network_name, driver="bridge")
|
network = client.networks.create(test_network_name, driver="bridge")
|
||||||
|
|
||||||
@@ -111,59 +172,8 @@ def docker_test_network():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="function")
|
|
||||||
def isolate_cubbi_config(temp_config_dir):
|
|
||||||
"""
|
|
||||||
Automatically isolate all Cubbi configuration for every test.
|
|
||||||
|
|
||||||
This fixture ensures that tests never touch the user's real configuration
|
|
||||||
by patching both ConfigManager and UserConfigManager in cli.py to use
|
|
||||||
temporary directories.
|
|
||||||
"""
|
|
||||||
# Create isolated config instances with temporary paths
|
|
||||||
config_path = temp_config_dir / "config.yaml"
|
|
||||||
user_config_path = temp_config_dir / "user_config.yaml"
|
|
||||||
|
|
||||||
# Create the ConfigManager with a custom config path
|
|
||||||
isolated_config_manager = ConfigManager(config_path)
|
|
||||||
|
|
||||||
# Create the UserConfigManager with a custom config path
|
|
||||||
isolated_user_config = UserConfigManager(str(user_config_path))
|
|
||||||
|
|
||||||
# Create isolated session manager
|
|
||||||
sessions_path = temp_config_dir / "sessions.yaml"
|
|
||||||
isolated_session_manager = SessionManager(sessions_path)
|
|
||||||
|
|
||||||
# Create isolated container manager
|
|
||||||
isolated_container_manager = ContainerManager(
|
|
||||||
isolated_config_manager, isolated_session_manager, isolated_user_config
|
|
||||||
)
|
|
||||||
|
|
||||||
# Patch all the global instances in cli.py and the UserConfigManager class
|
|
||||||
with (
|
|
||||||
patch("cubbi.cli.config_manager", isolated_config_manager),
|
|
||||||
patch("cubbi.cli.user_config", isolated_user_config),
|
|
||||||
patch("cubbi.cli.session_manager", isolated_session_manager),
|
|
||||||
patch("cubbi.cli.container_manager", isolated_container_manager),
|
|
||||||
patch("cubbi.cli.UserConfigManager", return_value=isolated_user_config),
|
|
||||||
):
|
|
||||||
# Create isolated MCP manager with isolated user config
|
|
||||||
from cubbi.mcp import MCPManager
|
|
||||||
|
|
||||||
isolated_mcp_manager = MCPManager(config_manager=isolated_user_config)
|
|
||||||
|
|
||||||
# Patch the global mcp_manager instance
|
|
||||||
with patch("cubbi.cli.mcp_manager", isolated_mcp_manager):
|
|
||||||
yield {
|
|
||||||
"config_manager": isolated_config_manager,
|
|
||||||
"user_config": isolated_user_config,
|
|
||||||
"session_manager": isolated_session_manager,
|
|
||||||
"container_manager": isolated_container_manager,
|
|
||||||
"mcp_manager": isolated_mcp_manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def patched_config_manager(isolate_cubbi_config):
|
def patched_config_manager(isolated_config):
|
||||||
"""Compatibility fixture - returns the isolated user config."""
|
"""Patch the UserConfigManager in cli.py to use our isolated instance."""
|
||||||
return isolate_cubbi_config["user_config"]
|
with patch("mcontainer.cli.user_config", isolated_config):
|
||||||
|
yield isolated_config
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from cubbi.cli import app
|
from mcontainer.cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ def test_version() -> None:
|
|||||||
"""Test version command"""
|
"""Test version command"""
|
||||||
result = runner.invoke(app, ["version"])
|
result = runner.invoke(app, ["version"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Cubbi - Cubbi Container Tool" in result.stdout
|
assert "MC - Monadical Container Tool" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_session_list() -> None:
|
def test_session_list() -> None:
|
||||||
@@ -25,4 +25,4 @@ def test_help() -> None:
|
|||||||
result = runner.invoke(app, ["--help"])
|
result = runner.invoke(app, ["--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Usage" in result.stdout
|
assert "Usage" in result.stdout
|
||||||
assert "Cubbi Container Tool" in result.stdout
|
assert "Monadical Container Tool" in result.stdout
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
Tests for the configuration management commands.
|
Tests for the configuration management commands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from cubbi.cli import app
|
from mcontainer.cli import app
|
||||||
|
|
||||||
|
|
||||||
def test_config_list(cli_runner, patched_config_manager):
|
def test_config_list(cli_runner, patched_config_manager):
|
||||||
"""Test the 'cubbi config list' command."""
|
"""Test the 'mc config list' command."""
|
||||||
result = cli_runner.invoke(app, ["config", "list"])
|
result = cli_runner.invoke(app, ["config", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -14,18 +14,18 @@ def test_config_list(cli_runner, patched_config_manager):
|
|||||||
assert "Value" in result.stdout
|
assert "Value" in result.stdout
|
||||||
|
|
||||||
# Check for default configurations
|
# Check for default configurations
|
||||||
assert "defaults.image" in result.stdout
|
assert "defaults.driver" in result.stdout
|
||||||
assert "defaults.connect" in result.stdout
|
assert "defaults.connect" in result.stdout
|
||||||
assert "defaults.mount_local" in result.stdout
|
assert "defaults.mount_local" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_config_get(cli_runner, patched_config_manager):
|
def test_config_get(cli_runner, patched_config_manager):
|
||||||
"""Test the 'cubbi config get' command."""
|
"""Test the 'mc config get' command."""
|
||||||
# Test getting an existing value
|
# Test getting an existing value
|
||||||
result = cli_runner.invoke(app, ["config", "get", "defaults.image"])
|
result = cli_runner.invoke(app, ["config", "get", "defaults.driver"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "defaults.image" in result.stdout
|
assert "defaults.driver" in result.stdout
|
||||||
assert "goose" in result.stdout
|
assert "goose" in result.stdout
|
||||||
|
|
||||||
# Test getting a non-existent value
|
# Test getting a non-existent value
|
||||||
@@ -36,13 +36,13 @@ def test_config_get(cli_runner, patched_config_manager):
|
|||||||
|
|
||||||
|
|
||||||
def test_config_set(cli_runner, patched_config_manager):
|
def test_config_set(cli_runner, patched_config_manager):
|
||||||
"""Test the 'cubbi config set' command."""
|
"""Test the 'mc config set' command."""
|
||||||
# Test setting a string value
|
# Test setting a string value
|
||||||
result = cli_runner.invoke(app, ["config", "set", "defaults.image", "claude"])
|
result = cli_runner.invoke(app, ["config", "set", "defaults.driver", "claude"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Configuration updated" in result.stdout
|
assert "Configuration updated" in result.stdout
|
||||||
assert patched_config_manager.get("defaults.image") == "claude"
|
assert patched_config_manager.get("defaults.driver") == "claude"
|
||||||
|
|
||||||
# Test setting a boolean value
|
# Test setting a boolean value
|
||||||
result = cli_runner.invoke(app, ["config", "set", "defaults.connect", "false"])
|
result = cli_runner.invoke(app, ["config", "set", "defaults.connect", "false"])
|
||||||
@@ -60,7 +60,7 @@ def test_config_set(cli_runner, patched_config_manager):
|
|||||||
|
|
||||||
|
|
||||||
def test_volume_list_empty(cli_runner, patched_config_manager):
|
def test_volume_list_empty(cli_runner, patched_config_manager):
|
||||||
"""Test the 'cubbi config volume list' command with no volumes."""
|
"""Test the 'mc config volume list' command with no volumes."""
|
||||||
result = cli_runner.invoke(app, ["config", "volume", "list"])
|
result = cli_runner.invoke(app, ["config", "volume", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -81,13 +81,11 @@ def test_volume_add_and_list(cli_runner, patched_config_manager, temp_config_dir
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Added volume" in result.stdout
|
assert "Added volume" in result.stdout
|
||||||
|
|
||||||
# Verify volume was added to the configuration
|
# List volumes
|
||||||
volumes = patched_config_manager.get("defaults.volumes", [])
|
|
||||||
assert f"{test_dir}:/container/path" in volumes
|
|
||||||
|
|
||||||
# List volumes - just check the command runs without error
|
|
||||||
result = cli_runner.invoke(app, ["config", "volume", "list"])
|
result = cli_runner.invoke(app, ["config", "volume", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
assert str(test_dir) in result.stdout
|
||||||
assert "/container/path" in result.stdout
|
assert "/container/path" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
@@ -133,7 +131,7 @@ def test_volume_add_nonexistent_path(cli_runner, patched_config_manager, monkeyp
|
|||||||
|
|
||||||
|
|
||||||
def test_network_list_empty(cli_runner, patched_config_manager):
|
def test_network_list_empty(cli_runner, patched_config_manager):
|
||||||
"""Test the 'cubbi config network list' command with no networks."""
|
"""Test the 'mc config network list' command with no networks."""
|
||||||
result = cli_runner.invoke(app, ["config", "network", "list"])
|
result = cli_runner.invoke(app, ["config", "network", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -174,7 +172,7 @@ def test_network_remove(cli_runner, patched_config_manager):
|
|||||||
def test_config_reset(cli_runner, patched_config_manager, monkeypatch):
|
def test_config_reset(cli_runner, patched_config_manager, monkeypatch):
|
||||||
"""Test resetting the configuration."""
|
"""Test resetting the configuration."""
|
||||||
# Set a custom value first
|
# Set a custom value first
|
||||||
patched_config_manager.set("defaults.image", "custom-image")
|
patched_config_manager.set("defaults.driver", "custom-driver")
|
||||||
|
|
||||||
# Mock typer.confirm to return True
|
# Mock typer.confirm to return True
|
||||||
monkeypatch.setattr("typer.confirm", lambda message: True)
|
monkeypatch.setattr("typer.confirm", lambda message: True)
|
||||||
@@ -186,106 +184,7 @@ def test_config_reset(cli_runner, patched_config_manager, monkeypatch):
|
|||||||
assert "Configuration reset to defaults" in result.stdout
|
assert "Configuration reset to defaults" in result.stdout
|
||||||
|
|
||||||
# Verify it was reset
|
# Verify it was reset
|
||||||
assert patched_config_manager.get("defaults.image") == "goose"
|
assert patched_config_manager.get("defaults.driver") == "goose"
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_empty(cli_runner, patched_config_manager):
|
|
||||||
"""Test listing ports when none are configured."""
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No default ports configured" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_add_single(cli_runner, patched_config_manager):
|
|
||||||
"""Test adding a single port."""
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "8000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Added port 8000 to defaults" in result.stdout
|
|
||||||
|
|
||||||
# Verify it was added
|
|
||||||
ports = patched_config_manager.get("defaults.ports")
|
|
||||||
assert 8000 in ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_add_multiple(cli_runner, patched_config_manager):
|
|
||||||
"""Test adding multiple ports with comma separation."""
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "8000,3000,5173"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Added ports [8000, 3000, 5173] to defaults" in result.stdout
|
|
||||||
|
|
||||||
# Verify they were added
|
|
||||||
ports = patched_config_manager.get("defaults.ports")
|
|
||||||
assert 8000 in ports
|
|
||||||
assert 3000 in ports
|
|
||||||
assert 5173 in ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_add_duplicate(cli_runner, patched_config_manager):
|
|
||||||
"""Test adding a port that already exists."""
|
|
||||||
# Add a port first
|
|
||||||
patched_config_manager.set("defaults.ports", [8000])
|
|
||||||
|
|
||||||
# Try to add the same port again
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "8000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Port 8000 is already in defaults" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_add_invalid_format(cli_runner, patched_config_manager):
|
|
||||||
"""Test adding an invalid port format."""
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "invalid"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Error: Invalid port format" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_add_invalid_range(cli_runner, patched_config_manager):
|
|
||||||
"""Test adding a port outside valid range."""
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "70000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Error: Invalid ports [70000]" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_with_ports(cli_runner, patched_config_manager):
|
|
||||||
"""Test listing ports when some are configured."""
|
|
||||||
# Add some ports
|
|
||||||
patched_config_manager.set("defaults.ports", [8000, 3000])
|
|
||||||
|
|
||||||
# List ports
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "8000" in result.stdout
|
|
||||||
assert "3000" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_remove(cli_runner, patched_config_manager):
|
|
||||||
"""Test removing a port."""
|
|
||||||
# Add a port first
|
|
||||||
patched_config_manager.set("defaults.ports", [8000])
|
|
||||||
|
|
||||||
# Remove the port
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "remove", "8000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Removed port 8000 from defaults" in result.stdout
|
|
||||||
|
|
||||||
# Verify it's gone
|
|
||||||
ports = patched_config_manager.get("defaults.ports")
|
|
||||||
assert 8000 not in ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_remove_not_found(cli_runner, patched_config_manager):
|
|
||||||
"""Test removing a port that doesn't exist."""
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "remove", "8000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Port 8000 is not in defaults" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
# patched_config_manager fixture is now in conftest.py
|
# patched_config_manager fixture is now in conftest.py
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
"""
|
|
||||||
Test that configuration isolation works correctly and doesn't touch user's real config.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from cubbi.cli import app
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_isolation_preserves_user_config(cli_runner, isolate_cubbi_config):
|
|
||||||
"""Test that test isolation doesn't affect user's real configuration."""
|
|
||||||
|
|
||||||
# Get the user's real config path
|
|
||||||
real_config_path = Path.home() / ".config" / "cubbi" / "config.yaml"
|
|
||||||
|
|
||||||
# If the user has a real config, store its content before test
|
|
||||||
original_content = None
|
|
||||||
if real_config_path.exists():
|
|
||||||
with open(real_config_path, "r") as f:
|
|
||||||
original_content = f.read()
|
|
||||||
|
|
||||||
# Run some config modification commands in the test
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "9999"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["config", "set", "defaults.image", "test-image"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
# Verify the user's real config is unchanged
|
|
||||||
if original_content is not None:
|
|
||||||
with open(real_config_path, "r") as f:
|
|
||||||
current_content = f.read()
|
|
||||||
assert current_content == original_content
|
|
||||||
else:
|
|
||||||
# If no real config existed, it should still not exist
|
|
||||||
assert not real_config_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_isolated_config_works_independently(cli_runner, isolate_cubbi_config):
|
|
||||||
"""Test that the isolated config works correctly for tests."""
|
|
||||||
|
|
||||||
# Add a port to isolated config
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "8888"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Added port 8888 to defaults" in result.stdout
|
|
||||||
|
|
||||||
# Verify it appears in the list
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "8888" in result.stdout
|
|
||||||
|
|
||||||
# Remove the port
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "remove", "8888"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Removed port 8888 from defaults" in result.stdout
|
|
||||||
|
|
||||||
# Verify it's gone
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No default ports configured" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_each_test_gets_fresh_config(cli_runner, isolate_cubbi_config):
|
|
||||||
"""Test that each test gets a fresh, isolated configuration."""
|
|
||||||
|
|
||||||
# This test should start with empty ports (fresh config)
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No default ports configured" in result.stdout
|
|
||||||
|
|
||||||
# Add a port
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "add", "7777"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
# Verify it's there
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "7777" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_another_fresh_config_test(cli_runner, isolate_cubbi_config):
|
|
||||||
"""Another test to verify each test gets a completely fresh config."""
|
|
||||||
|
|
||||||
# This test should also start with empty ports (independent of previous test)
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No default ports configured" in result.stdout
|
|
||||||
|
|
||||||
# The port from the previous test should not be here
|
|
||||||
result = cli_runner.invoke(app, ["config", "port", "list"])
|
|
||||||
assert "7777" not in result.stdout
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for Docker interactions in Cubbi Container.
|
Integration tests for Docker interactions in Monadical Container.
|
||||||
These tests require Docker to be running.
|
These tests require Docker to be running.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import docker
|
|
||||||
|
|
||||||
|
|
||||||
# Import the requires_docker decorator from conftest
|
# Import the requires_docker decorator from conftest
|
||||||
from conftest import requires_docker
|
from conftest import requires_docker
|
||||||
@@ -23,60 +21,17 @@ def execute_command_in_container(container_id, command):
|
|||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def wait_for_container_init(container_id, timeout=5.0, poll_interval=0.1):
|
|
||||||
"""
|
|
||||||
Wait for a Cubbi container to complete initialization by polling /cubbi/init.status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_id: Docker container ID
|
|
||||||
timeout: Maximum time to wait in seconds (default: 5.0)
|
|
||||||
poll_interval: Time between polls in seconds (default: 0.1)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if initialization completed, False if timed out
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
subprocess.CalledProcessError: If docker exec command fails
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
|
||||||
try:
|
|
||||||
# Check if /cubbi/init.status contains INIT_COMPLETE=true
|
|
||||||
result = execute_command_in_container(
|
|
||||||
container_id,
|
|
||||||
"grep -q 'INIT_COMPLETE=true' /cubbi/init.status 2>/dev/null && echo 'COMPLETE' || echo 'PENDING'",
|
|
||||||
)
|
|
||||||
|
|
||||||
if result == "COMPLETE":
|
|
||||||
return True
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
# File might not exist yet or container not ready, continue polling
|
|
||||||
pass
|
|
||||||
|
|
||||||
time.sleep(poll_interval)
|
|
||||||
|
|
||||||
# Timeout reached
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@requires_docker
|
@requires_docker
|
||||||
def test_integration_session_create_with_volumes(
|
def test_integration_session_create_with_volumes(container_manager, test_file_content):
|
||||||
isolate_cubbi_config, test_file_content
|
|
||||||
):
|
|
||||||
"""Test creating a session with a volume mount."""
|
"""Test creating a session with a volume mount."""
|
||||||
test_file, test_content = test_file_content
|
test_file, test_content = test_file_content
|
||||||
session = None
|
session = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the isolated container manager
|
|
||||||
container_manager = isolate_cubbi_config["container_manager"]
|
|
||||||
|
|
||||||
# Create a session with a volume mount
|
# Create a session with a volume mount
|
||||||
session = container_manager.create_session(
|
session = container_manager.create_session(
|
||||||
image_name="goose",
|
driver_name="goose",
|
||||||
session_name=f"cubbi-test-volume-{uuid.uuid4().hex[:8]}",
|
session_name=f"mc-test-volume-{uuid.uuid4().hex[:8]}",
|
||||||
mount_local=False, # Don't mount current directory
|
mount_local=False, # Don't mount current directory
|
||||||
volumes={str(test_file): {"bind": "/test/volume_test.txt", "mode": "ro"}},
|
volumes={str(test_file): {"bind": "/test/volume_test.txt", "mode": "ro"}},
|
||||||
)
|
)
|
||||||
@@ -84,9 +39,8 @@ def test_integration_session_create_with_volumes(
|
|||||||
assert session is not None
|
assert session is not None
|
||||||
assert session.status == "running"
|
assert session.status == "running"
|
||||||
|
|
||||||
# Wait for container initialization to complete
|
# Give container time to fully start
|
||||||
init_success = wait_for_container_init(session.container_id)
|
time.sleep(2)
|
||||||
assert init_success, "Container initialization timed out"
|
|
||||||
|
|
||||||
# Verify the file exists in the container and has correct content
|
# Verify the file exists in the container and has correct content
|
||||||
container_content = execute_command_in_container(
|
container_content = execute_command_in_container(
|
||||||
@@ -96,26 +50,23 @@ def test_integration_session_create_with_volumes(
|
|||||||
assert container_content == test_content
|
assert container_content == test_content
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up the container (use kill for faster test cleanup)
|
# Clean up the container
|
||||||
if session and session.container_id:
|
if session and session.container_id:
|
||||||
container_manager.close_session(session.id, kill=True)
|
container_manager.close_session(session.id)
|
||||||
|
|
||||||
|
|
||||||
@requires_docker
|
@requires_docker
|
||||||
def test_integration_session_create_with_networks(
|
def test_integration_session_create_with_networks(
|
||||||
isolate_cubbi_config, docker_test_network
|
container_manager, docker_test_network
|
||||||
):
|
):
|
||||||
"""Test creating a session connected to a custom network."""
|
"""Test creating a session connected to a custom network."""
|
||||||
session = None
|
session = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the isolated container manager
|
|
||||||
container_manager = isolate_cubbi_config["container_manager"]
|
|
||||||
|
|
||||||
# Create a session with the test network
|
# Create a session with the test network
|
||||||
session = container_manager.create_session(
|
session = container_manager.create_session(
|
||||||
image_name="goose",
|
driver_name="goose",
|
||||||
session_name=f"cubbi-test-network-{uuid.uuid4().hex[:8]}",
|
session_name=f"mc-test-network-{uuid.uuid4().hex[:8]}",
|
||||||
mount_local=False, # Don't mount current directory
|
mount_local=False, # Don't mount current directory
|
||||||
networks=[docker_test_network],
|
networks=[docker_test_network],
|
||||||
)
|
)
|
||||||
@@ -123,9 +74,8 @@ def test_integration_session_create_with_networks(
|
|||||||
assert session is not None
|
assert session is not None
|
||||||
assert session.status == "running"
|
assert session.status == "running"
|
||||||
|
|
||||||
# Wait for container initialization to complete
|
# Give container time to fully start
|
||||||
init_success = wait_for_container_init(session.container_id)
|
time.sleep(2)
|
||||||
assert init_success, "Container initialization timed out"
|
|
||||||
|
|
||||||
# Verify the container is connected to the test network
|
# Verify the container is connected to the test network
|
||||||
# Use inspect to check network connections
|
# Use inspect to check network connections
|
||||||
@@ -135,7 +85,7 @@ def test_integration_session_create_with_networks(
|
|||||||
container = client.containers.get(session.container_id)
|
container = client.containers.get(session.container_id)
|
||||||
container_networks = container.attrs["NetworkSettings"]["Networks"]
|
container_networks = container.attrs["NetworkSettings"]["Networks"]
|
||||||
|
|
||||||
# Container should be connected to both the default cubbi-network and our test network
|
# Container should be connected to both the default mc-network and our test network
|
||||||
assert docker_test_network in container_networks
|
assert docker_test_network in container_networks
|
||||||
|
|
||||||
# Verify network interface exists in container
|
# Verify network interface exists in container
|
||||||
@@ -143,244 +93,10 @@ def test_integration_session_create_with_networks(
|
|||||||
session.container_id, "ip link show | grep -v 'lo' | wc -l"
|
session.container_id, "ip link show | grep -v 'lo' | wc -l"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should have at least 2 interfaces (eth0 for cubbi-network, eth1 for test network)
|
# Should have at least 2 interfaces (eth0 for mc-network, eth1 for test network)
|
||||||
assert int(network_interfaces) >= 2
|
assert int(network_interfaces) >= 2
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up the container (use kill for faster test cleanup)
|
# Clean up the container
|
||||||
if session and session.container_id:
|
if session and session.container_id:
|
||||||
container_manager.close_session(session.id, kill=True)
|
container_manager.close_session(session.id)
|
||||||
|
|
||||||
|
|
||||||
@requires_docker
|
|
||||||
def test_integration_session_create_with_ports(isolate_cubbi_config):
|
|
||||||
"""Test creating a session with port forwarding."""
|
|
||||||
session = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get the isolated container manager
|
|
||||||
container_manager = isolate_cubbi_config["container_manager"]
|
|
||||||
|
|
||||||
# Create a session with port forwarding
|
|
||||||
session = container_manager.create_session(
|
|
||||||
image_name="goose",
|
|
||||||
session_name=f"cubbi-test-ports-{uuid.uuid4().hex[:8]}",
|
|
||||||
mount_local=False, # Don't mount current directory
|
|
||||||
ports=[8080, 9000], # Forward these ports
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session is not None
|
|
||||||
assert session.status == "running"
|
|
||||||
|
|
||||||
# Verify ports are mapped
|
|
||||||
assert isinstance(session.ports, dict)
|
|
||||||
assert 8080 in session.ports
|
|
||||||
assert 9000 in session.ports
|
|
||||||
|
|
||||||
# Verify port mappings are valid (host ports should be assigned)
|
|
||||||
assert isinstance(session.ports[8080], int)
|
|
||||||
assert isinstance(session.ports[9000], int)
|
|
||||||
assert session.ports[8080] > 0
|
|
||||||
assert session.ports[9000] > 0
|
|
||||||
|
|
||||||
# Wait for container initialization to complete
|
|
||||||
init_success = wait_for_container_init(session.container_id)
|
|
||||||
assert init_success, "Container initialization timed out"
|
|
||||||
|
|
||||||
# Verify Docker port mappings using Docker client
|
|
||||||
import docker
|
|
||||||
|
|
||||||
client = docker.from_env()
|
|
||||||
container = client.containers.get(session.container_id)
|
|
||||||
container_ports = container.attrs["NetworkSettings"]["Ports"]
|
|
||||||
|
|
||||||
# Verify both ports are exposed
|
|
||||||
assert "8080/tcp" in container_ports
|
|
||||||
assert "9000/tcp" in container_ports
|
|
||||||
|
|
||||||
# Verify host port bindings exist
|
|
||||||
assert container_ports["8080/tcp"] is not None
|
|
||||||
assert container_ports["9000/tcp"] is not None
|
|
||||||
assert len(container_ports["8080/tcp"]) > 0
|
|
||||||
assert len(container_ports["9000/tcp"]) > 0
|
|
||||||
|
|
||||||
# Verify host ports match session.ports
|
|
||||||
host_port_8080 = int(container_ports["8080/tcp"][0]["HostPort"])
|
|
||||||
host_port_9000 = int(container_ports["9000/tcp"][0]["HostPort"])
|
|
||||||
assert session.ports[8080] == host_port_8080
|
|
||||||
assert session.ports[9000] == host_port_9000
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up the container (use kill for faster test cleanup)
|
|
||||||
if session and session.container_id:
|
|
||||||
container_manager.close_session(session.id, kill=True)
|
|
||||||
|
|
||||||
|
|
||||||
@requires_docker
|
|
||||||
def test_integration_session_create_no_ports(isolate_cubbi_config):
|
|
||||||
"""Test creating a session without port forwarding."""
|
|
||||||
session = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get the isolated container manager
|
|
||||||
container_manager = isolate_cubbi_config["container_manager"]
|
|
||||||
|
|
||||||
# Create a session without ports
|
|
||||||
session = container_manager.create_session(
|
|
||||||
image_name="goose",
|
|
||||||
session_name=f"cubbi-test-no-ports-{uuid.uuid4().hex[:8]}",
|
|
||||||
mount_local=False, # Don't mount current directory
|
|
||||||
ports=[], # No ports
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session is not None
|
|
||||||
assert session.status == "running"
|
|
||||||
|
|
||||||
# Verify no ports are mapped
|
|
||||||
assert isinstance(session.ports, dict)
|
|
||||||
assert len(session.ports) == 0
|
|
||||||
|
|
||||||
# Wait for container initialization to complete
|
|
||||||
init_success = wait_for_container_init(session.container_id)
|
|
||||||
assert init_success, "Container initialization timed out"
|
|
||||||
|
|
||||||
# Verify Docker has no port mappings
|
|
||||||
import docker
|
|
||||||
|
|
||||||
client = docker.from_env()
|
|
||||||
container = client.containers.get(session.container_id)
|
|
||||||
container_ports = container.attrs["NetworkSettings"]["Ports"]
|
|
||||||
|
|
||||||
# Should have no port mappings (empty dict or None values)
|
|
||||||
for port_spec, bindings in container_ports.items():
|
|
||||||
assert bindings is None or len(bindings) == 0
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up the container (use kill for faster test cleanup)
|
|
||||||
if session and session.container_id:
|
|
||||||
container_manager.close_session(session.id, kill=True)
|
|
||||||
|
|
||||||
|
|
||||||
@requires_docker
|
|
||||||
def test_integration_session_create_with_single_port(isolate_cubbi_config):
|
|
||||||
"""Test creating a session with a single port forward."""
|
|
||||||
session = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get the isolated container manager
|
|
||||||
container_manager = isolate_cubbi_config["container_manager"]
|
|
||||||
|
|
||||||
# Create a session with single port
|
|
||||||
session = container_manager.create_session(
|
|
||||||
image_name="goose",
|
|
||||||
session_name=f"cubbi-test-single-port-{uuid.uuid4().hex[:8]}",
|
|
||||||
mount_local=False, # Don't mount current directory
|
|
||||||
ports=[3000], # Single port
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session is not None
|
|
||||||
assert session.status == "running"
|
|
||||||
|
|
||||||
# Verify single port is mapped
|
|
||||||
assert isinstance(session.ports, dict)
|
|
||||||
assert len(session.ports) == 1
|
|
||||||
assert 3000 in session.ports
|
|
||||||
assert isinstance(session.ports[3000], int)
|
|
||||||
assert session.ports[3000] > 0
|
|
||||||
|
|
||||||
# Wait for container initialization to complete
|
|
||||||
init_success = wait_for_container_init(session.container_id)
|
|
||||||
assert init_success, "Container initialization timed out"
|
|
||||||
|
|
||||||
client = docker.from_env()
|
|
||||||
container = client.containers.get(session.container_id)
|
|
||||||
container_ports = container.attrs["NetworkSettings"]["Ports"]
|
|
||||||
|
|
||||||
# Should have exactly one port mapping
|
|
||||||
port_mappings = {
|
|
||||||
k: v for k, v in container_ports.items() if v is not None and len(v) > 0
|
|
||||||
}
|
|
||||||
assert len(port_mappings) == 1
|
|
||||||
assert "3000/tcp" in port_mappings
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up the container (use kill for faster test cleanup)
|
|
||||||
if session and session.container_id:
|
|
||||||
container_manager.close_session(session.id, kill=True)
|
|
||||||
|
|
||||||
|
|
||||||
@requires_docker
|
|
||||||
def test_integration_kill_vs_stop_speed(isolate_cubbi_config):
|
|
||||||
"""Test that kill is faster than stop for container termination."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Get the isolated container manager
|
|
||||||
container_manager = isolate_cubbi_config["container_manager"]
|
|
||||||
|
|
||||||
# Create two identical sessions for comparison
|
|
||||||
session_stop = None
|
|
||||||
session_kill = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create first session (will be stopped gracefully)
|
|
||||||
session_stop = container_manager.create_session(
|
|
||||||
image_name="goose",
|
|
||||||
session_name=f"cubbi-test-stop-{uuid.uuid4().hex[:8]}",
|
|
||||||
mount_local=False,
|
|
||||||
ports=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create second session (will be killed)
|
|
||||||
session_kill = container_manager.create_session(
|
|
||||||
image_name="goose",
|
|
||||||
session_name=f"cubbi-test-kill-{uuid.uuid4().hex[:8]}",
|
|
||||||
mount_local=False,
|
|
||||||
ports=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session_stop is not None
|
|
||||||
assert session_kill is not None
|
|
||||||
|
|
||||||
# Wait for both containers to initialize
|
|
||||||
init_success_stop = wait_for_container_init(session_stop.container_id)
|
|
||||||
init_success_kill = wait_for_container_init(session_kill.container_id)
|
|
||||||
assert init_success_stop, "Stop test container initialization timed out"
|
|
||||||
assert init_success_kill, "Kill test container initialization timed out"
|
|
||||||
|
|
||||||
# Time graceful stop
|
|
||||||
start_time = time.time()
|
|
||||||
container_manager.close_session(session_stop.id, kill=False)
|
|
||||||
stop_time = time.time() - start_time
|
|
||||||
session_stop = None # Mark as cleaned up
|
|
||||||
|
|
||||||
# Time kill
|
|
||||||
start_time = time.time()
|
|
||||||
container_manager.close_session(session_kill.id, kill=True)
|
|
||||||
kill_time = time.time() - start_time
|
|
||||||
session_kill = None # Mark as cleaned up
|
|
||||||
|
|
||||||
# Kill should be faster than stop (usually by several seconds)
|
|
||||||
# We use a generous threshold since system performance can vary
|
|
||||||
assert (
|
|
||||||
kill_time < stop_time
|
|
||||||
), f"Kill ({kill_time:.2f}s) should be faster than stop ({stop_time:.2f}s)"
|
|
||||||
|
|
||||||
# Verify both methods successfully closed the containers
|
|
||||||
# (containers should no longer be in the session list)
|
|
||||||
remaining_sessions = container_manager.list_sessions()
|
|
||||||
session_ids = [s.id for s in remaining_sessions]
|
|
||||||
assert session_stop.id if session_stop else "stop-session" not in session_ids
|
|
||||||
assert session_kill.id if session_kill else "kill-session" not in session_ids
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up any remaining containers
|
|
||||||
if session_stop and session_stop.container_id:
|
|
||||||
try:
|
|
||||||
container_manager.close_session(session_stop.id, kill=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if session_kill and session_kill.container_id:
|
|
||||||
try:
|
|
||||||
container_manager.close_session(session_kill.id, kill=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ Tests for the MCP server management commands.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from cubbi.cli import app
|
from mcontainer.cli import app
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_list_empty(cli_runner, patched_config_manager):
|
def test_mcp_list_empty(cli_runner, patched_config_manager):
|
||||||
"""Test the 'cubbi mcp list' command with no MCPs configured."""
|
"""Test the 'mc mcp list' command with no MCPs configured."""
|
||||||
# Make sure mcps is empty
|
# Make sure mcps is empty
|
||||||
patched_config_manager.set("mcps", [])
|
patched_config_manager.set("mcps", [])
|
||||||
|
|
||||||
with patch("cubbi.cli.mcp_manager.list_mcps") as mock_list_mcps:
|
with patch("mcontainer.cli.mcp_manager.list_mcps") as mock_list_mcps:
|
||||||
mock_list_mcps.return_value = []
|
mock_list_mcps.return_value = []
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["mcp", "list"])
|
result = cli_runner.invoke(app, ["mcp", "list"])
|
||||||
@@ -21,14 +21,15 @@ def test_mcp_list_empty(cli_runner, patched_config_manager):
|
|||||||
assert "No MCP servers configured" in result.stdout
|
assert "No MCP servers configured" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_add_remote(cli_runner, isolate_cubbi_config):
|
def test_mcp_remote_add_and_list(cli_runner, patched_config_manager):
|
||||||
"""Test adding a remote MCP server and listing it."""
|
"""Test adding a remote MCP server and listing it."""
|
||||||
# Add a remote MCP server
|
# Add a remote MCP server
|
||||||
result = cli_runner.invoke(
|
result = cli_runner.invoke(
|
||||||
app,
|
app,
|
||||||
[
|
[
|
||||||
"mcp",
|
"mcp",
|
||||||
"add-remote",
|
"remote",
|
||||||
|
"add",
|
||||||
"test-remote-mcp",
|
"test-remote-mcp",
|
||||||
"http://mcp-server.example.com/sse",
|
"http://mcp-server.example.com/sse",
|
||||||
"--header",
|
"--header",
|
||||||
@@ -46,16 +47,17 @@ def test_mcp_add_remote(cli_runner, isolate_cubbi_config):
|
|||||||
assert "test-remote-mcp" in result.stdout
|
assert "test-remote-mcp" in result.stdout
|
||||||
assert "remote" in result.stdout
|
assert "remote" in result.stdout
|
||||||
# Check partial URL since it may be truncated in the table display
|
# Check partial URL since it may be truncated in the table display
|
||||||
assert "http://mcp-se" in result.stdout # Truncated in table view
|
assert "http://mcp-server.example.com" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_add(cli_runner, isolate_cubbi_config):
|
def test_mcp_docker_add_and_list(cli_runner, patched_config_manager):
|
||||||
"""Test adding a proxy-based MCP server and listing it."""
|
"""Test adding a Docker-based MCP server and listing it."""
|
||||||
# Add a Docker MCP server
|
# Add a Docker MCP server
|
||||||
result = cli_runner.invoke(
|
result = cli_runner.invoke(
|
||||||
app,
|
app,
|
||||||
[
|
[
|
||||||
"mcp",
|
"mcp",
|
||||||
|
"docker",
|
||||||
"add",
|
"add",
|
||||||
"test-docker-mcp",
|
"test-docker-mcp",
|
||||||
"mcp/github:latest",
|
"mcp/github:latest",
|
||||||
@@ -67,15 +69,58 @@ def test_mcp_add(cli_runner, isolate_cubbi_config):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Added MCP server" in result.stdout
|
assert "Added Docker-based MCP server" in result.stdout
|
||||||
|
|
||||||
# List MCP servers
|
# List MCP servers
|
||||||
result = cli_runner.invoke(app, ["mcp", "list"])
|
result = cli_runner.invoke(app, ["mcp", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "test-docker-mcp" in result.stdout
|
assert "test-docker-mcp" in result.stdout
|
||||||
assert "proxy" in result.stdout # It's a proxy-based MCP
|
assert "docker" in result.stdout
|
||||||
assert "mcp/github:la" in result.stdout # Truncated in table view
|
assert "mcp/github:latest" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_proxy_add_and_list(cli_runner, patched_config_manager):
|
||||||
|
"""Test adding a proxy-based MCP server and listing it."""
|
||||||
|
# Add a proxy MCP server
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"mcp",
|
||||||
|
"proxy",
|
||||||
|
"add",
|
||||||
|
"test-proxy-mcp",
|
||||||
|
"ghcr.io/mcp/github:latest",
|
||||||
|
"--proxy-image",
|
||||||
|
"ghcr.io/sparfenyuk/mcp-proxy:latest",
|
||||||
|
"--command",
|
||||||
|
"github-mcp",
|
||||||
|
"--sse-port",
|
||||||
|
"8080",
|
||||||
|
"--sse-host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--allow-origin",
|
||||||
|
"*",
|
||||||
|
"--env",
|
||||||
|
"GITHUB_TOKEN=test-token",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Added proxy-based MCP server" in result.stdout
|
||||||
|
|
||||||
|
# List MCP servers
|
||||||
|
result = cli_runner.invoke(app, ["mcp", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "test-proxy-mcp" in result.stdout
|
||||||
|
assert "proxy" in result.stdout
|
||||||
|
assert (
|
||||||
|
"ghcr.io/mcp/github" in result.stdout
|
||||||
|
) # Partial match due to potential truncation
|
||||||
|
# The proxy image might not be visible in the table output
|
||||||
|
# so we'll check for the specific format we expect instead
|
||||||
|
assert "via" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_remove(cli_runner, patched_config_manager):
|
def test_mcp_remove(cli_runner, patched_config_manager):
|
||||||
@@ -93,212 +138,28 @@ def test_mcp_remove(cli_runner, patched_config_manager):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the container_manager.list_sessions to return sessions without MCPs
|
# Mock the get_mcp and remove_mcp methods
|
||||||
with patch("cubbi.cli.container_manager.list_sessions") as mock_list_sessions:
|
with patch("mcontainer.cli.mcp_manager.get_mcp") as mock_get_mcp:
|
||||||
mock_list_sessions.return_value = []
|
# First make get_mcp return our MCP
|
||||||
|
mock_get_mcp.return_value = {
|
||||||
|
"name": "test-mcp",
|
||||||
|
"type": "remote",
|
||||||
|
"url": "http://test-server.com/sse",
|
||||||
|
"headers": {"Authorization": "Bearer test-token"},
|
||||||
|
}
|
||||||
|
|
||||||
# Mock the remove_mcp method
|
# Mock the remove_mcp method to return True
|
||||||
with patch("cubbi.cli.mcp_manager.remove_mcp") as mock_remove_mcp:
|
with patch("mcontainer.cli.mcp_manager.remove_mcp") as mock_remove_mcp:
|
||||||
# Make remove_mcp return True (successful removal)
|
|
||||||
mock_remove_mcp.return_value = True
|
mock_remove_mcp.return_value = True
|
||||||
|
|
||||||
# Remove the MCP server
|
# Remove the MCP server
|
||||||
result = cli_runner.invoke(app, ["mcp", "remove", "test-mcp"])
|
result = cli_runner.invoke(app, ["mcp", "remove", "test-mcp"])
|
||||||
|
|
||||||
# Just check it ran successfully with exit code 0
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Removed MCP server 'test-mcp'" in result.stdout
|
assert "Removed MCP server" in result.stdout
|
||||||
|
|
||||||
|
# Verify remove_mcp was called with the right name
|
||||||
def test_mcp_remove_with_active_sessions(cli_runner, patched_config_manager):
|
mock_remove_mcp.assert_called_once_with("test-mcp")
|
||||||
"""Test removing an MCP server that is used by active sessions."""
|
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
|
|
||||||
# Add a remote MCP server
|
|
||||||
patched_config_manager.set(
|
|
||||||
"mcps",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "test-mcp",
|
|
||||||
"type": "remote",
|
|
||||||
"url": "http://test-server.com/sse",
|
|
||||||
"headers": {"Authorization": "Bearer test-token"},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create mock sessions that use the MCP
|
|
||||||
mock_sessions = [
|
|
||||||
Session(
|
|
||||||
id="session-1",
|
|
||||||
name="test-session-1",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
container_id="container-1",
|
|
||||||
mcps=["test-mcp", "other-mcp"],
|
|
||||||
),
|
|
||||||
Session(
|
|
||||||
id="session-2",
|
|
||||||
name="test-session-2",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
container_id="container-2",
|
|
||||||
mcps=["other-mcp"], # This one doesn't use test-mcp
|
|
||||||
),
|
|
||||||
Session(
|
|
||||||
id="session-3",
|
|
||||||
name="test-session-3",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
container_id="container-3",
|
|
||||||
mcps=["test-mcp"], # This one uses test-mcp
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Mock the container_manager.list_sessions to return our sessions
|
|
||||||
with patch("cubbi.cli.container_manager.list_sessions") as mock_list_sessions:
|
|
||||||
mock_list_sessions.return_value = mock_sessions
|
|
||||||
|
|
||||||
# Mock the remove_mcp method
|
|
||||||
with patch("cubbi.cli.mcp_manager.remove_mcp") as mock_remove_mcp:
|
|
||||||
# Make remove_mcp return True (successful removal)
|
|
||||||
mock_remove_mcp.return_value = True
|
|
||||||
|
|
||||||
# Remove the MCP server
|
|
||||||
result = cli_runner.invoke(app, ["mcp", "remove", "test-mcp"])
|
|
||||||
|
|
||||||
# Check it ran successfully with exit code 0
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Removed MCP server 'test-mcp'" in result.stdout
|
|
||||||
# Check warning about affected sessions
|
|
||||||
assert (
|
|
||||||
"Warning: Found 2 active sessions using MCP 'test-mcp'" in result.stdout
|
|
||||||
)
|
|
||||||
assert "session-1" in result.stdout
|
|
||||||
assert "session-3" in result.stdout
|
|
||||||
# session-2 should not be mentioned since it doesn't use test-mcp
|
|
||||||
assert "session-2" not in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_remove_nonexistent(cli_runner, patched_config_manager):
|
|
||||||
"""Test removing a non-existent MCP server."""
|
|
||||||
# No MCPs configured
|
|
||||||
patched_config_manager.set("mcps", [])
|
|
||||||
|
|
||||||
# Mock the container_manager.list_sessions to return empty list
|
|
||||||
with patch("cubbi.cli.container_manager.list_sessions") as mock_list_sessions:
|
|
||||||
mock_list_sessions.return_value = []
|
|
||||||
|
|
||||||
# Mock the remove_mcp method to return False (MCP not found)
|
|
||||||
with patch("cubbi.cli.mcp_manager.remove_mcp") as mock_remove_mcp:
|
|
||||||
mock_remove_mcp.return_value = False
|
|
||||||
|
|
||||||
# Try to remove a non-existent MCP server
|
|
||||||
result = cli_runner.invoke(app, ["mcp", "remove", "nonexistent-mcp"])
|
|
||||||
|
|
||||||
# Check it ran successfully but reported not found
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "MCP server 'nonexistent-mcp' not found" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_mcps_attribute():
|
|
||||||
"""Test that Session model has mcps attribute and can be populated correctly."""
|
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
|
|
||||||
# Test that Session can be created with mcps attribute
|
|
||||||
session = Session(
|
|
||||||
id="test-session",
|
|
||||||
name="test-session",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
container_id="test-container",
|
|
||||||
mcps=["mcp1", "mcp2"],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session.mcps == ["mcp1", "mcp2"]
|
|
||||||
|
|
||||||
# Test that Session can be created with empty mcps list
|
|
||||||
session_empty = Session(
|
|
||||||
id="test-session-2",
|
|
||||||
name="test-session-2",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
container_id="test-container-2",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session_empty.mcps == [] # Should default to empty list
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_mcps_from_container_labels():
|
|
||||||
"""Test that Session mcps are correctly populated from container labels."""
|
|
||||||
from unittest.mock import Mock
|
|
||||||
from cubbi.container import ContainerManager
|
|
||||||
|
|
||||||
# Mock a container with MCP labels
|
|
||||||
mock_container = Mock()
|
|
||||||
mock_container.id = "test-container-id"
|
|
||||||
mock_container.status = "running"
|
|
||||||
mock_container.labels = {
|
|
||||||
"cubbi.session": "true",
|
|
||||||
"cubbi.session.id": "test-session",
|
|
||||||
"cubbi.session.name": "test-session-name",
|
|
||||||
"cubbi.image": "goose",
|
|
||||||
"cubbi.mcps": "mcp1,mcp2,mcp3", # Test with multiple MCPs
|
|
||||||
}
|
|
||||||
mock_container.attrs = {"NetworkSettings": {"Ports": {}}}
|
|
||||||
|
|
||||||
# Mock Docker client
|
|
||||||
mock_client = Mock()
|
|
||||||
mock_client.containers.list.return_value = [mock_container]
|
|
||||||
|
|
||||||
# Create container manager with mocked client
|
|
||||||
with patch("cubbi.container.docker.from_env") as mock_docker:
|
|
||||||
mock_docker.return_value = mock_client
|
|
||||||
mock_client.ping.return_value = True
|
|
||||||
|
|
||||||
container_manager = ContainerManager()
|
|
||||||
sessions = container_manager.list_sessions()
|
|
||||||
|
|
||||||
assert len(sessions) == 1
|
|
||||||
session = sessions[0]
|
|
||||||
assert session.id == "test-session"
|
|
||||||
assert session.mcps == ["mcp1", "mcp2", "mcp3"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_mcps_from_empty_container_labels():
|
|
||||||
"""Test that Session mcps are correctly handled when container has no MCP labels."""
|
|
||||||
from unittest.mock import Mock
|
|
||||||
from cubbi.container import ContainerManager
|
|
||||||
|
|
||||||
# Mock a container without MCP labels
|
|
||||||
mock_container = Mock()
|
|
||||||
mock_container.id = "test-container-id"
|
|
||||||
mock_container.status = "running"
|
|
||||||
mock_container.labels = {
|
|
||||||
"cubbi.session": "true",
|
|
||||||
"cubbi.session.id": "test-session",
|
|
||||||
"cubbi.session.name": "test-session-name",
|
|
||||||
"cubbi.image": "goose",
|
|
||||||
# No cubbi.mcps label
|
|
||||||
}
|
|
||||||
mock_container.attrs = {"NetworkSettings": {"Ports": {}}}
|
|
||||||
|
|
||||||
# Mock Docker client
|
|
||||||
mock_client = Mock()
|
|
||||||
mock_client.containers.list.return_value = [mock_container]
|
|
||||||
|
|
||||||
# Create container manager with mocked client
|
|
||||||
with patch("cubbi.container.docker.from_env") as mock_docker:
|
|
||||||
mock_docker.return_value = mock_client
|
|
||||||
mock_client.ping.return_value = True
|
|
||||||
|
|
||||||
container_manager = ContainerManager()
|
|
||||||
sessions = container_manager.list_sessions()
|
|
||||||
|
|
||||||
assert len(sessions) == 1
|
|
||||||
session = sessions[0]
|
|
||||||
assert session.id == "test-session"
|
|
||||||
assert session.mcps == [] # Should be empty list when no MCPs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.requires_docker
|
@pytest.mark.requires_docker
|
||||||
@@ -319,7 +180,7 @@ def test_mcp_status(cli_runner, patched_config_manager, mock_container_manager):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# First mock get_mcp to return our MCP config
|
# First mock get_mcp to return our MCP config
|
||||||
with patch("cubbi.cli.mcp_manager.get_mcp") as mock_get_mcp:
|
with patch("mcontainer.cli.mcp_manager.get_mcp") as mock_get_mcp:
|
||||||
mock_get_mcp.return_value = {
|
mock_get_mcp.return_value = {
|
||||||
"name": "test-docker-mcp",
|
"name": "test-docker-mcp",
|
||||||
"type": "docker",
|
"type": "docker",
|
||||||
@@ -329,7 +190,7 @@ def test_mcp_status(cli_runner, patched_config_manager, mock_container_manager):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Then mock the get_mcp_status method
|
# Then mock the get_mcp_status method
|
||||||
with patch("cubbi.cli.mcp_manager.get_mcp_status") as mock_get_status:
|
with patch("mcontainer.cli.mcp_manager.get_mcp_status") as mock_get_status:
|
||||||
mock_get_status.return_value = {
|
mock_get_status.return_value = {
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"container_id": "test-container-id",
|
"container_id": "test-container-id",
|
||||||
@@ -350,12 +211,10 @@ def test_mcp_status(cli_runner, patched_config_manager, mock_container_manager):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.requires_docker
|
@pytest.mark.requires_docker
|
||||||
def test_mcp_start(cli_runner, isolate_cubbi_config):
|
def test_mcp_start(cli_runner, patched_config_manager, mock_container_manager):
|
||||||
"""Test starting an MCP server."""
|
"""Test starting an MCP server."""
|
||||||
mcp_manager = isolate_cubbi_config["mcp_manager"]
|
|
||||||
|
|
||||||
# Add a Docker MCP
|
# Add a Docker MCP
|
||||||
isolate_cubbi_config["user_config"].set(
|
patched_config_manager.set(
|
||||||
"mcps",
|
"mcps",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -367,30 +226,25 @@ def test_mcp_start(cli_runner, isolate_cubbi_config):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the start_mcp method to avoid actual Docker operations
|
# Mock the start operation
|
||||||
with patch.object(
|
mock_container_manager.start_mcp.return_value = {
|
||||||
mcp_manager,
|
"container_id": "test-container-id",
|
||||||
"start_mcp",
|
"status": "running",
|
||||||
return_value={
|
}
|
||||||
"container_id": "test-container-id",
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
):
|
|
||||||
# Start the MCP
|
|
||||||
result = cli_runner.invoke(app, ["mcp", "start", "test-docker-mcp"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
# Start the MCP
|
||||||
assert "Started MCP server" in result.stdout
|
result = cli_runner.invoke(app, ["mcp", "start", "test-docker-mcp"])
|
||||||
assert "test-docker-mcp" in result.stdout
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Started MCP server" in result.stdout
|
||||||
|
assert "test-docker-mcp" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.requires_docker
|
@pytest.mark.requires_docker
|
||||||
def test_mcp_stop(cli_runner, isolate_cubbi_config):
|
def test_mcp_stop(cli_runner, patched_config_manager, mock_container_manager):
|
||||||
"""Test stopping an MCP server."""
|
"""Test stopping an MCP server."""
|
||||||
mcp_manager = isolate_cubbi_config["mcp_manager"]
|
|
||||||
|
|
||||||
# Add a Docker MCP
|
# Add a Docker MCP
|
||||||
isolate_cubbi_config["user_config"].set(
|
patched_config_manager.set(
|
||||||
"mcps",
|
"mcps",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -402,23 +256,22 @@ def test_mcp_stop(cli_runner, isolate_cubbi_config):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the stop_mcp method to avoid actual Docker operations
|
# Mock the stop operation
|
||||||
with patch.object(mcp_manager, "stop_mcp", return_value=True):
|
mock_container_manager.stop_mcp.return_value = True
|
||||||
# Stop the MCP
|
|
||||||
result = cli_runner.invoke(app, ["mcp", "stop", "test-docker-mcp"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
# Stop the MCP
|
||||||
assert "Stopped and removed MCP server" in result.stdout
|
result = cli_runner.invoke(app, ["mcp", "stop", "test-docker-mcp"])
|
||||||
assert "test-docker-mcp" in result.stdout
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Stopped MCP server" in result.stdout
|
||||||
|
assert "test-docker-mcp" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.requires_docker
|
@pytest.mark.requires_docker
|
||||||
def test_mcp_restart(cli_runner, isolate_cubbi_config):
|
def test_mcp_restart(cli_runner, patched_config_manager, mock_container_manager):
|
||||||
"""Test restarting an MCP server."""
|
"""Test restarting an MCP server."""
|
||||||
mcp_manager = isolate_cubbi_config["mcp_manager"]
|
|
||||||
|
|
||||||
# Add a Docker MCP
|
# Add a Docker MCP
|
||||||
isolate_cubbi_config["user_config"].set(
|
patched_config_manager.set(
|
||||||
"mcps",
|
"mcps",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -430,21 +283,18 @@ def test_mcp_restart(cli_runner, isolate_cubbi_config):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the restart_mcp method to avoid actual Docker operations
|
# Mock the restart operation
|
||||||
with patch.object(
|
mock_container_manager.restart_mcp.return_value = {
|
||||||
mcp_manager,
|
"container_id": "test-container-id",
|
||||||
"restart_mcp",
|
"status": "running",
|
||||||
return_value={
|
}
|
||||||
"container_id": "test-container-id",
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
):
|
|
||||||
# Restart the MCP
|
|
||||||
result = cli_runner.invoke(app, ["mcp", "restart", "test-docker-mcp"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
# Restart the MCP
|
||||||
assert "Restarted MCP server" in result.stdout
|
result = cli_runner.invoke(app, ["mcp", "restart", "test-docker-mcp"])
|
||||||
assert "test-docker-mcp" in result.stdout
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Restarted MCP server" in result.stdout
|
||||||
|
assert "test-docker-mcp" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.requires_docker
|
@pytest.mark.requires_docker
|
||||||
@@ -464,7 +314,7 @@ def test_mcp_logs(cli_runner, patched_config_manager, mock_container_manager):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Mock the logs operation
|
# Mock the logs operation
|
||||||
with patch("cubbi.cli.mcp_manager.get_mcp_logs") as mock_get_logs:
|
with patch("mcontainer.cli.mcp_manager.get_mcp_logs") as mock_get_logs:
|
||||||
mock_get_logs.return_value = "Test log output"
|
mock_get_logs.return_value = "Test log output"
|
||||||
|
|
||||||
# View MCP logs
|
# View MCP logs
|
||||||
@@ -490,16 +340,18 @@ def test_session_with_mcp(cli_runner, patched_config_manager, mock_container_man
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Mock the session creation with MCP
|
# Mock the session creation with MCP
|
||||||
from cubbi.models import Session, SessionStatus
|
from mcontainer.models import Session, SessionStatus
|
||||||
|
|
||||||
# timestamp no longer needed since we don't use created_at in Session
|
timestamp = "2023-01-01T00:00:00Z"
|
||||||
mock_container_manager.create_session.return_value = Session(
|
mock_container_manager.create_session.return_value = Session(
|
||||||
id="test-session-id",
|
id="test-session-id",
|
||||||
name="test-session",
|
name="test-session",
|
||||||
image="goose",
|
driver="goose",
|
||||||
status=SessionStatus.RUNNING,
|
status=SessionStatus.RUNNING,
|
||||||
container_id="test-container-id",
|
container_id="test-container-id",
|
||||||
|
created_at=timestamp,
|
||||||
ports={},
|
ports={},
|
||||||
|
mcps=["test-mcp"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a session with MCP
|
# Create a session with MCP
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from conftest import requires_docker
|
from conftest import requires_docker
|
||||||
from cubbi.mcp import MCPManager
|
from mcontainer.mcp import MCPManager
|
||||||
|
|
||||||
|
|
||||||
@requires_docker
|
@requires_docker
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ Tests for the session management commands.
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
from cubbi.cli import app
|
from mcontainer.cli import app
|
||||||
|
|
||||||
|
|
||||||
def test_session_list_empty(cli_runner, mock_container_manager):
|
def test_session_list_empty(cli_runner, mock_container_manager):
|
||||||
"""Test 'cubbi session list' with no active sessions."""
|
"""Test 'mc session list' with no active sessions."""
|
||||||
mock_container_manager.list_sessions.return_value = []
|
mock_container_manager.list_sessions.return_value = []
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["session", "list"])
|
result = cli_runner.invoke(app, ["session", "list"])
|
||||||
@@ -19,16 +19,19 @@ def test_session_list_empty(cli_runner, mock_container_manager):
|
|||||||
|
|
||||||
|
|
||||||
def test_session_list_with_sessions(cli_runner, mock_container_manager):
|
def test_session_list_with_sessions(cli_runner, mock_container_manager):
|
||||||
"""Test 'cubbi session list' with active sessions."""
|
"""Test 'mc session list' with active sessions."""
|
||||||
# Create a mock session and set list_sessions to return it
|
# Create a mock session and set list_sessions to return it
|
||||||
from cubbi.models import Session, SessionStatus
|
from mcontainer.models import Session, SessionStatus
|
||||||
|
|
||||||
mock_session = Session(
|
mock_session = Session(
|
||||||
id="test-session-id",
|
id="test-session-id",
|
||||||
name="test-session",
|
name="test-session",
|
||||||
image="goose",
|
driver="goose",
|
||||||
status=SessionStatus.RUNNING,
|
status=SessionStatus.RUNNING,
|
||||||
ports={"8080": "8080"},
|
ports={"8080": "8080"},
|
||||||
|
project=None,
|
||||||
|
created_at="2023-01-01T00:00:00Z",
|
||||||
|
mcps=[],
|
||||||
)
|
)
|
||||||
mock_container_manager.list_sessions.return_value = [mock_session]
|
mock_container_manager.list_sessions.return_value = [mock_session]
|
||||||
|
|
||||||
@@ -40,12 +43,12 @@ def test_session_list_with_sessions(cli_runner, mock_container_manager):
|
|||||||
|
|
||||||
|
|
||||||
def test_session_create_basic(cli_runner, mock_container_manager):
|
def test_session_create_basic(cli_runner, mock_container_manager):
|
||||||
"""Test 'cubbi session create' with basic options."""
|
"""Test 'mc session create' with basic options."""
|
||||||
# We need to patch user_config.get with a side_effect to handle different keys
|
# We need to patch user_config.get with a side_effect to handle different keys
|
||||||
with patch("cubbi.cli.user_config") as mock_user_config:
|
with patch("mcontainer.cli.user_config") as mock_user_config:
|
||||||
# Handle different key requests appropriately
|
# Handle different key requests appropriately
|
||||||
def mock_get_side_effect(key, default=None):
|
def mock_get_side_effect(key, default=None):
|
||||||
if key == "defaults.image":
|
if key == "defaults.driver":
|
||||||
return "goose"
|
return "goose"
|
||||||
elif key == "defaults.volumes":
|
elif key == "defaults.volumes":
|
||||||
return [] # Return empty list for volumes
|
return [] # Return empty list for volumes
|
||||||
@@ -68,39 +71,39 @@ def test_session_create_basic(cli_runner, mock_container_manager):
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Session created successfully" in result.stdout
|
assert "Session created successfully" in result.stdout
|
||||||
|
|
||||||
# Verify container_manager was called with the expected image
|
# Verify container_manager was called with the expected driver
|
||||||
mock_container_manager.create_session.assert_called_once()
|
mock_container_manager.create_session.assert_called_once()
|
||||||
assert (
|
assert (
|
||||||
mock_container_manager.create_session.call_args[1]["image_name"] == "goose"
|
mock_container_manager.create_session.call_args[1]["driver_name"] == "goose"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_session_close(cli_runner, mock_container_manager):
|
def test_session_close(cli_runner, mock_container_manager):
|
||||||
"""Test 'cubbi session close' command."""
|
"""Test 'mc session close' command."""
|
||||||
mock_container_manager.close_session.return_value = True
|
mock_container_manager.close_session.return_value = True
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["session", "close", "test-session-id"])
|
result = cli_runner.invoke(app, ["session", "close", "test-session-id"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "closed successfully" in result.stdout
|
assert "closed successfully" in result.stdout
|
||||||
mock_container_manager.close_session.assert_called_once_with(
|
mock_container_manager.close_session.assert_called_once_with("test-session-id")
|
||||||
"test-session-id", kill=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_close_all(cli_runner, mock_container_manager):
|
def test_session_close_all(cli_runner, mock_container_manager):
|
||||||
"""Test 'cubbi session close --all' command."""
|
"""Test 'mc session close --all' command."""
|
||||||
# Set up mock sessions
|
# Set up mock sessions
|
||||||
from cubbi.models import Session, SessionStatus
|
from mcontainer.models import Session, SessionStatus
|
||||||
|
|
||||||
# timestamp no longer needed since we don't use created_at in Session
|
timestamp = "2023-01-01T00:00:00Z"
|
||||||
mock_sessions = [
|
mock_sessions = [
|
||||||
Session(
|
Session(
|
||||||
id=f"session-{i}",
|
id=f"session-{i}",
|
||||||
name=f"Session {i}",
|
name=f"Session {i}",
|
||||||
image="goose",
|
driver="goose",
|
||||||
status=SessionStatus.RUNNING,
|
status=SessionStatus.RUNNING,
|
||||||
ports={},
|
ports={},
|
||||||
|
project=None,
|
||||||
|
created_at=timestamp,
|
||||||
)
|
)
|
||||||
for i in range(3)
|
for i in range(3)
|
||||||
]
|
]
|
||||||
@@ -115,197 +118,6 @@ def test_session_close_all(cli_runner, mock_container_manager):
|
|||||||
mock_container_manager.close_all_sessions.assert_called_once()
|
mock_container_manager.close_all_sessions.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_session_create_with_ports(
|
|
||||||
cli_runner, mock_container_manager, patched_config_manager
|
|
||||||
):
|
|
||||||
"""Test session creation with port forwarding."""
|
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
|
|
||||||
# Mock the create_session to return a session with ports
|
|
||||||
mock_session = Session(
|
|
||||||
id="test-session-id",
|
|
||||||
name="test-session",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
ports={8000: 32768, 3000: 32769},
|
|
||||||
)
|
|
||||||
mock_container_manager.create_session.return_value = mock_session
|
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["session", "create", "--port", "8000,3000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Session created successfully" in result.stdout
|
|
||||||
assert "Forwarding ports: 8000, 3000" in result.stdout
|
|
||||||
|
|
||||||
# Verify create_session was called with correct ports
|
|
||||||
mock_container_manager.create_session.assert_called_once()
|
|
||||||
call_args = mock_container_manager.create_session.call_args
|
|
||||||
assert call_args.kwargs["ports"] == [8000, 3000]
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_create_with_default_ports(
|
|
||||||
cli_runner, mock_container_manager, patched_config_manager
|
|
||||||
):
|
|
||||||
"""Test session creation using default ports."""
|
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
|
|
||||||
# Set up default ports
|
|
||||||
patched_config_manager.set("defaults.ports", [8080, 9000])
|
|
||||||
|
|
||||||
# Mock the create_session to return a session with ports
|
|
||||||
mock_session = Session(
|
|
||||||
id="test-session-id",
|
|
||||||
name="test-session",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
ports={8080: 32768, 9000: 32769},
|
|
||||||
)
|
|
||||||
mock_container_manager.create_session.return_value = mock_session
|
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["session", "create"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Session created successfully" in result.stdout
|
|
||||||
assert "Forwarding ports: 8080, 9000" in result.stdout
|
|
||||||
|
|
||||||
# Verify create_session was called with default ports
|
|
||||||
mock_container_manager.create_session.assert_called_once()
|
|
||||||
call_args = mock_container_manager.create_session.call_args
|
|
||||||
assert call_args.kwargs["ports"] == [8080, 9000]
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_create_combine_default_and_custom_ports(
|
|
||||||
cli_runner, mock_container_manager, patched_config_manager
|
|
||||||
):
|
|
||||||
"""Test session creation combining default and custom ports."""
|
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
|
|
||||||
# Set up default ports
|
|
||||||
patched_config_manager.set("defaults.ports", [8080])
|
|
||||||
|
|
||||||
# Mock the create_session to return a session with combined ports
|
|
||||||
mock_session = Session(
|
|
||||||
id="test-session-id",
|
|
||||||
name="test-session",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
ports={8080: 32768, 3000: 32769},
|
|
||||||
)
|
|
||||||
mock_container_manager.create_session.return_value = mock_session
|
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["session", "create", "--port", "3000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Session created successfully" in result.stdout
|
|
||||||
# Ports should be combined and deduplicated
|
|
||||||
assert "Forwarding ports:" in result.stdout
|
|
||||||
|
|
||||||
# Verify create_session was called with combined ports
|
|
||||||
mock_container_manager.create_session.assert_called_once()
|
|
||||||
call_args = mock_container_manager.create_session.call_args
|
|
||||||
# Should contain both default (8080) and custom (3000) ports
|
|
||||||
assert set(call_args.kwargs["ports"]) == {8080, 3000}
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_create_invalid_port_format(
|
|
||||||
cli_runner, mock_container_manager, patched_config_manager
|
|
||||||
):
|
|
||||||
"""Test session creation with invalid port format."""
|
|
||||||
result = cli_runner.invoke(app, ["session", "create", "--port", "invalid"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Warning: Ignoring invalid port format" in result.stdout
|
|
||||||
|
|
||||||
# Session creation should continue with empty ports list (invalid port ignored)
|
|
||||||
mock_container_manager.create_session.assert_called_once()
|
|
||||||
call_args = mock_container_manager.create_session.call_args
|
|
||||||
assert call_args.kwargs["ports"] == [] # Invalid port should be ignored
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_create_invalid_port_range(
|
|
||||||
cli_runner, mock_container_manager, patched_config_manager
|
|
||||||
):
|
|
||||||
"""Test session creation with port outside valid range."""
|
|
||||||
result = cli_runner.invoke(app, ["session", "create", "--port", "70000"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Error: Invalid ports [70000]" in result.stdout
|
|
||||||
|
|
||||||
# Session creation should not happen due to early return
|
|
||||||
mock_container_manager.create_session.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_list_shows_ports(cli_runner, mock_container_manager):
|
|
||||||
"""Test that session list shows port mappings."""
|
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
|
|
||||||
mock_session = Session(
|
|
||||||
id="test-session-id",
|
|
||||||
name="test-session",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
ports={8000: 32768, 3000: 32769},
|
|
||||||
)
|
|
||||||
mock_container_manager.list_sessions.return_value = [mock_session]
|
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["session", "list"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "8000:32768" in result.stdout
|
|
||||||
assert "3000:32769" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_close_with_kill_flag(
|
|
||||||
cli_runner, mock_container_manager, patched_config_manager
|
|
||||||
):
|
|
||||||
"""Test session close with --kill flag."""
|
|
||||||
result = cli_runner.invoke(app, ["session", "close", "test-session-id", "--kill"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
# Verify close_session was called with kill=True
|
|
||||||
mock_container_manager.close_session.assert_called_once_with(
|
|
||||||
"test-session-id", kill=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_close_all_with_kill_flag(
|
|
||||||
cli_runner, mock_container_manager, patched_config_manager
|
|
||||||
):
|
|
||||||
"""Test session close --all with --kill flag."""
|
|
||||||
from cubbi.models import Session, SessionStatus
|
|
||||||
|
|
||||||
# Mock some sessions to close
|
|
||||||
mock_sessions = [
|
|
||||||
Session(
|
|
||||||
id="session-1",
|
|
||||||
name="Session 1",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
ports={},
|
|
||||||
),
|
|
||||||
Session(
|
|
||||||
id="session-2",
|
|
||||||
name="Session 2",
|
|
||||||
image="goose",
|
|
||||||
status=SessionStatus.RUNNING,
|
|
||||||
ports={},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
mock_container_manager.list_sessions.return_value = mock_sessions
|
|
||||||
mock_container_manager.close_all_sessions.return_value = (2, True)
|
|
||||||
|
|
||||||
result = cli_runner.invoke(app, ["session", "close", "--all", "--kill"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "2 sessions closed successfully" in result.stdout
|
|
||||||
|
|
||||||
# Verify close_all_sessions was called with kill=True
|
|
||||||
mock_container_manager.close_all_sessions.assert_called_once()
|
|
||||||
call_args = mock_container_manager.close_all_sessions.call_args
|
|
||||||
assert call_args.kwargs["kill"] is True
|
|
||||||
|
|
||||||
|
|
||||||
# For more complex tests that need actual Docker,
|
# For more complex tests that need actual Docker,
|
||||||
# we've implemented them in test_integration_docker.py
|
# we've implemented them in test_integration_docker.py
|
||||||
# They will run automatically if Docker is available
|
# They will run automatically if Docker is available
|
||||||
|
|||||||
392
uv.lock
generated
392
uv.lock
generated
@@ -1,58 +1,57 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.1.31"
|
version = "2025.1.31"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.1"
|
version = "3.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" },
|
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -60,25 +59,69 @@ name = "click"
|
|||||||
version = "8.1.8"
|
version = "8.1.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cubbi"
|
name = "docker"
|
||||||
version = "0.4.0"
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcontainer"
|
||||||
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "docker" },
|
{ name = "docker" },
|
||||||
@@ -111,62 +154,17 @@ requires-dist = [
|
|||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.9" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.9" },
|
||||||
{ name = "typer", specifier = ">=0.9.0" },
|
{ name = "typer", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pytest", specifier = ">=8.3.5" }]
|
dev = [{ name = "pytest", specifier = ">=8.3.5" }]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "docker"
|
|
||||||
version = "7.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
|
||||||
{ name = "requests" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.10"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iniconfig"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markdown-it-py"
|
|
||||||
version = "3.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "mdurl" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdurl"
|
name = "mdurl"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -177,48 +175,48 @@ dependencies = [
|
|||||||
{ name = "mypy-extensions" },
|
{ name = "mypy-extensions" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload-time = "2025-02-05T03:50:28.25Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload-time = "2025-02-05T03:50:13.411Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload-time = "2025-02-05T03:50:31.421Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload-time = "2025-02-05T03:48:48.705Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload-time = "2025-02-05T03:49:03.628Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" },
|
{ url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy-extensions"
|
name = "mypy-extensions"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "24.2"
|
version = "24.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -230,9 +228,9 @@ dependencies = [
|
|||||||
{ name = "pydantic-core" },
|
{ name = "pydantic-core" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -242,45 +240,45 @@ source = { registry = "https://pypi.org/simple" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.1"
|
version = "2.19.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -293,9 +291,9 @@ dependencies = [
|
|||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -303,38 +301,38 @@ name = "pywin32"
|
|||||||
version = "309"
|
version = "309"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/2c/b0240b14ff3dba7a8a7122dc9bbf7fbd21ed0e8b57c109633675b5d1761f/pywin32-309-cp312-cp312-win32.whl", hash = "sha256:de9acacced5fa82f557298b1fed5fef7bd49beee04190f68e1e4783fbdc19926", size = 8790648, upload-time = "2025-03-09T18:04:03.253Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/2c/b0240b14ff3dba7a8a7122dc9bbf7fbd21ed0e8b57c109633675b5d1761f/pywin32-309-cp312-cp312-win32.whl", hash = "sha256:de9acacced5fa82f557298b1fed5fef7bd49beee04190f68e1e4783fbdc19926", size = 8790648 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/11/c36884c732e2b3397deee808b5dac1abbb170ec37f94c6606fcb04d1e9d7/pywin32-309-cp312-cp312-win_amd64.whl", hash = "sha256:6ff9eebb77ffc3d59812c68db33c0a7817e1337e3537859499bd27586330fc9e", size = 9497399, upload-time = "2025-03-09T18:04:05.388Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/11/c36884c732e2b3397deee808b5dac1abbb170ec37f94c6606fcb04d1e9d7/pywin32-309-cp312-cp312-win_amd64.whl", hash = "sha256:6ff9eebb77ffc3d59812c68db33c0a7817e1337e3537859499bd27586330fc9e", size = 9497399 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/9f/79703972958f8ba3fd38bc9bf1165810bd75124982419b0cc433a2894d46/pywin32-309-cp312-cp312-win_arm64.whl", hash = "sha256:619f3e0a327b5418d833f44dc87859523635cf339f86071cc65a13c07be3110f", size = 8454122, upload-time = "2025-03-09T18:04:07.217Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/9f/79703972958f8ba3fd38bc9bf1165810bd75124982419b0cc433a2894d46/pywin32-309-cp312-cp312-win_arm64.whl", hash = "sha256:619f3e0a327b5418d833f44dc87859523635cf339f86071cc65a13c07be3110f", size = 8454122 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/c3/51aca6887cc5e410aa4cdc55662cf8438212440c67335c3f141b02eb8d52/pywin32-309-cp313-cp313-win32.whl", hash = "sha256:008bffd4afd6de8ca46c6486085414cc898263a21a63c7f860d54c9d02b45c8d", size = 8789700, upload-time = "2025-03-09T18:04:08.937Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/c3/51aca6887cc5e410aa4cdc55662cf8438212440c67335c3f141b02eb8d52/pywin32-309-cp313-cp313-win32.whl", hash = "sha256:008bffd4afd6de8ca46c6486085414cc898263a21a63c7f860d54c9d02b45c8d", size = 8789700 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/66/330f265140fa814b4ed1bf16aea701f9d005f8f4ab57a54feb17f53afe7e/pywin32-309-cp313-cp313-win_amd64.whl", hash = "sha256:bd0724f58492db4cbfbeb1fcd606495205aa119370c0ddc4f70e5771a3ab768d", size = 9496714, upload-time = "2025-03-09T18:04:10.619Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/66/330f265140fa814b4ed1bf16aea701f9d005f8f4ab57a54feb17f53afe7e/pywin32-309-cp313-cp313-win_amd64.whl", hash = "sha256:bd0724f58492db4cbfbeb1fcd606495205aa119370c0ddc4f70e5771a3ab768d", size = 9496714 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/84/9a51e6949a03f25cd329ece54dbf0846d57fadd2e79046c3b8d140aaa132/pywin32-309-cp313-cp313-win_arm64.whl", hash = "sha256:8fd9669cfd41863b688a1bc9b1d4d2d76fd4ba2128be50a70b0ea66b8d37953b", size = 8453052, upload-time = "2025-03-09T18:04:12.812Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/84/9a51e6949a03f25cd329ece54dbf0846d57fadd2e79046c3b8d140aaa132/pywin32-309-cp313-cp313-win_arm64.whl", hash = "sha256:8fd9669cfd41863b688a1bc9b1d4d2d76fd4ba2128be50a70b0ea66b8d37953b", size = 8453052 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.2"
|
version = "6.0.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -347,9 +345,9 @@ dependencies = [
|
|||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -360,43 +358,43 @@ dependencies = [
|
|||||||
{ name = "markdown-it-py" },
|
{ name = "markdown-it-py" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/20/8e/fafaa6f15c332e73425d9c44ada85360501045d5ab0b81400076aff27cf6/ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7", size = 3759776, upload-time = "2025-03-07T15:27:44.363Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/20/8e/fafaa6f15c332e73425d9c44ada85360501045d5ab0b81400076aff27cf6/ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7", size = 3759776 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/b2/af7c2cc9e438cbc19fafeec4f20bfcd72165460fe75b2b6e9a0958c8c62b/ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d", size = 10049494, upload-time = "2025-03-07T15:26:51.268Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/b2/af7c2cc9e438cbc19fafeec4f20bfcd72165460fe75b2b6e9a0958c8c62b/ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d", size = 10049494 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/12/03f6dfa1b95ddd47e6969f0225d60d9d7437c91938a310835feb27927ca0/ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d", size = 10853584, upload-time = "2025-03-07T15:26:56.104Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/12/03f6dfa1b95ddd47e6969f0225d60d9d7437c91938a310835feb27927ca0/ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d", size = 10853584 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/49/1c79e0906b6ff551fb0894168763f705bf980864739572b2815ecd3c9df0/ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d", size = 10155692, upload-time = "2025-03-07T15:27:01.385Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/49/1c79e0906b6ff551fb0894168763f705bf980864739572b2815ecd3c9df0/ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d", size = 10155692 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/01/85e8082e41585e0e1ceb11e41c054e9e36fed45f4b210991052d8a75089f/ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c", size = 10369760, upload-time = "2025-03-07T15:27:04.023Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/01/85e8082e41585e0e1ceb11e41c054e9e36fed45f4b210991052d8a75089f/ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c", size = 10369760 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/90/0bc60bd4e5db051f12445046d0c85cc2c617095c0904f1aa81067dc64aea/ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e", size = 9912196, upload-time = "2025-03-07T15:27:06.93Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/90/0bc60bd4e5db051f12445046d0c85cc2c617095c0904f1aa81067dc64aea/ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e", size = 9912196 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/ea/0b7e8c42b1ec608033c4d5a02939c82097ddcb0b3e393e4238584b7054ab/ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12", size = 11434985, upload-time = "2025-03-07T15:27:10.082Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/ea/0b7e8c42b1ec608033c4d5a02939c82097ddcb0b3e393e4238584b7054ab/ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12", size = 11434985 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/86/3171d1eff893db4f91755175a6e1163c5887be1f1e2f4f6c0c59527c2bfd/ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16", size = 12155842, upload-time = "2025-03-07T15:27:12.727Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/86/3171d1eff893db4f91755175a6e1163c5887be1f1e2f4f6c0c59527c2bfd/ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16", size = 12155842 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/9e/700ca289f172a38eb0bca752056d0a42637fa17b81649b9331786cb791d7/ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52", size = 11613804, upload-time = "2025-03-07T15:27:15.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/9e/700ca289f172a38eb0bca752056d0a42637fa17b81649b9331786cb791d7/ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52", size = 11613804 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/92/648020b3b5db180f41a931a68b1c8575cca3e63cec86fd26807422a0dbad/ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1", size = 13823776, upload-time = "2025-03-07T15:27:18.996Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/92/648020b3b5db180f41a931a68b1c8575cca3e63cec86fd26807422a0dbad/ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1", size = 13823776 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/a6/cc472161cd04d30a09d5c90698696b70c169eeba2c41030344194242db45/ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c", size = 11302673, upload-time = "2025-03-07T15:27:21.655Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/a6/cc472161cd04d30a09d5c90698696b70c169eeba2c41030344194242db45/ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c", size = 11302673 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/db/d31c361c4025b1b9102b4d032c70a69adb9ee6fde093f6c3bf29f831c85c/ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43", size = 10235358, upload-time = "2025-03-07T15:27:24.72Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/db/d31c361c4025b1b9102b4d032c70a69adb9ee6fde093f6c3bf29f831c85c/ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43", size = 10235358 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/86/d6374e24a14d4d93ebe120f45edd82ad7dcf3ef999ffc92b197d81cdc2a5/ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c", size = 9886177, upload-time = "2025-03-07T15:27:27.282Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/86/d6374e24a14d4d93ebe120f45edd82ad7dcf3ef999ffc92b197d81cdc2a5/ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c", size = 9886177 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/62/a61691f6eaaac1e945a1f3f59f1eea9a218513139d5b6c2b8f88b43b5b8f/ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5", size = 10864747, upload-time = "2025-03-07T15:27:30.637Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/62/a61691f6eaaac1e945a1f3f59f1eea9a218513139d5b6c2b8f88b43b5b8f/ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5", size = 10864747 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/94/2c7065e1d92a8a8a46d46d9c3cf07b0aa7e0a1e0153d74baa5e6620b4102/ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8", size = 11360441, upload-time = "2025-03-07T15:27:33.356Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/94/2c7065e1d92a8a8a46d46d9c3cf07b0aa7e0a1e0153d74baa5e6620b4102/ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8", size = 11360441 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/8f/1f545ea6f9fcd7bf4368551fb91d2064d8f0577b3079bb3f0ae5779fb773/ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029", size = 10247401, upload-time = "2025-03-07T15:27:35.994Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/8f/1f545ea6f9fcd7bf4368551fb91d2064d8f0577b3079bb3f0ae5779fb773/ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029", size = 10247401 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/18/fb703603ab108e5c165f52f5b86ee2aa9be43bb781703ec87c66a5f5d604/ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1", size = 11366360, upload-time = "2025-03-07T15:27:38.66Z" },
|
{ url = "https://files.pythonhosted.org/packages/4f/18/fb703603ab108e5c165f52f5b86ee2aa9be43bb781703ec87c66a5f5d604/ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1", size = 11366360 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/85/338e603dc68e7d9994d5d84f24adbf69bae760ba5efd3e20f5ff2cec18da/ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69", size = 10436892, upload-time = "2025-03-07T15:27:41.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/85/338e603dc68e7d9994d5d84f24adbf69bae760ba5efd3e20f5ff2cec18da/ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69", size = 10436892 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shellingham"
|
name = "shellingham"
|
||||||
version = "1.5.4"
|
version = "1.5.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -409,25 +407,25 @@ dependencies = [
|
|||||||
{ name = "shellingham" },
|
{ name = "shellingham" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711, upload-time = "2025-02-27T19:17:34.807Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061, upload-time = "2025-02-27T19:17:32.111Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.12.2"
|
version = "4.12.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user