Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9125a52f57 | |||
| c8441fc593 | |||
| 62951f1fca | |||
| 6bebc02122 | |||
| 97b036e7d4 | |||
| 240643d3b0 | |||
| 68f881e220 | |||
| 94d938156d | |||
| 1d30ac954d | |||
| a468ec68ea | |||
| 52c1d47161 | |||
| b206a6870c | |||
| ac48f0d166 | |||
| 49ef1203e4 | |||
| b5837a704d | |||
| 5764ff2fdc | |||
| df2f4321a1 | |||
| 8677e7d52b | |||
| d6ae51fad0 | |||
| eca289be3a | |||
| 7aa75f9451 | |||
| efac32ad9f | |||
| a69accbad5 | |||
| 29e5307121 | |||
| 545a245530 | |||
| c554463d4d | |||
| 4ea87a6e01 | |||
| f5a571679a | |||
| 0e1dcd3a80 | |||
| 4238f38353 | |||
| 858799ce20 | |||
| 32246a99c1 | |||
| bb667d2209 | |||
| f08bb30b53 | |||
| 32b039a838 | |||
| 4dcaea7bf9 | |||
| c2bc5cd314 | |||
| c73b923e40 | |||
| 08ed594bfd | |||
| fd074a5643 | |||
| 40863c0aa7 | |||
| d9b223ac3c | |||
| 1749f9ca05 | |||
| 61554aaa8c | |||
| f4fcae7c0f | |||
| 05b9bb1ae4 | |||
| 45b72c7241 | |||
| 2646b36cb1 | |||
| 3202e4899a | |||
| c4a5184725 | |||
| 63d56eadc9 | |||
| 507b73a07e | |||
| 814f765313 | |||
| 92a154f54a | |||
| 7aa7dea5ad | |||
| 59b0472125 | |||
| 673caf2e95 | |||
| ab5ff4b4be | |||
| f5a68b500b | |||
| a5edad7f17 | |||
| fb657b875d | |||
| 016d4dc523 | |||
| c8d99cfba3 | |||
| 74c8698566 | |||
| 03333991a4 | |||
| 2b0dba4ed1 | |||
| 57c900b2c9 | |||
| 0ccee61192 | |||
| 494dc4b8a1 | |||
| 3b135ad4a4 | |||
| 258591833f | |||
| 3efc22c589 | |||
| 8ba4c323c2 | |||
| 3afa72095b | |||
| cf66809349 | |||
| 020a2cba79 | |||
| 3fd7b47097 | |||
| e1f077dac3 | |||
| 29c52b7072 | |||
| 312cb71bf0 | |||
| f89ea12d9e | |||
| 0fe47943aa | |||
| 4f225fd7d3 | |||
| f04d7d2808 | |||
| 4ae6370bcf |
@@ -4,3 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -192,8 +192,8 @@ main() {
|
||||
|
||||
# Always run formatter to ensure consistent formatting
|
||||
echo "🔧 Running formatter to ensure consistent formatting..."
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
bun fmt >/dev/null 2>&1 || echo "⚠️ Warning: bun fmt failed, but continuing..."
|
||||
if command -v bun > /dev/null 2>&1; then
|
||||
bun fmt > /dev/null 2>&1 || echo "⚠️ Warning: bun fmt failed, but continuing..."
|
||||
else
|
||||
echo "⚠️ Warning: bun not found, skipping formatting"
|
||||
fi
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
muc = "muc" # For Munich location code
|
||||
Hashi = "Hashi"
|
||||
HashiCorp = "HashiCorp"
|
||||
mavrickrishi = "mavrickrishi" # Username
|
||||
mavrick = "mavrick" # Username
|
||||
|
||||
[files]
|
||||
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run check.sh
|
||||
run: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@main
|
||||
- name: Set up Bun
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.34.0
|
||||
uses: crate-ci/typos@v1.35.5
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
needs: validate-style
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: deploy-registry
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Runs at 02:30 UTC Monday through Friday
|
||||
- cron: "30 2 * * 1-5"
|
||||
push:
|
||||
tags:
|
||||
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
||||
@@ -11,6 +14,7 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/deploy-registry.yaml"
|
||||
- "registry/**/templates/**"
|
||||
- "registry/**/README.md"
|
||||
- ".icons/**"
|
||||
|
||||
jobs:
|
||||
@@ -24,14 +28,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Authenticate with Google Cloud
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193
|
||||
uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5
|
||||
with:
|
||||
workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github
|
||||
service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a
|
||||
uses: google-github-actions/setup-gcloud@26f734c2779b00b7dda794207734c511110a4368
|
||||
- name: Deploy to dev.registry.coder.com
|
||||
run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main
|
||||
- name: Deploy to registry.coder.com
|
||||
|
||||
@@ -14,11 +14,11 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.1
|
||||
version: v2.1
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "release/*/*/v*.*.*"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Extract tag information
|
||||
id: tag_info
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
IFS='/' read -ra PARTS <<< "$TAG"
|
||||
NAMESPACE="${PARTS[1]}"
|
||||
MODULE="${PARTS[2]}"
|
||||
VERSION="${PARTS[3]}"
|
||||
|
||||
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
||||
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
||||
|
||||
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
||||
echo "release_title=$RELEASE_TITLE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find previous tag
|
||||
id: prev_tag
|
||||
env:
|
||||
NAMESPACE: ${{ steps.tag_info.outputs.namespace }}
|
||||
MODULE: ${{ steps.tag_info.outputs.module }}
|
||||
CURRENT_TAG: ${{ steps.tag_info.outputs.tag }}
|
||||
run: |
|
||||
PREV_TAG=$(git tag -l "release/$NAMESPACE/$MODULE/v*" | sort -V | grep -B1 "$CURRENT_TAG" | head -1)
|
||||
|
||||
if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||
echo "No previous tag found, using initial commit"
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Previous tag: $PREV_TAG"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
MODULE_PATH: ${{ steps.tag_info.outputs.module_path }}
|
||||
PREV_TAG: ${{ steps.prev_tag.outputs.prev_tag }}
|
||||
CURRENT_TAG: ${{ steps.tag_info.outputs.tag }}
|
||||
run: |
|
||||
echo "Generating changelog for $MODULE_PATH between $PREV_TAG and $CURRENT_TAG"
|
||||
|
||||
COMMITS=$(git log --oneline --no-merges "$PREV_TAG..$CURRENT_TAG" -- "$MODULE_PATH")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No commits found for this module"
|
||||
echo "changelog=No changes found for this module." >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PREV_TAG" == release/* ]]; then
|
||||
FULL_CHANGELOG=$(gh api repos/:owner/:repo/releases/generate-notes \
|
||||
--field tag_name="$CURRENT_TAG" \
|
||||
--field previous_tag_name="$PREV_TAG" \
|
||||
--jq '.body')
|
||||
else
|
||||
echo "New module detected, skipping GitHub API"
|
||||
FULL_CHANGELOG=""
|
||||
fi
|
||||
|
||||
MODULE_COMMIT_SHAS=$(git log --format="%H" --no-merges "$PREV_TAG..$CURRENT_TAG" -- "$MODULE_PATH")
|
||||
|
||||
FILTERED_CHANGELOG="## What's Changed\n\n"
|
||||
|
||||
for sha in $MODULE_COMMIT_SHAS; do
|
||||
SHORT_SHA=${sha:0:7}
|
||||
|
||||
COMMIT_LINES=$(echo "$FULL_CHANGELOG" | grep -E "$SHORT_SHA|$(git log --format='%s' -n 1 $sha)" || true)
|
||||
|
||||
if [ -n "$COMMIT_LINES" ]; then
|
||||
FILTERED_CHANGELOG="${FILTERED_CHANGELOG}${COMMIT_LINES}\n"
|
||||
else
|
||||
COMMIT_MSG=$(git log --format="%s" -n 1 $sha)
|
||||
AUTHOR=$(gh api repos/:owner/:repo/commits/$sha --jq '.author.login // .commit.author.name')
|
||||
FILTERED_CHANGELOG="${FILTERED_CHANGELOG}* $COMMIT_MSG by @$AUTHOR\n"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||
echo -e "$FILTERED_CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ steps.tag_info.outputs.tag }}
|
||||
RELEASE_TITLE: ${{ steps.tag_info.outputs.release_title }}
|
||||
CHANGELOG: ${{ steps.changelog.outputs.changelog }}
|
||||
run: |
|
||||
gh release create "$TAG_NAME" \
|
||||
--title "$RELEASE_TITLE" \
|
||||
--notes "$CHANGELOG"
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -145,3 +145,6 @@ dist
|
||||
|
||||
# Generated credentials from google-github-actions/auth
|
||||
gha-creds-*.json
|
||||
|
||||
# IDEs
|
||||
.idea
|
||||
|
||||
@@ -163,8 +163,8 @@ linters:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- SA4006 # Detects redundant assignments
|
||||
- SA4009 # Detects redundant variable declarations
|
||||
- SA4006 # Detects redundant assignments
|
||||
- SA4009 # Detects redundant variable declarations
|
||||
- SA1019
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="25" height="25"/>
|
||||
<path d="M5.50694 21.1637C5.17323 21.1637 4.89218 21.1064 4.66378 20.9926C4.436 20.8787 4.26333 20.7052 4.1476 20.4763C4.03187 20.2473 3.97247 19.9672 3.97247 19.6382V16.2463C3.97247 15.8281 3.88859 15.5239 3.72265 15.3341C3.55549 15.1449 3.25912 15.0449 2.83418 15.0353C2.70191 15.0353 2.59598 14.9859 2.51577 14.8853C2.43433 14.7859 2.39453 14.6708 2.39453 14.5425C2.39453 14.4033 2.43433 14.2882 2.51577 14.1984C2.5966 14.1087 2.70375 14.0593 2.83418 14.0496C3.25912 14.0394 3.55549 13.94 3.72265 13.7508C3.88981 13.5616 3.97247 13.2622 3.97247 12.8537V9.46177C3.97247 8.96352 4.10474 8.58456 4.36742 8.3261C4.6301 8.06763 5.01035 7.9375 5.50694 7.9375H9.55926C9.71173 7.9375 9.83725 7.98269 9.9389 8.07185C10.0399 8.16162 10.0914 8.27669 10.0914 8.41466C10.0914 8.5448 10.0485 8.65626 9.96278 8.75145C9.87706 8.84664 9.76316 8.89423 9.62049 8.89423H5.8578C5.6441 8.89423 5.48245 8.94906 5.37162 9.05871C5.26018 9.16836 5.20446 9.33766 5.20446 9.5678V12.9754C5.20446 13.2742 5.14323 13.5454 5.02199 13.7894C4.90075 14.034 4.73848 14.2256 4.53581 14.3659C4.33313 14.5051 4.09616 14.575 3.82246 14.575V14.5147C4.09616 14.5147 4.33313 14.5846 4.53581 14.7238C4.73848 14.863 4.90075 15.0552 5.02199 15.3004C5.14323 15.5444 5.20446 15.8155 5.20446 16.1143V19.537C5.20446 19.7671 5.26018 19.9358 5.37162 20.0461C5.48306 20.1569 5.64533 20.2106 5.8578 20.2106H9.62049C9.76194 20.2106 9.87583 20.2582 9.96278 20.3527C10.0497 20.4479 10.0914 20.56 10.0914 20.6895C10.0914 20.8191 10.0412 20.9299 9.9389 21.0251C9.83725 21.1203 9.71112 21.1673 9.55926 21.1673H5.50694V21.1643V21.1637Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M15.4423 21.1634C15.2898 21.1634 15.1643 21.1158 15.0626 21.0212C14.961 20.926 14.9102 20.8139 14.9102 20.6856C14.9102 20.5573 14.953 20.444 15.0387 20.3488C15.1245 20.2536 15.2384 20.2067 15.381 20.2067H19.1437C19.3574 20.2067 19.5191 20.153 19.6299 20.0422C19.7413 19.9325 19.7971 19.7632 19.7971 19.5331V16.1104C19.7971 15.8116 19.8583 15.5405 19.9795 15.2965C20.1008 15.0519 20.263 14.8603 20.4657 14.7199C20.6684 14.5807 20.9054 14.5108 21.1791 14.5108V14.5711C20.9054 14.5711 20.6684 14.5012 20.4657 14.362C20.263 14.2229 20.1008 14.0307 19.9795 13.7855C19.8583 13.5415 19.7971 13.2703 19.7971 12.9715V9.5639C19.7971 9.33496 19.7413 9.16566 19.6299 9.0548C19.5185 8.94515 19.3562 8.89033 19.1437 8.89033H15.381C15.2396 8.89033 15.1257 8.84273 15.0387 8.74754C14.953 8.65355 14.9102 8.54089 14.9102 8.41076C14.9102 8.27158 14.9604 8.15771 15.0626 8.06795C15.1637 7.97818 15.2898 7.93359 15.4423 7.93359H19.4946C19.9912 7.93359 20.3702 8.06373 20.6341 8.32219C20.898 8.58065 21.029 8.95961 21.029 9.45786V12.8498C21.029 13.2583 21.1129 13.5583 21.2789 13.7469C21.446 13.9361 21.7424 14.0361 22.1673 14.0457C22.2996 14.0554 22.4055 14.1048 22.4858 14.1945C22.5672 14.2843 22.607 14.3994 22.607 14.5385C22.607 14.6687 22.5672 14.7826 22.4858 14.8814C22.4055 14.9808 22.2978 15.0314 22.1673 15.0314C21.7424 15.041 21.4466 15.141 21.2789 15.3302C21.1117 15.5194 21.029 15.823 21.029 16.2424V19.6343C21.029 19.9639 20.9709 20.2422 20.8539 20.4723C20.737 20.7025 20.5655 20.8736 20.3377 20.9887C20.1093 21.1025 19.8283 21.1598 19.4946 21.1598H15.4423V21.1628V21.1634Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M16.4845 15.8401C17.2224 15.8401 17.8206 15.2515 17.8206 14.5255C17.8206 13.7996 17.2224 13.2109 16.4845 13.2109C15.7467 13.2109 15.1484 13.7996 15.1484 14.5255C15.1484 15.2515 15.7467 15.8401 16.4845 15.8401Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M9.00014 15.8401C9.73798 15.8401 10.3362 15.2515 10.3362 14.5255C10.3362 13.7996 9.73798 13.2109 9.00014 13.2109C8.2623 13.2109 7.66406 13.7996 7.66406 14.5255C7.66406 15.2515 8.2623 15.8401 9.00014 15.8401Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M12.0442 4.13327L11.942 6.81971C11.942 6.97033 11.7974 7.04564 11.5084 7.04564C11.2194 7.04564 11.0749 6.97033 11.0749 6.81971C11.0492 6.15036 11.0284 5.63103 11.0112 5.26291C11.0027 4.88637 10.9941 4.61826 10.9855 4.45921C10.9769 4.30016 10.9727 4.20376 10.9727 4.17062V4.12062C10.9727 3.92843 11.1515 3.83203 11.5084 3.83203C11.8654 3.83203 12.0442 3.93264 12.0442 4.13327ZM14.213 4.13327L14.1108 6.81971C14.1108 6.97033 13.9663 7.04564 13.6773 7.04564C13.3883 7.04564 13.2437 6.97033 13.2437 6.81971C13.218 6.15036 13.1972 5.63103 13.1801 5.26291C13.1715 4.88637 13.1629 4.61826 13.1543 4.45921C13.1458 4.30016 13.1415 4.20376 13.1415 4.17062V4.12062C13.1415 3.92843 13.3203 3.83203 13.6773 3.83203C14.0342 3.83203 14.213 3.93264 14.213 4.13327Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="721" height="721" viewBox="0 0 721 721" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1637_2935)">
|
||||
<g clip-path="url(#clip1_1637_2935)">
|
||||
<path d="M304.246 295.411V249.828C304.246 245.989 305.687 243.109 309.044 241.191L400.692 188.412C413.167 181.215 428.042 177.858 443.394 177.858C500.971 177.858 537.44 222.482 537.44 269.982C537.44 273.34 537.44 277.179 536.959 281.018L441.954 225.358C436.197 222 430.437 222 424.68 225.358L304.246 295.411ZM518.245 472.945V364.024C518.245 357.304 515.364 352.507 509.608 349.149L389.174 279.096L428.519 256.543C431.877 254.626 434.757 254.626 438.115 256.543L529.762 309.323C556.154 324.679 573.905 357.304 573.905 388.971C573.905 425.436 552.315 459.024 518.245 472.941V472.945ZM275.937 376.982L236.592 353.952C233.235 352.034 231.794 349.154 231.794 345.315V239.756C231.794 188.416 271.139 149.548 324.4 149.548C344.555 149.548 363.264 156.268 379.102 168.262L284.578 222.964C278.822 226.321 275.942 231.119 275.942 237.838V376.986L275.937 376.982ZM360.626 425.922L304.246 394.255V327.083L360.626 295.416L417.002 327.083V394.255L360.626 425.922ZM396.852 571.789C376.698 571.789 357.989 565.07 342.151 553.075L436.674 498.374C442.431 495.017 445.311 490.219 445.311 483.499V344.352L485.138 367.382C488.495 369.299 489.936 372.179 489.936 376.018V481.577C489.936 532.917 450.109 571.785 396.852 571.785V571.789ZM283.134 464.79L191.486 412.01C165.094 396.654 147.343 364.029 147.343 332.362C147.343 295.416 169.415 262.309 203.48 248.393V357.791C203.48 364.51 206.361 369.308 212.117 372.665L332.074 442.237L292.729 464.79C289.372 466.707 286.491 466.707 283.134 464.79ZM277.859 543.48C223.639 543.48 183.813 502.695 183.813 452.314C183.813 448.475 184.294 444.636 184.771 440.797L279.295 495.498C285.051 498.856 290.812 498.856 296.568 495.498L417.002 425.927V471.509C417.002 475.349 415.562 478.229 412.204 480.146L320.557 532.926C308.081 540.122 293.206 543.48 277.854 543.48H277.859ZM396.852 600.576C454.911 600.576 503.37 559.313 514.41 504.612C568.149 490.696 602.696 440.315 602.696 388.976C602.696 355.387 588.303 322.762 562.392 299.25C564.791 289.173 566.231 279.096 566.231 269.024C566.231 200.411 510.571 149.067 446.274 149.067C433.322 149.067 420.846 150.984 408.37 155.305C386.775 134.192 357.026 120.758 324.4 120.758C266.342 120.758 217.883 162.02 206.843 216.721C153.104 230.637 118.557 281.018 118.557 332.357C118.557 365.946 132.95 398.571 158.861 422.083C156.462 432.16 155.022 442.237 155.022 452.309C155.022 520.922 210.682 572.266 274.978 572.266C287.931 572.266 300.407 570.349 312.883 566.028C334.473 587.141 364.222 600.576 396.852 600.576Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1637_2935">
|
||||
<rect width="720" height="720" fill="white" transform="translate(0.606934 0.899902)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1637_2935">
|
||||
<rect width="484.139" height="479.818" fill="white" transform="translate(118.557 120.758)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,137 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="800"
|
||||
height="800"
|
||||
viewBox="0 0 211.66666 211.66666"
|
||||
version="1.1"
|
||||
id="svg924"
|
||||
inkscape:export-filename="/home/daniela/Documents/proxmox/Proxmox/Marketing/Logo/proxmox-logo/Screen/Full Lockup/stacked/proxmox-logo-color-stacked-bgblack.png"
|
||||
inkscape:export-xdpi="360"
|
||||
inkscape:export-ydpi="360"
|
||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
|
||||
sodipodi:docname="proxmox-logo-stacked-inverted-color-bgtrans.svg">
|
||||
<defs
|
||||
id="defs918" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.38489655"
|
||||
inkscape:cx="541.41545"
|
||||
inkscape:cy="189.70435"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
inkscape:document-rotation="0"
|
||||
showgrid="false"
|
||||
inkscape:pagecheckerboard="true"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="1720"
|
||||
inkscape:window-height="1343"
|
||||
inkscape:window-x="1720"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="0"
|
||||
units="px" />
|
||||
<metadata
|
||||
id="metadata921">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-11.346916,-31.368461)">
|
||||
<g
|
||||
id="g209">
|
||||
<g
|
||||
transform="matrix(0.84666672,0,0,0.84666672,544.05161,-814.30036)"
|
||||
id="g1288"
|
||||
style="fill:#ffffff">
|
||||
<g
|
||||
id="g1286"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:25.8336px;line-height:125%;font-family:Helion;-inkscape-font-specification:Helion;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none"
|
||||
transform="matrix(1.2435137,0,0,1.2435137,-791.06481,553.75862)">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1272"
|
||||
d="m 168.85142,500.9595 h -11.85747 c -0.46554,0.0129 -0.85842,0.18085 -1.17864,0.50374 -0.32023,0.32294 -0.48707,0.72335 -0.50052,1.20125 v 16.3783 c 1.27229,-0.0318 2.33145,-0.472 3.17749,-1.32073 0.84604,-0.84873 1.2852,-1.91543 1.3175,-3.2001 h 9.04164 c 1.28573,-0.0317 2.35673,-0.47199 3.21302,-1.32072 0.85624,-0.84873 1.30079,-1.91543 1.33364,-3.2001 v -4.49499 c -0.0328,-1.28573 -0.4774,-2.35673 -1.33364,-3.21301 -0.85629,-0.85625 -1.92729,-1.3008 -3.21302,-1.33364 z m -9.04164,9.60997 v -5.06332 h 7.93081 c 0.0463,-0.0231 0.23141,0.0232 0.55542,0.13885 0.32398,0.11573 0.50912,0.43972 0.55541,0.97198 v 2.81583 c 0.0231,0.0474 -0.0231,0.23681 -0.13885,0.56833 -0.11573,0.33154 -0.43972,0.52098 -0.97198,0.56833 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1274"
|
||||
d="m 194.05931,508.89031 v -3.38416 c -0.0318,-1.28573 -0.47201,-2.35673 -1.32072,-3.21301 -0.84875,-0.85625 -1.91545,-1.3008 -3.2001,-1.33364 h -11.85747 c -0.47684,0.0129 -0.87295,0.18085 -1.18833,0.50374 -0.31538,0.32294 -0.47899,0.72335 -0.49083,1.20125 v 16.3783 c 1.27336,-0.0318 2.33683,-0.472 3.1904,-1.32073 0.85357,-0.84873 1.29705,-1.91543 1.33042,-3.2001 v -1.13666 h 5.14082 l 2.60916,3.71999 c 0.4187,0.60063 0.94398,1.07208 1.57583,1.41437 0.63182,0.34229 1.33793,0.51667 2.11833,0.52313 0.37618,-5.4e-4 0.74107,-0.0447 1.09468,-0.1324 0.35358,-0.0877 0.68618,-0.21582 0.99781,-0.38427 l -3.64249,-5.19249 c 1.05592,-0.22872 1.92133,-0.74647 2.59625,-1.55322 0.67487,-0.80675 1.02362,-1.77011 1.04624,-2.8901 z m -13.53663,0.5425 v -3.92666 h 7.87915 c 0.0474,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33151,0.11573 0.52096,0.43972 0.56833,0.97198 v 1.705 c 0.0237,0.0463 -0.0237,0.23143 -0.14208,0.55541 -0.11842,0.324 -0.44995,0.50914 -0.99458,0.55542 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1276"
|
||||
d="m 210.1751,500.9595 h -9.01581 c -1.28467,0.0328 -2.35137,0.47739 -3.2001,1.33364 -0.84873,0.85628 -1.28897,1.92728 -1.32072,3.21301 v 9.01581 c 0.0317,1.28467 0.47199,2.35137 1.32072,3.2001 0.84873,0.84873 1.91543,1.28897 3.2001,1.32073 h 9.01581 c 1.28465,-0.0318 2.35135,-0.472 3.2001,-1.32073 0.84871,-0.84873 1.28895,-1.91543 1.32072,-3.2001 v -9.01581 c -0.0318,-1.28573 -0.47201,-2.35673 -1.32072,-3.21301 -0.84875,-0.85625 -1.91545,-1.3008 -3.2001,-1.33364 z m 0,12.4258 c 0.0237,0.0474 -0.0237,0.23681 -0.14208,0.56833 -0.11842,0.33153 -0.44995,0.52098 -0.99458,0.56833 h -6.74249 c -0.0474,0.0237 -0.23681,-0.0237 -0.56833,-0.14208 -0.33153,-0.1184 -0.52098,-0.44993 -0.56833,-0.99458 v -6.76832 c -0.0237,-0.0463 0.0237,-0.23141 0.14208,-0.55541 0.1184,-0.32398 0.44993,-0.50912 0.99458,-0.55542 h 6.74249 c 0.0473,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33151,0.11573 0.52096,0.43972 0.56833,0.97198 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1278"
|
||||
d="m 237.4767,502.25116 c -0.39183,-0.39179 -0.84822,-0.69964 -1.36917,-0.92354 -0.52099,-0.22387 -1.08071,-0.33797 -1.67916,-0.34229 -0.6367,0.005 -1.22333,0.1308 -1.75989,0.37781 -0.53659,0.24705 -1.00052,0.58611 -1.39177,1.01719 l -3.90082,4.28832 -3.92666,-4.28832 c -0.4015,-0.44238 -0.86434,-0.78467 -1.38854,-1.02688 -0.5242,-0.24217 -1.1033,-0.36487 -1.73729,-0.36812 -0.59847,0.004 -1.15819,0.11842 -1.67916,0.34229 -0.52097,0.2239 -0.97736,0.53175 -1.36916,0.92354 l 7.05248,7.74998 -7.05248,7.74998 c 0.3918,0.40419 0.84819,0.71957 1.36916,0.94615 0.52097,0.22657 1.08069,0.34175 1.67916,0.34552 0.62538,-0.005 1.20878,-0.13079 1.75021,-0.37782 0.54142,-0.24703 1.00857,-0.58609 1.40145,-1.01718 l 3.90083,-4.28832 3.90082,4.28832 c 0.39125,0.43109 0.85518,0.77015 1.39177,1.01718 0.53656,0.24703 1.12319,0.37297 1.75989,0.37782 0.59845,-0.004 1.15817,-0.11895 1.67916,-0.34552 0.52096,-0.22658 0.97734,-0.54196 1.36917,-0.94615 l -7.05249,-7.74998 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1280"
|
||||
d="m 260.98042,500.9595 h -2.84166 c -0.92947,0.0129 -1.75721,0.2648 -2.48322,0.75562 -0.72604,0.49085 -1.27607,1.14314 -1.6501,1.95687 l 0.0258,-0.0517 -2.66082,5.83832 -2.635,-5.83832 v 0.0517 c -0.36275,-0.81373 -0.90955,-1.46602 -1.64041,-1.95687 -0.73087,-0.49082 -1.56184,-0.74269 -2.49291,-0.75562 h -2.81583 c -0.48922,0.0129 -0.89286,0.18085 -1.21093,0.50374 -0.31808,0.32294 -0.48276,0.72335 -0.49406,1.20125 v 16.3783 c 1.27336,-0.0318 2.33683,-0.472 3.19041,-1.32073 0.85357,-0.84873 1.29704,-1.91543 1.33041,-3.2001 v -8.65414 c 0.002,-0.11785 0.0371,-0.2115 0.10656,-0.28094 0.0694,-0.0694 0.16307,-0.10493 0.28094,-0.10656 0.0673,0.002 0.13293,0.0237 0.19698,0.0646 0.064,0.0409 0.11032,0.0883 0.13885,0.14208 l 5.01166,11.05664 c 0.0947,0.19752 0.23464,0.3579 0.41979,0.48115 0.18512,0.12325 0.38964,0.18675 0.61354,0.19052 0.22226,-0.003 0.42354,-0.0619 0.60385,-0.1776 0.18028,-0.11571 0.32344,-0.27179 0.42948,-0.46823 L 257.4154,505.687 c 0.0393,-0.0538 0.0899,-0.10116 0.15177,-0.14208 0.0619,-0.0409 0.13184,-0.0624 0.2099,-0.0646 0.10547,0.002 0.19158,0.0371 0.25833,0.10656 0.0667,0.0694 0.10116,0.16309 0.10333,0.28094 v 8.65414 c 0.0333,1.28467 0.47682,2.35137 1.33042,3.2001 0.85355,0.84873 1.91702,1.28897 3.19041,1.32073 v -16.3783 c -0.0119,-0.4779 -0.17548,-0.87831 -0.49084,-1.20125 -0.3154,-0.32289 -0.71151,-0.49081 -1.18833,-0.50374 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1282"
|
||||
d="m 278.79561,500.9595 h -9.01581 c -1.28467,0.0328 -2.35137,0.47739 -3.20009,1.33364 -0.84874,0.85628 -1.28898,1.92728 -1.32073,3.21301 v 9.01581 c 0.0317,1.28467 0.47199,2.35137 1.32073,3.2001 0.84872,0.84873 1.91542,1.28897 3.20009,1.32073 h 9.01581 c 1.28466,-0.0318 2.35135,-0.472 3.2001,-1.32073 0.84871,-0.84873 1.28896,-1.91543 1.32073,-3.2001 v -9.01581 c -0.0318,-1.28573 -0.47202,-2.35673 -1.32073,-3.21301 -0.84875,-0.85625 -1.91544,-1.3008 -3.2001,-1.33364 z m 0,12.4258 c 0.0237,0.0474 -0.0237,0.23681 -0.14208,0.56833 -0.11842,0.33153 -0.44994,0.52098 -0.99458,0.56833 h -6.74248 c -0.0474,0.0237 -0.23681,-0.0237 -0.56834,-0.14208 -0.33153,-0.1184 -0.52097,-0.44993 -0.56833,-0.99458 v -6.76832 c -0.0237,-0.0463 0.0237,-0.23141 0.14209,-0.55541 0.11839,-0.32398 0.44992,-0.50912 0.99458,-0.55542 h 6.74248 c 0.0473,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33152,0.11573 0.52096,0.43972 0.56833,0.97198 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1284"
|
||||
d="m 306.0972,502.25116 c -0.39182,-0.39179 -0.84821,-0.69964 -1.36916,-0.92354 -0.52099,-0.22387 -1.08071,-0.33797 -1.67916,-0.34229 -0.6367,0.005 -1.22333,0.1308 -1.75989,0.37781 -0.5366,0.24705 -1.00052,0.58611 -1.39177,1.01719 l -3.90083,4.28832 -3.92665,-4.28832 c -0.4015,-0.44238 -0.86435,-0.78467 -1.38854,-1.02688 -0.52421,-0.24217 -1.1033,-0.36487 -1.73729,-0.36812 -0.59847,0.004 -1.15819,0.11842 -1.67916,0.34229 -0.52097,0.2239 -0.97736,0.53175 -1.36917,0.92354 l 7.05249,7.74998 -7.05249,7.74998 c 0.39181,0.40419 0.8482,0.71957 1.36917,0.94615 0.52097,0.22657 1.08069,0.34175 1.67916,0.34552 0.62538,-0.005 1.20878,-0.13079 1.7502,-0.37782 0.54142,-0.24703 1.00857,-0.58609 1.40146,-1.01718 l 3.90082,-4.28832 3.90083,4.28832 c 0.39125,0.43109 0.85517,0.77015 1.39177,1.01718 0.53656,0.24703 1.12319,0.37297 1.75989,0.37782 0.59845,-0.004 1.15817,-0.11895 1.67916,-0.34552 0.52095,-0.22658 0.97734,-0.54196 1.36916,-0.94615 l -7.05248,-7.74998 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1290"
|
||||
style="fill:#e57000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.730891"
|
||||
d="m 141.89758,116.88641 25.41237,27.92599 c -2.7927,2.88547 -6.70224,4.65495 -10.98527,4.65495 -4.56076,0 -8.56315,-1.95514 -11.35592,-5.02707 l -14.05575,-15.45172 -10.9846,-12.10215 10.9846,-12.00854 14.05575,-15.452532 c 2.79277,-3.071175 6.79516,-5.027074 11.35592,-5.027074 4.28303,0 8.19257,1.768759 10.98527,4.561525 z"
|
||||
sodipodi:nodetypes="ccscccccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1292"
|
||||
style="fill:#e57000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.730891"
|
||||
d="m 92.334245,116.8861 -25.41238,27.92603 c 2.7927,2.88547 6.702237,4.65495 10.985287,4.65495 4.560744,0 8.563138,-1.95514 11.355905,-5.02707 L 103.3188,128.98825 114.30338,116.8861 103.3188,104.87756 89.263057,89.425028 c -2.792767,-3.071215 -6.795161,-5.027074 -11.355905,-5.027074 -4.28305,0 -8.192587,1.768759 -10.985287,4.561526 z"
|
||||
sodipodi:nodetypes="ccscccccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1294"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.66712"
|
||||
d="m 127.12922,130.73289 -10.02585,-11.04587 -10.0263,11.04587 -23.195348,25.48982 c 2.548811,2.54902 6.118169,4.16327 10.025148,4.16327 4.16359,0 7.64778,-1.69975 10.28111,-4.58817 l 12.91539,-14.10405 12.82882,14.10405 c 2.5493,2.80293 6.20218,4.58817 10.36513,4.58817 3.9091,0 7.47768,-1.61425 10.02652,-4.16327 z"
|
||||
sodipodi:nodetypes="ccccscccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1296"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.66712"
|
||||
d="M 127.1286,103.03813 117.10275,114.084 107.07645,103.03813 83.881116,77.548321 c 2.548838,-2.548932 6.118156,-4.163226 10.025162,-4.163226 4.163603,0 7.647762,1.699732 10.281082,4.588143 l 12.91539,14.104094 12.82882,-14.104094 c 2.54934,-2.802999 6.20221,-4.588143 10.36513,-4.588143 3.90911,0 7.47772,1.614294 10.02653,4.163226 z"
|
||||
sodipodi:nodetypes="ccccscccscc" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="400" height="400" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.41508 17.2983L7.88484 12.7653L9.51146 18.9412L11.8745 18.2949L9.52018 9.32758L0.69527 6.93747L0.066864 9.35199L6.13926 11.0015L1.68806 15.5279L3.41508 17.2983Z" fill="#F34E3F"/>
|
||||
<path d="M16.3044 12.0436L18.6675 11.3973L16.3132 2.43003L7.48824 0.0399246L6.85984 2.45444L14.312 4.47881L16.3044 12.0436Z" fill="#F34E3F"/>
|
||||
<path d="M12.9126 15.4902L15.2756 14.8439L12.9213 5.87659L4.09639 3.48648L3.46799 5.901L10.9201 7.92537L12.9126 15.4902Z" fill="#F34E3F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 576 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" height="2500" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160"><g clip-rule="evenodd" fill-rule="evenodd"><path d="M0 116h160v28.996c0 8.287-6.722 15.004-14.998 15.004H14.998C6.716 160 0 153.293 0 144.996zm0 0h160v30H0z" fill="#1bb91f"/><path d="M83 70V0h-6v146h6V76h77v-6zM0 15.007C0 6.719 6.722 0 14.998 0h130.004C153.285 0 160 6.725 160 15.007V146H0z" fill="#3c3c3c"/></g></svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,22 @@
|
||||
# Ignore symlinks to avoid Prettier errors
|
||||
CLAUDE.md
|
||||
.github/copilot-instructions.md
|
||||
|
||||
# Ignore node_modules and dependencies
|
||||
node_modules/
|
||||
|
||||
# Ignore Terraform files (formatted by terraform fmt)
|
||||
*.tf
|
||||
*.hcl
|
||||
*.tfvars
|
||||
|
||||
# Ignore generated and temporary files
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfstate.lock.info
|
||||
|
||||
# Ignore other files that shouldn't be formatted
|
||||
bun.lock
|
||||
go.sum
|
||||
go.mod
|
||||
@@ -24,7 +24,7 @@ The Coder Registry is a collection of Terraform modules and templates for Coder
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
Install Bun:
|
||||
Install Bun (for formatting and scripts):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
@@ -89,7 +89,7 @@ Create `registry/[your-username]/README.md`:
|
||||
---
|
||||
display_name: "Your Name"
|
||||
bio: "Brief description of who you are and what you do"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
avatar: "./.images/avatar.png"
|
||||
github: "your-username"
|
||||
linkedin: "https://www.linkedin.com/in/your-username" # Optional
|
||||
website: "https://yourwebsite.com" # Optional
|
||||
@@ -102,7 +102,7 @@ status: "community"
|
||||
Brief description of who you are and what you do.
|
||||
```
|
||||
|
||||
> **Note**: The `avatar_url` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
|
||||
> **Note**: The `avatar` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
|
||||
|
||||
### 2. Generate Module Files
|
||||
|
||||
@@ -124,19 +124,23 @@ This script generates:
|
||||
- Accurate description and usage examples
|
||||
- Correct icon path (usually `../../../../.icons/your-icon.svg`)
|
||||
- Proper tags that describe your module
|
||||
3. **Create `main.test.ts`** to test your module
|
||||
3. **Create at least one `.tftest.hcl`** to test your module with `terraform test`
|
||||
4. **Add any scripts** or additional files your module needs
|
||||
|
||||
### 4. Test and Submit
|
||||
|
||||
```bash
|
||||
# Test your module
|
||||
bun test -t 'module-name'
|
||||
# Test your module (from the module directory)
|
||||
terraform init -upgrade
|
||||
terraform test -verbose
|
||||
|
||||
# Or run all tests in the repo
|
||||
./scripts/terraform_test_all.sh
|
||||
|
||||
# Format code
|
||||
bun fmt
|
||||
bun run fmt
|
||||
|
||||
# Commit and create PR
|
||||
# Commit and create PR (do not push to main directly)
|
||||
git add .
|
||||
git commit -m "Add [module-name] module"
|
||||
git push origin your-branch
|
||||
@@ -335,11 +339,12 @@ coder templates push test-[template-name] -d .
|
||||
### 2. Test Your Changes
|
||||
|
||||
```bash
|
||||
# Test a specific module
|
||||
bun test -t 'module-name'
|
||||
# Test a specific module (from the module directory)
|
||||
terraform init -upgrade
|
||||
terraform test -verbose
|
||||
|
||||
# Test all modules
|
||||
bun test
|
||||
./scripts/terraform_test_all.sh
|
||||
```
|
||||
|
||||
### 3. Maintain Backward Compatibility
|
||||
@@ -388,7 +393,7 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template=
|
||||
### Every Module Must Have
|
||||
|
||||
- `main.tf` - Terraform code
|
||||
- `main.test.ts` - Working tests
|
||||
- One or more `.tftest.hcl` files - Working tests with `terraform test`
|
||||
- `README.md` - Documentation with frontmatter
|
||||
|
||||
### Every Template Must Have
|
||||
@@ -488,6 +493,6 @@ When reporting bugs, include:
|
||||
2. **No tests** or broken tests
|
||||
3. **Hardcoded values** instead of variables
|
||||
4. **Breaking changes** without defaults
|
||||
5. **Not running** `bun fmt` before submitting
|
||||
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting
|
||||
|
||||
Happy contributing! 🚀
|
||||
|
||||
@@ -18,9 +18,9 @@ sudo apt install golang-go
|
||||
|
||||
Check that PRs have:
|
||||
|
||||
- [ ] All required files (`main.tf`, `main.test.ts`, `README.md`)
|
||||
- [ ] All required files (`main.tf`, `README.md`, at least one `.tftest.hcl`)
|
||||
- [ ] Proper frontmatter in README
|
||||
- [ ] Working tests (`bun test`)
|
||||
- [ ] Working tests (`terraform test`)
|
||||
- [ ] Formatted code (`bun run fmt`)
|
||||
- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`)
|
||||
|
||||
@@ -42,12 +42,58 @@ go build ./cmd/readmevalidation && ./readmevalidation
|
||||
|
||||
## Making a Release
|
||||
|
||||
### Create Release Tags
|
||||
### Automated Tag and Release Process
|
||||
|
||||
After merging a PR:
|
||||
After merging a PR, use the automated script to create and push release tags:
|
||||
|
||||
1. Get the new version from the PR (shown as `old → new`)
|
||||
2. Checkout the merge commit and create the tag:
|
||||
**Prerequisites:**
|
||||
|
||||
- Ensure all module versions are updated in their respective README files (the script uses this as the source of truth)
|
||||
- Make sure you have the necessary permissions to push tags to the repository
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Checkout the merge commit:**
|
||||
|
||||
```bash
|
||||
git checkout MERGE_COMMIT_ID
|
||||
```
|
||||
|
||||
2. **Run the tag release script:**
|
||||
|
||||
```bash
|
||||
./scripts/tag_release.sh
|
||||
```
|
||||
|
||||
3. **Review and confirm:**
|
||||
- The script will automatically scan all modules in the registry
|
||||
- It will detect which modules need version bumps by comparing README versions to existing tags
|
||||
- A summary will be displayed showing which modules need tagging
|
||||
- Confirm the list is correct when prompted
|
||||
|
||||
4. **Automatic tagging:**
|
||||
- After confirmation, the script will automatically create all necessary release tags
|
||||
- Tags will be pushed to the remote repository
|
||||
- The script operates on the current checked-out commit
|
||||
|
||||
**Example output:**
|
||||
|
||||
```text
|
||||
🔍 Scanning all modules for missing release tags...
|
||||
|
||||
📦 coder/code-server: v4.1.2 (needs tag)
|
||||
✅ coder/dotfiles: v1.0.5 (already tagged)
|
||||
|
||||
## Tags to be created:
|
||||
- `release/coder/code-server/v4.1.2`
|
||||
|
||||
❓ Do you want to proceed with creating and pushing these release tags?
|
||||
Continue? [y/N]: y
|
||||
```
|
||||
|
||||
### Manual Process (Fallback)
|
||||
|
||||
If the automated script fails, you can manually tag and release modules:
|
||||
|
||||
```bash
|
||||
# Checkout the merge commit
|
||||
@@ -81,7 +127,7 @@ tags: ["tag1", "tag2"]
|
||||
```yaml
|
||||
display_name: "Your Name"
|
||||
bio: "Brief description of who you are and what you do"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
avatar: "./.images/avatar.png"
|
||||
github: "username"
|
||||
linkedin: "https://www.linkedin.com/in/username" # Optional
|
||||
website: "https://yourwebsite.com" # Optional
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
"": {
|
||||
"name": "registry",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.18",
|
||||
"bun-types": "^1.2.18",
|
||||
"@types/bun": "^1.2.21",
|
||||
"bun-types": "^1.2.21",
|
||||
"dedent": "^1.6.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^16.0.0",
|
||||
"marked": "^16.2.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-sh": "^0.18.0",
|
||||
"prettier-plugin-terraform-formatter": "^1.2.1",
|
||||
@@ -21,7 +21,7 @@
|
||||
"packages": {
|
||||
"@reteps/dockerfmt": ["@reteps/dockerfmt@0.3.6", "", {}, "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
|
||||
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
|
||||
|
||||
"@types/node": ["@types/node@24.0.14", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw=="],
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"marked": ["marked@16.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MUKMXDjsD/eptB7GPzxo4xcnLS6oo7/RHimUMHEDRhUooPwmN9BEpMl7AEOJv3bmso169wHI2wUF9VQgL7zfmA=="],
|
||||
"marked": ["marked@16.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg=="],
|
||||
|
||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func validateCoderModuleReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
|
||||
errs = append(errs, baseErrs...)
|
||||
}
|
||||
|
||||
foundParagraph := false
|
||||
terraformCodeBlockCount := 0
|
||||
foundTerraformVersionRef := false
|
||||
|
||||
lineNum := 0
|
||||
isInsideCodeBlock := false
|
||||
isInsideTerraform := false
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for lineScanner.Scan() {
|
||||
lineNum++
|
||||
nextLine := lineScanner.Text()
|
||||
|
||||
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
|
||||
// need to check deeper if the first line isn't an h1.
|
||||
if lineNum == 1 {
|
||||
if !strings.HasPrefix(nextLine, "# ") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(nextLine, "```") {
|
||||
isInsideCodeBlock = !isInsideCodeBlock
|
||||
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
|
||||
if isInsideTerraform {
|
||||
terraformCodeBlockCount++
|
||||
}
|
||||
if strings.HasPrefix(nextLine, "```hcl") {
|
||||
errs = append(errs, xerrors.New("all hcl code blocks must be converted to tf"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isInsideCodeBlock {
|
||||
if isInsideTerraform {
|
||||
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
|
||||
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Code assumes that if we've reached this point, the only other options are:
|
||||
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
|
||||
trimmedLine := strings.TrimSpace(nextLine)
|
||||
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
|
||||
foundParagraph = foundParagraph || isParagraph
|
||||
}
|
||||
|
||||
if terraformCodeBlockCount == 0 {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
|
||||
} else {
|
||||
if terraformCodeBlockCount > 1 {
|
||||
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
|
||||
}
|
||||
if !foundTerraformVersionRef {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
|
||||
}
|
||||
}
|
||||
if !foundParagraph {
|
||||
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
|
||||
}
|
||||
if isInsideCodeBlock {
|
||||
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderModuleReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
for _, err := range validateCoderModuleReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
|
||||
errs = append(errs, fmErrs...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAllCoderModuleReadmes(resources []coderResourceReadme) error {
|
||||
var yamlValidationErrors []error
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderModuleReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAllCoderModules() error {
|
||||
const resourceType = "modules"
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = validateAllCoderModuleReadmes(resources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
|
||||
return nil
|
||||
}
|
||||
@@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
|
||||
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errs := validateCoderResourceReadmeBody(testBody)
|
||||
errs := validateCoderModuleReadmeBody(testBody)
|
||||
for _, e := range errs {
|
||||
t.Error(e)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -17,6 +15,7 @@ import (
|
||||
|
||||
var (
|
||||
supportedResourceTypes = []string{"modules", "templates"}
|
||||
operatingSystems = []string{"windows", "macos", "linux"}
|
||||
|
||||
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
|
||||
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
|
||||
@@ -25,11 +24,21 @@ var (
|
||||
)
|
||||
|
||||
type coderResourceFrontmatter struct {
|
||||
Description string `yaml:"description"`
|
||||
IconURL string `yaml:"icon"`
|
||||
DisplayName *string `yaml:"display_name"`
|
||||
Verified *bool `yaml:"verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Description string `yaml:"description"`
|
||||
IconURL string `yaml:"icon"`
|
||||
DisplayName *string `yaml:"display_name"`
|
||||
Verified *bool `yaml:"verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
OperatingSystems []string `yaml:"supported_os"`
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
|
||||
// list at runtime in the future, but this should be okay for now
|
||||
var supportedCoderResourceStructKeys = []string{
|
||||
"description", "icon", "display_name", "verified", "tags", "supported_os",
|
||||
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
|
||||
// make sure that the Registry Server is no longer checking this field.
|
||||
"maintainer_github",
|
||||
}
|
||||
|
||||
// coderResourceReadme represents a README describing a Terraform resource used
|
||||
@@ -42,6 +51,17 @@ type coderResourceReadme struct {
|
||||
frontmatter coderResourceFrontmatter
|
||||
}
|
||||
|
||||
func validateSupportedOperatingSystems(systems []string) []error {
|
||||
var errs []error
|
||||
for _, s := range systems {
|
||||
if slices.Contains(operatingSystems, s) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderResourceDisplayName(displayName *string) error {
|
||||
if displayName != nil && *displayName == "" {
|
||||
return xerrors.New("if defined, display_name must not be empty string")
|
||||
@@ -67,7 +87,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
|
||||
return []error{xerrors.New("icon URL cannot be empty")}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
|
||||
// If the URL does not have a relative path.
|
||||
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
|
||||
@@ -98,7 +118,7 @@ func validateCoderResourceTags(tags []string) error {
|
||||
|
||||
// All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they
|
||||
// can all be placed in the browser URL without issue.
|
||||
invalidTags := []string{}
|
||||
var invalidTags []string
|
||||
for _, t := range tags {
|
||||
if t != url.QueryEscape(t) {
|
||||
invalidTags = append(invalidTags, t)
|
||||
@@ -111,119 +131,50 @@ func validateCoderResourceTags(tags []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCoderResourceReadmeBody(body string) []error {
|
||||
func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
|
||||
errs = append(errs, validateReadmeBody(trimmed)...)
|
||||
|
||||
foundParagraph := false
|
||||
terraformCodeBlockCount := 0
|
||||
foundTerraformVersionRef := false
|
||||
|
||||
lineNum := 0
|
||||
isInsideCodeBlock := false
|
||||
isInsideTerraform := false
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for lineScanner.Scan() {
|
||||
lineNum++
|
||||
nextLine := lineScanner.Text()
|
||||
|
||||
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
|
||||
// need to check deeper if the first line isn't an h1.
|
||||
if lineNum == 1 {
|
||||
if !strings.HasPrefix(nextLine, "# ") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(nextLine, "```") {
|
||||
isInsideCodeBlock = !isInsideCodeBlock
|
||||
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
|
||||
if isInsideTerraform {
|
||||
terraformCodeBlockCount++
|
||||
}
|
||||
if strings.HasPrefix(nextLine, "```hcl") {
|
||||
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isInsideCodeBlock {
|
||||
if isInsideTerraform {
|
||||
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
|
||||
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Code assumes that if we've reached this point, the only other options are:
|
||||
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
|
||||
trimmedLine := strings.TrimSpace(nextLine)
|
||||
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
|
||||
foundParagraph = foundParagraph || isParagraph
|
||||
if err := validateCoderResourceDisplayName(fm.DisplayName); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceDescription(fm.Description); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceTags(fm.Tags); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
if terraformCodeBlockCount == 0 {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
|
||||
} else {
|
||||
if terraformCodeBlockCount > 1 {
|
||||
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
|
||||
}
|
||||
if !foundTerraformVersionRef {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
|
||||
}
|
||||
for _, err := range validateCoderResourceIconURL(fm.IconURL) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
if !foundParagraph {
|
||||
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
|
||||
}
|
||||
if isInsideCodeBlock {
|
||||
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
|
||||
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderResourceReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
|
||||
for _, err := range validateCoderResourceReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
|
||||
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
|
||||
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
|
||||
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) {
|
||||
fm, body, err := separateFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return coderResourceReadme{}, remapped
|
||||
}
|
||||
|
||||
yml := coderResourceFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return coderResourceReadme{
|
||||
@@ -234,13 +185,17 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
|
||||
}
|
||||
|
||||
resources := map[string]coderResourceReadme{}
|
||||
var yamlParsingErrs []error
|
||||
for _, rm := range rms {
|
||||
p, err := parseCoderResourceReadme(resourceType, rm)
|
||||
if err != nil {
|
||||
yamlParsingErrs = append(yamlParsingErrs, err)
|
||||
p, errs := parseCoderResourceReadme(resourceType, rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrs = append(yamlParsingErrs, errs...)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -253,30 +208,27 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
|
||||
}
|
||||
}
|
||||
|
||||
yamlValidationErrors := []error{}
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderResourceReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
var serialized []coderResourceReadme
|
||||
for _, r := range resources {
|
||||
serialized = append(serialized, r)
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
|
||||
return strings.Compare(r1.filePath, r2.filePath)
|
||||
})
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
// Todo: Need to beef up this function by grabbing each image/video URL from
|
||||
// the body's AST.
|
||||
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
|
||||
func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
|
||||
}
|
||||
|
||||
registryFiles, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -325,27 +277,3 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
|
||||
}
|
||||
return allReadmeFiles, nil
|
||||
}
|
||||
|
||||
func validateAllCoderResourceFilesOfType(resourceType string) error {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
|
||||
}
|
||||
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "rocessing README files", "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "rocessed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func validateCoderTemplateReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
|
||||
errs = append(errs, baseErrs...)
|
||||
}
|
||||
|
||||
var nextLine string
|
||||
foundParagraph := false
|
||||
isInsideCodeBlock := false
|
||||
lineNum := 0
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for lineScanner.Scan() {
|
||||
lineNum++
|
||||
nextLine = lineScanner.Text()
|
||||
|
||||
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
|
||||
// need to check deeper if the first line isn't an h1.
|
||||
if lineNum == 1 {
|
||||
if !strings.HasPrefix(nextLine, "# ") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(nextLine, "```") {
|
||||
isInsideCodeBlock = !isInsideCodeBlock
|
||||
if strings.HasPrefix(nextLine, "```hcl") {
|
||||
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
|
||||
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Code assumes that if we've reached this point, the only other options are:
|
||||
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
|
||||
trimmedLine := strings.TrimSpace(nextLine)
|
||||
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
|
||||
foundParagraph = foundParagraph || isParagraph
|
||||
}
|
||||
|
||||
if !foundParagraph {
|
||||
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
|
||||
}
|
||||
if isInsideCodeBlock {
|
||||
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderTemplateReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
for _, err := range validateCoderTemplateReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
|
||||
errs = append(errs, fmErrs...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error {
|
||||
var yamlValidationErrors []error
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderTemplateReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAllCoderTemplates() error {
|
||||
const resourceType = "templates"
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = validateAllCoderTemplateReadmes(resources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
|
||||
return nil
|
||||
}
|
||||
@@ -19,11 +19,16 @@ type contributorProfileFrontmatter struct {
|
||||
Bio string `yaml:"bio"`
|
||||
ContributorStatus string `yaml:"status"`
|
||||
AvatarURL *string `yaml:"avatar"`
|
||||
GithubUsername *string `yaml:"github"`
|
||||
LinkedinURL *string `yaml:"linkedin"`
|
||||
WebsiteURL *string `yaml:"website"`
|
||||
SupportEmail *string `yaml:"support_email"`
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
|
||||
// this list at runtime in the future, but this should be okay for now
|
||||
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
|
||||
|
||||
type contributorProfileReadme struct {
|
||||
frontmatter contributorProfileFrontmatter
|
||||
namespace string
|
||||
@@ -50,6 +55,22 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGithubUsername(username *string) error {
|
||||
if username == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := *username
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return xerrors.New("username must have non-whitespace characters")
|
||||
}
|
||||
if name != trimmed {
|
||||
return xerrors.Errorf("username %q has extra whitespace", trimmed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
|
||||
// that this is correct without actually sending an email, especially because some contributors are individual developers
|
||||
// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure.
|
||||
@@ -58,7 +79,7 @@ func validateContributorSupportEmail(email *string) []error {
|
||||
return nil
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
|
||||
username, server, ok := strings.Cut(*email, "@")
|
||||
if !ok {
|
||||
@@ -119,7 +140,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
// Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL.
|
||||
if _, err := url.Parse(*avatarURL); err != nil {
|
||||
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
|
||||
@@ -145,7 +166,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
}
|
||||
|
||||
func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
allErrs := []error{}
|
||||
var allErrs []error
|
||||
|
||||
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
@@ -153,6 +174,9 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
@@ -170,15 +194,24 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
|
||||
func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
|
||||
fm, _, err := separateFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
|
||||
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return contributorProfileReadme{}, remapped
|
||||
}
|
||||
|
||||
yml := contributorProfileFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
|
||||
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return contributorProfileReadme{
|
||||
@@ -190,11 +223,11 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
|
||||
|
||||
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
|
||||
profilesByNamespace := map[string]contributorProfileReadme{}
|
||||
yamlParsingErrors := []error{}
|
||||
var yamlParsingErrors []error
|
||||
for _, rm := range readmeEntries {
|
||||
p, err := parseContributorProfile(rm)
|
||||
if err != nil {
|
||||
yamlParsingErrors = append(yamlParsingErrors, err)
|
||||
p, errs := parseContributorProfile(rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrors = append(yamlParsingErrors, errs...)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -211,7 +244,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
|
||||
}
|
||||
}
|
||||
|
||||
yamlValidationErrors := []error{}
|
||||
var yamlValidationErrors []error
|
||||
for _, p := range profilesByNamespace {
|
||||
if errors := validateContributorReadme(p); len(errors) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errors...)
|
||||
@@ -234,8 +267,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allReadmeFiles := []readme{}
|
||||
errs := []error{}
|
||||
var allReadmeFiles []readme
|
||||
var errs []error
|
||||
dirPath := ""
|
||||
for _, e := range dirEntries {
|
||||
if !e.IsDir() {
|
||||
|
||||
@@ -31,7 +31,11 @@ func main() {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderResourceFilesOfType("modules")
|
||||
err = validateAllCoderModules()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderTemplates()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
@@ -39,7 +40,9 @@ const (
|
||||
|
||||
var (
|
||||
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
|
||||
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
|
||||
// Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for
|
||||
// validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the
|
||||
// range 1–6 to be valid). The function has checks to see if the level is correct.
|
||||
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
|
||||
)
|
||||
|
||||
@@ -168,3 +171,25 @@ func validateReadmeBody(body string) []error {
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error {
|
||||
if len(allowedKeys) == 0 {
|
||||
return []error{xerrors.New("Set of allowed keys is empty")}
|
||||
}
|
||||
|
||||
var key string
|
||||
var cutOk bool
|
||||
var line string
|
||||
|
||||
var errs []error
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(frontmatter))
|
||||
for lineScanner.Scan() {
|
||||
line = lineScanner.Text()
|
||||
key, _, cutOk = strings.Cut(line, ":")
|
||||
if !cutOk || slices.Contains(allowedKeys, key) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, xerrors.Errorf("detected unknown key %q", key))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -4,24 +4,32 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||
|
||||
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
||||
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
|
||||
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
|
||||
// expected file conventions
|
||||
func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
subDir, err := os.Stat(dirPath)
|
||||
resourceDir, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
|
||||
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow
|
||||
// specific rules.
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return []error{addFilePathToError(dirPath, err)}
|
||||
}
|
||||
}
|
||||
|
||||
if !subDir.IsDir() {
|
||||
if !resourceDir.IsDir() {
|
||||
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
|
||||
}
|
||||
|
||||
@@ -30,14 +38,21 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
return []error{addFilePathToError(dirPath, err)}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
for _, f := range files {
|
||||
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
|
||||
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
|
||||
// The .coder subdirectories are sometimes generated as part of our Bun tests. These subdirectories will never
|
||||
// be committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over
|
||||
// them.
|
||||
if !f.IsDir() || f.Name() == ".coder" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate module/template name
|
||||
if !validNameRe.MatchString(f.Name()) {
|
||||
errs = append(errs, xerrors.Errorf("%q: name contains invalid characters (only alphanumeric characters and hyphens are allowed)", path.Join(dirPath, f.Name())))
|
||||
continue
|
||||
}
|
||||
|
||||
resourceReadmePath := path.Join(dirPath, f.Name(), "README.md")
|
||||
if _, err := os.Stat(resourceReadmePath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -59,49 +74,59 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This
|
||||
// includes the top-level structure of the individual namespace directories.
|
||||
func validateRegistryDirectory() []error {
|
||||
userDirs, err := os.ReadDir(rootRegistryPath)
|
||||
namespaceDirs, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return []error{err}
|
||||
}
|
||||
|
||||
allErrs := []error{}
|
||||
for _, d := range userDirs {
|
||||
dirPath := path.Join(rootRegistryPath, d.Name())
|
||||
if !d.IsDir() {
|
||||
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
|
||||
var allErrs []error
|
||||
for _, nDir := range namespaceDirs {
|
||||
namespacePath := path.Join(rootRegistryPath, nDir.Name())
|
||||
if !nDir.IsDir() {
|
||||
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath))
|
||||
continue
|
||||
}
|
||||
|
||||
contributorReadmePath := path.Join(dirPath, "README.md")
|
||||
// Validate namespace name
|
||||
if !validNameRe.MatchString(nDir.Name()) {
|
||||
allErrs = append(allErrs, xerrors.Errorf("%q: namespace name contains invalid characters (only alphanumeric characters and hyphens are allowed)", namespacePath))
|
||||
continue
|
||||
}
|
||||
|
||||
contributorReadmePath := path.Join(namespacePath, "README.md")
|
||||
if _, err := os.Stat(contributorReadmePath); err != nil {
|
||||
allErrs = append(allErrs, err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(dirPath)
|
||||
files, err := os.ReadDir(namespacePath)
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
|
||||
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
|
||||
// level of each user namespace.
|
||||
if !f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
segment := f.Name()
|
||||
filePath := path.Join(dirPath, segment)
|
||||
filePath := path.Join(namespacePath, segment)
|
||||
|
||||
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
|
||||
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(supportedResourceTypes, segment) {
|
||||
continue
|
||||
}
|
||||
|
||||
if slices.Contains(supportedResourceTypes, segment) {
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
}
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +134,9 @@ func validateRegistryDirectory() []error {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
|
||||
// checks. It is NOT an exhaustive validation of the entire repo structure – it only checks the parts of the repo that
|
||||
// are relevant for the main validation steps
|
||||
func validateRepoStructure() error {
|
||||
var errs []error
|
||||
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
run "plan_with_required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "example-agent-id"
|
||||
}
|
||||
}
|
||||
|
||||
run "app_url_uses_port" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "example-agent-id"
|
||||
port = 19999
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999"
|
||||
error_message = "Expected MODULE_NAME app URL to include configured port"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "registry",
|
||||
"scripts": {
|
||||
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
|
||||
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",
|
||||
"fmt": "bun x prettier --write . && terraform fmt -recursive -diff",
|
||||
"fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff",
|
||||
"terraform-validate": "./scripts/terraform_validate.sh",
|
||||
"test": "bun test",
|
||||
"test": "./scripts/terraform_test_all.sh",
|
||||
"update-version": "./update-version.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.18",
|
||||
"bun-types": "^1.2.18",
|
||||
"@types/bun": "^1.2.21",
|
||||
"bun-types": "^1.2.21",
|
||||
"dedent": "^1.6.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^16.0.0",
|
||||
"marked": "^16.2.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-sh": "^0.18.0",
|
||||
"prettier-plugin-terraform-formatter": "^1.2.1"
|
||||
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,13 @@
|
||||
---
|
||||
display_name: "Jay Kumar"
|
||||
bio: "I'm a Software Engineer :)"
|
||||
avatar: "./.images/avatar.jpeg"
|
||||
github: "35C4n0r"
|
||||
linkedin: "https://www.linkedin.com/in/jaykum4r"
|
||||
support_email: "work.jaykumar@gmail.com"
|
||||
status: "community"
|
||||
---
|
||||
|
||||
# Your Name
|
||||
|
||||
I'm a Software Engineer :)
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
display_name: "tmux"
|
||||
description: "tmux with session persistence and plugins"
|
||||
icon: "../../../../.icons/tmux.svg"
|
||||
verified: false
|
||||
tags: ["tmux", "terminal", "persistent"]
|
||||
---
|
||||
|
||||
# tmux
|
||||
|
||||
This module provisions and configures [tmux](https://github.com/tmux/tmux) with session persistence and plugin support
|
||||
for a Coder agent. It automatically installs tmux, the Tmux Plugin Manager (TPM), and a set of useful plugins, and sets
|
||||
up a default or custom tmux configuration with session save/restore capabilities.
|
||||
|
||||
```tf
|
||||
module "tmux" {
|
||||
source = "registry.coder.com/anomaly/tmux/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Installs tmux if not already present
|
||||
- Installs TPM (Tmux Plugin Manager)
|
||||
- Configures tmux with plugins for sensible defaults, session persistence, and automation:
|
||||
- `tmux-plugins/tpm`
|
||||
- `tmux-plugins/tmux-sensible`
|
||||
- `tmux-plugins/tmux-resurrect`
|
||||
- `tmux-plugins/tmux-continuum`
|
||||
- Supports custom tmux configuration
|
||||
- Enables automatic session save
|
||||
- Configurable save interval
|
||||
- **Supports multiple named tmux sessions, each as a separate app in the Coder UI**
|
||||
|
||||
## Usage
|
||||
|
||||
```tf
|
||||
module "tmux" {
|
||||
source = "registry.coder.com/anomaly/tmux/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
tmux_config = "" # Optional: custom tmux.conf content
|
||||
save_interval = 1 # Optional: save interval in minutes
|
||||
sessions = ["default", "dev", "ops"] # Optional: list of tmux sessions
|
||||
order = 1 # Optional: UI order
|
||||
group = "Terminal" # Optional: UI group
|
||||
icon = "/icon/tmux.svg" # Optional: app icon
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Session Support
|
||||
|
||||
This module can provision multiple tmux sessions, each as a separate app in the Coder UI. Use the `sessions` variable to specify a list of session names. For each session, a `coder_app` is created, allowing you to launch or attach to that session directly from the UI.
|
||||
|
||||
- **sessions**: List of tmux session names (default: `["default"]`).
|
||||
|
||||
## How It Works
|
||||
|
||||
- **tmux Installation:**
|
||||
- Checks if tmux is installed; if not, installs it using the system's package manager (supports apt, yum, dnf,
|
||||
zypper, apk, brew).
|
||||
- **TPM Installation:**
|
||||
- Installs the Tmux Plugin Manager (TPM) to `~/.tmux/plugins/tpm` if not already present.
|
||||
- **tmux Configuration:**
|
||||
- If `tmux_config` is provided, writes it to `~/.tmux.conf`.
|
||||
- Otherwise, generates a default configuration with plugin support and session persistence (using tmux-resurrect and
|
||||
tmux-continuum).
|
||||
- Sets up key bindings for quick session save (`Ctrl+s`) and restore (`Ctrl+r`).
|
||||
- **Plugin Installation:**
|
||||
- Installs plugins via TPM.
|
||||
- **Session Persistence:**
|
||||
- Enables automatic session save/restore at the configured interval.
|
||||
|
||||
## Example
|
||||
|
||||
```tf
|
||||
module "tmux" {
|
||||
source = "registry.coder.com/anomaly/tmux/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = var.agent_id
|
||||
sessions = ["default", "dev", "anomaly"]
|
||||
tmux_config = <<-EOT
|
||||
set -g mouse on
|
||||
set -g history-limit 10000
|
||||
EOT
|
||||
group = "Terminal"
|
||||
order = 2
|
||||
}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you provide a custom `tmux_config`, it will completely replace the default configuration. Ensure you include plugin and TPM initialization lines if you want plugin support and session persistence.
|
||||
> The script will attempt to install dependencies using `sudo` where required.
|
||||
> If `git` is not installed, TPM installation will fail.
|
||||
> If you are using custom config, you'll be responsible for setting up persistence and plugins.
|
||||
> The `order`, `group`, and `icon` variables allow you to customize how tmux apps appear in the Coder UI.
|
||||
> In case of session restart or shh reconnection, the tmux session will be automatically restored :)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
findResourceInstance,
|
||||
} from "~test";
|
||||
import path from "path";
|
||||
|
||||
const moduleDir = path.resolve(__dirname);
|
||||
|
||||
const requiredVars = {
|
||||
agent_id: "dummy-agent-id",
|
||||
};
|
||||
|
||||
describe("tmux module", async () => {
|
||||
await runTerraformInit(moduleDir);
|
||||
|
||||
// 1. Required variables
|
||||
testRequiredVariables(moduleDir, requiredVars);
|
||||
|
||||
// 2. coder_script resource is created
|
||||
it("creates coder_script resource", async () => {
|
||||
const state = await runTerraformApply(moduleDir, requiredVars);
|
||||
const scriptResource = findResourceInstance(state, "coder_script");
|
||||
expect(scriptResource).toBeDefined();
|
||||
expect(scriptResource.agent_id).toBe(requiredVars.agent_id);
|
||||
|
||||
// check that the script contains expected lines
|
||||
expect(scriptResource.script).toContain("Installing tmux");
|
||||
expect(scriptResource.script).toContain(
|
||||
"Installing Tmux Plugin Manager (TPM)",
|
||||
);
|
||||
expect(scriptResource.script).toContain("tmux configuration created at");
|
||||
expect(scriptResource.script).toContain("✅ tmux setup complete!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "tmux_config" {
|
||||
type = string
|
||||
description = "Custom tmux configuration to apply."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "save_interval" {
|
||||
type = number
|
||||
description = "Save interval (in minutes)."
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/tmux.svg"
|
||||
}
|
||||
|
||||
variable "sessions" {
|
||||
type = list(string)
|
||||
description = "List of tmux sessions to create or start."
|
||||
default = ["default"]
|
||||
}
|
||||
|
||||
resource "coder_script" "tmux" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "tmux"
|
||||
icon = "/icon/terminal.svg"
|
||||
script = templatefile("${path.module}/scripts/run.sh", {
|
||||
TMUX_CONFIG = var.tmux_config
|
||||
SAVE_INTERVAL = var.save_interval
|
||||
})
|
||||
run_on_start = true
|
||||
run_on_stop = false
|
||||
}
|
||||
|
||||
resource "coder_app" "tmux_sessions" {
|
||||
for_each = toset(var.sessions)
|
||||
|
||||
agent_id = var.agent_id
|
||||
slug = "tmux-${each.value}"
|
||||
display_name = "tmux - ${each.value}"
|
||||
icon = var.icon
|
||||
order = var.order
|
||||
group = var.group
|
||||
|
||||
command = templatefile("${path.module}/scripts/start.sh", {
|
||||
SESSION_NAME = each.value
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
# Convert templated variables to shell variables
|
||||
SAVE_INTERVAL="${SAVE_INTERVAL}"
|
||||
TMUX_CONFIG="${TMUX_CONFIG}"
|
||||
|
||||
# Function to install tmux
|
||||
install_tmux() {
|
||||
printf "Checking for tmux installation\n"
|
||||
|
||||
if command -v tmux &> /dev/null; then
|
||||
printf "tmux is already installed \n\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf "Installing tmux \n\n"
|
||||
|
||||
# Detect package manager and install tmux
|
||||
if command -v apt-get &> /dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y tmux
|
||||
elif command -v yum &> /dev/null; then
|
||||
sudo yum install -y tmux
|
||||
elif command -v dnf &> /dev/null; then
|
||||
sudo dnf install -y tmux
|
||||
elif command -v zypper &> /dev/null; then
|
||||
sudo zypper install -y tmux
|
||||
elif command -v apk &> /dev/null; then
|
||||
sudo apk add tmux
|
||||
elif command -v brew &> /dev/null; then
|
||||
brew install tmux
|
||||
else
|
||||
printf "No supported package manager found. Please install tmux manually. \n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "tmux installed successfully \n"
|
||||
}
|
||||
|
||||
# Function to install Tmux Plugin Manager (TPM)
|
||||
install_tpm() {
|
||||
local tpm_dir="$HOME/.tmux/plugins/tpm"
|
||||
|
||||
if [ -d "$tpm_dir" ]; then
|
||||
printf "TPM is already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf "Installing Tmux Plugin Manager (TPM) \n"
|
||||
|
||||
# Create plugins directory
|
||||
mkdir -p "$HOME/.tmux/plugins"
|
||||
|
||||
# Clone TPM repository
|
||||
if command -v git &> /dev/null; then
|
||||
git clone https://github.com/tmux-plugins/tpm "$tpm_dir"
|
||||
printf "TPM installed successfully"
|
||||
else
|
||||
printf "Git is not installed. Please install git to use tmux plugins. \n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to create tmux configuration
|
||||
setup_tmux_config() {
|
||||
printf "Setting up tmux configuration \n"
|
||||
|
||||
local config_dir="$HOME/.tmux"
|
||||
local config_file="$HOME/.tmux.conf"
|
||||
|
||||
mkdir -p "$config_dir"
|
||||
|
||||
if [ -n "$TMUX_CONFIG" ]; then
|
||||
printf "$TMUX_CONFIG" > "$config_file"
|
||||
printf "$${BOLD}Custom tmux configuration applied at {$config_file} \n\n"
|
||||
else
|
||||
cat > "$config_file" << EOF
|
||||
# Tmux Configuration File
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# List of plugins
|
||||
set -g @plugin 'tmux-plugins/tpm'
|
||||
set -g @plugin 'tmux-plugins/tmux-sensible'
|
||||
set -g @plugin 'tmux-plugins/tmux-resurrect'
|
||||
set -g @plugin 'tmux-plugins/tmux-continuum'
|
||||
|
||||
# tmux-continuum configuration
|
||||
set -g @continuum-restore 'on'
|
||||
set -g @continuum-save-interval '$${SAVE_INTERVAL}'
|
||||
set -g @continuum-boot 'on'
|
||||
set -g status-right 'Continuum status: #{continuum_status}'
|
||||
|
||||
# =============================================================================
|
||||
# KEY BINDINGS FOR SESSION MANAGEMENT
|
||||
# =============================================================================
|
||||
|
||||
# Quick session save and restore
|
||||
bind C-s run-shell "~/.tmux/plugins/tmux-resurrect/scripts/save.sh"
|
||||
bind C-r run-shell "~/.tmux/plugins/tmux-resurrect/scripts/restore.sh"
|
||||
|
||||
# Initialize TMUX plugin manager (keep this line at the very bottom of tmux.conf)
|
||||
run '~/.tmux/plugins/tpm/tpm'
|
||||
EOF
|
||||
printf "tmux configuration created at {$config_file} \n\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to install tmux plugins
|
||||
install_plugins() {
|
||||
printf "Installing tmux plugins"
|
||||
|
||||
# Check if TPM is installed
|
||||
if [ ! -d "$HOME/.tmux/plugins/tpm" ]; then
|
||||
printf "TPM is not installed. Cannot install plugins. \n"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Install plugins using TPM
|
||||
"$HOME/.tmux/plugins/tpm/bin/install_plugins"
|
||||
|
||||
printf "tmux plugins installed successfully \n"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
printf "$${BOLD} 🛠️Setting up tmux with session persistence! \n\n"
|
||||
printf ""
|
||||
|
||||
# Install dependencies
|
||||
install_tmux
|
||||
install_tpm
|
||||
|
||||
# Setup tmux configuration
|
||||
setup_tmux_config
|
||||
|
||||
# Install plugins
|
||||
install_plugins
|
||||
|
||||
printf "$${BOLD}✅ tmux setup complete! \n\n"
|
||||
|
||||
printf "$${BOLD} Attempting to restore sessions\n"
|
||||
tmux new-session -d \; source-file ~/.tmux.conf \; run-shell '~/.tmux/plugins/tmux-resurrect/scripts/restore.sh'
|
||||
printf "$${BOLD} Sessions restored: -> %s\n" "$(tmux ls)"
|
||||
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Convert templated variables to shell variables
|
||||
SESSION_NAME='${SESSION_NAME}'
|
||||
|
||||
# Function to check if tmux is installed
|
||||
check_tmux() {
|
||||
if ! command -v tmux &> /dev/null; then
|
||||
echo "tmux is not installed. Please run the tmux setup script first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to handle a single session
|
||||
handle_session() {
|
||||
local session_name="$1"
|
||||
|
||||
# Check if the session exists
|
||||
if tmux has-session -t "$session_name" 2> /dev/null; then
|
||||
echo "Session '$session_name' exists, attaching to it..."
|
||||
tmux attach-session -t "$session_name"
|
||||
else
|
||||
echo "Session '$session_name' does not exist, creating it..."
|
||||
tmux new-session -d -s "$session_name"
|
||||
tmux attach-session -t "$session_name"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
# Check if tmux is installed
|
||||
check_tmux
|
||||
handle_session "${SESSION_NAME}"
|
||||
}
|
||||
|
||||
# Run the main function
|
||||
main
|
||||
@@ -5,7 +5,7 @@ github: coder
|
||||
avatar: ./.images/avatar.svg
|
||||
linkedin: https://www.linkedin.com/company/coderhq
|
||||
website: https://discord.gg/coder
|
||||
status: community
|
||||
status: official
|
||||
---
|
||||
|
||||
å
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
display_name: Auggie CLI
|
||||
icon: ../../../../.icons/auggie.svg
|
||||
description: Run Auggie CLI in your workspace for AI-powered coding assistance with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, auggie, ai, tasks, augment]
|
||||
---
|
||||
|
||||
# Auggie CLI
|
||||
|
||||
Run Auggie CLI in your workspace to access Augment's AI coding assistant with advanced context understanding and codebase integration. This module integrates with [AgentAPI](https://github.com/coder/agentapi).
|
||||
|
||||
```tf
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js and npm must be sourced/available before the auggie module installs** - ensure they are installed in your workspace image or via earlier provisioning steps
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template
|
||||
- **Augment session token for authentication (required for tasks). [Instructions](https://docs.augmentcode.com/cli/setup-auggie/authentication) to get the session token**
|
||||
|
||||
## Examples
|
||||
|
||||
### Usage with Tasks and Configuration
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial task prompt for Auggie CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Authentication
|
||||
augment_session_token = <<-EOF
|
||||
{"accessToken":"xxxx-yyyy-zzzz-jjjj","tenantURL":"https://d1.api.augmentcode.com/","scopes":["read","write"]}
|
||||
EOF # Required for tasks
|
||||
|
||||
# Version
|
||||
auggie_version = "0.3.0"
|
||||
|
||||
# Task configuration
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
continue_previous_conversation = true
|
||||
interaction_mode = "quiet"
|
||||
auggie_model = "gpt5"
|
||||
report_tasks = true
|
||||
|
||||
# MCP configuration for additional integrations
|
||||
mcp = <<-EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/project"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Workspace guidelines
|
||||
rules = <<-EOT
|
||||
# Project Guidelines
|
||||
|
||||
## Code Style
|
||||
- Use TypeScript for all new JavaScript files
|
||||
- Follow consistent naming conventions
|
||||
- Add comprehensive comments for complex logic
|
||||
|
||||
## Testing
|
||||
- Write unit tests for all new functions
|
||||
- Ensure test coverage above 80%
|
||||
|
||||
## Documentation
|
||||
- Update README.md for any new features
|
||||
- Document API changes in CHANGELOG.md
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
### Using Multiple MCP Configuration Files
|
||||
|
||||
```tf
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Multiple MCP configuration files
|
||||
mcp_files = [
|
||||
"/path/to/filesystem-mcp.json",
|
||||
"/path/to/database-mcp.json",
|
||||
"/path/to/api-mcp.json"
|
||||
]
|
||||
|
||||
mcp = <<-EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"Test MCP": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/home/coder/test-mcp",
|
||||
"run",
|
||||
"server.py"
|
||||
],
|
||||
"timeout": 600
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If you have any issues, please take a look at the log files below.
|
||||
|
||||
```bash
|
||||
# Installation logs
|
||||
cat ~/.auggie-module/install.log
|
||||
|
||||
# Startup logs
|
||||
cat ~/.auggie-module/agentapi-start.log
|
||||
|
||||
# Pre/post install script logs
|
||||
cat ~/.auggie-module/pre_install.log
|
||||
cat ~/.auggie-module/post_install.log
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To use tasks with Auggie CLI, create a `coder_parameter` named `"AI Prompt"` and pass its value to the auggie module's `ai_prompt` variable. The `folder` variable is required for the module to function correctly.
|
||||
|
||||
## References
|
||||
|
||||
- [Auggie CLI Reference](https://docs.augmentcode.com/cli/reference)
|
||||
- [Auggie CLI MCP Integration](https://docs.augmentcode.com/cli/integrations#mcp-integrations)
|
||||
- [Augment Code Documentation](https://docs.augmentcode.com/)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -0,0 +1,186 @@
|
||||
run "test_auggie_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/home/coder/projects"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.auggie_session_auth.name == "AUGMENT_SESSION_AUTH"
|
||||
error_message = "Auggie session auth environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.folder == "/home/coder/projects"
|
||||
error_message = "Folder variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_auggie == true
|
||||
error_message = "Install auggie should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_agentapi == true
|
||||
error_message = "Install agentapi should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_session_token" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
folder = "/home/coder/workspace"
|
||||
augment_session_token = "test-session-token-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.auggie_session_auth.value == "test-session-token-123"
|
||||
error_message = "Auggie session token value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
folder = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
auggie_model = "gpt-4"
|
||||
ai_prompt = "Help me write better code"
|
||||
interaction_mode = "compact"
|
||||
continue_previous_conversation = true
|
||||
install_auggie = false
|
||||
install_agentapi = false
|
||||
auggie_version = "1.0.0"
|
||||
agentapi_version = "v0.6.0"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon variable should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.auggie_model == "gpt-4"
|
||||
error_message = "Auggie model variable should be set to 'gpt-4'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.ai_prompt == "Help me write better code"
|
||||
error_message = "AI prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.interaction_mode == "compact"
|
||||
error_message = "Interaction mode should be set to 'compact'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.continue_previous_conversation == true
|
||||
error_message = "Continue previous conversation should be set to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.auggie_version == "1.0.0"
|
||||
error_message = "Auggie version should be set to '1.0.0'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agentapi_version == "v0.6.0"
|
||||
error_message = "AgentAPI version should be set to 'v0.6.0'"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_mcp_and_rules" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-mcp"
|
||||
folder = "/home/coder/mcp-test"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
test = {
|
||||
command = "test-server"
|
||||
args = ["--config", "test.json"]
|
||||
}
|
||||
}
|
||||
})
|
||||
mcp_files = [
|
||||
"/path/to/mcp1.json",
|
||||
"/path/to/mcp2.json"
|
||||
]
|
||||
rules = "# General coding rules\n- Write clean code\n- Add comments"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != ""
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.mcp_files) == 2
|
||||
error_message = "Should have 2 MCP files"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.rules != ""
|
||||
error_message = "Rules should be provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
folder = "/home/coder/scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_interaction_mode_validation" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-validation"
|
||||
folder = "/home/coder/test"
|
||||
interaction_mode = "print"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(["interactive", "print", "quiet", "compact"], var.interaction_mode)
|
||||
error_message = "Interaction mode should be one of the valid options"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipAuggieMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_auggie: props?.skipAuggieMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
folder: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipAuggieMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/auggie",
|
||||
content: await loadTestFile(import.meta.dir, "auggie-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("auggie", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("install-auggie-version", async () => {
|
||||
const version_to_install = "0.3.0";
|
||||
const { id } = await setup({
|
||||
skipAuggieMock: true,
|
||||
moduleVariables: {
|
||||
install_auggie: "true",
|
||||
auggie_version: version_to_install,
|
||||
pre_install_script: dedent`
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Install Node.js and npm via system package manager
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nodejs npm
|
||||
fi
|
||||
|
||||
# Configure npm to use user directory (avoids permission issues)
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
# Persist npm user directory configuration
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
||||
echo "prefix=$HOME/.npm-global" > ~/.npmrc
|
||||
`,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.auggie-module/install.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("check-latest-auggie-version-works", async () => {
|
||||
const { id } = await setup({
|
||||
skipAuggieMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_auggie: "true",
|
||||
pre_install_script: dedent`
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Install Node.js and npm via system package manager
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nodejs npm
|
||||
fi
|
||||
|
||||
# Configure npm to use user directory (avoids permission issues)
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
# Persist npm user directory configuration
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
||||
echo "prefix=$HOME/.npm-global" > ~/.npmrc
|
||||
`,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("auggie-session-token", async () => {
|
||||
const sessionToken = "test-session-token-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
augment_session_token: sessionToken,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`env | grep AUGMENT_SESSION_AUTH || echo "AUGMENT_SESSION_AUTH not found"`,
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("AUGMENT_SESSION_AUTH");
|
||||
});
|
||||
|
||||
test("auggie-mcp-config", async () => {
|
||||
const mcpConfig = JSON.stringify({
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: "test-cmd",
|
||||
type: "stdio",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp: mcpConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("--mcp-config");
|
||||
});
|
||||
|
||||
test("auggie-rules", async () => {
|
||||
const rules = "Always use TypeScript for new files";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
install_auggie: "false", // Don't need to install auggie to test rules file creation
|
||||
rules: rules,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const rulesFile = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.augment/rules.md",
|
||||
);
|
||||
expect(rulesFile).toContain(rules);
|
||||
});
|
||||
|
||||
test("auggie-ai-task-prompt", async () => {
|
||||
const prompt = "This is a task prompt for Auggie.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.auggie-module/agentapi-start.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
test("auggie-interaction-mode", async () => {
|
||||
const mode = "compact";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
interaction_mode: mode,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--${mode}`);
|
||||
});
|
||||
|
||||
test("auggie-model", async () => {
|
||||
const model = "gpt-4";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
auggie_model: model,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--model ${model}`);
|
||||
});
|
||||
|
||||
test("auggie-continue-previous-conversation", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue_previous_conversation: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain("--continue");
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'auggie-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'auggie-post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("auggie-pre-install-script");
|
||||
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("auggie-post-install-script");
|
||||
});
|
||||
|
||||
test("folder-variable", async () => {
|
||||
const folder = "/home/coder/auggie-test-folder";
|
||||
const { id } = await setup({
|
||||
skipAuggieMock: false,
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("coder-mcp-config-created", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
install_auggie: "false", // Don't need to install auggie to test MCP config creation
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const mcpConfig = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.augment/coder_mcp.json",
|
||||
);
|
||||
expect(mcpConfig).toContain("mcpServers");
|
||||
expect(mcpConfig).toContain("coder");
|
||||
expect(mcpConfig).toContain("CODER_MCP_APP_STATUS_SLUG");
|
||||
expect(mcpConfig).toContain("CODER_MCP_AI_AGENTAPI_URL");
|
||||
});
|
||||
|
||||
test("mcp-files-array", async () => {
|
||||
const mcpFiles = ["/path/to/mcp1.json", "/path/to/mcp2.json"];
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp_files: JSON.stringify(mcpFiles),
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain("mcp1.json");
|
||||
expect(startLog.stdout).toContain("mcp2.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/auggie.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Auggie in."
|
||||
}
|
||||
|
||||
variable "install_auggie" {
|
||||
type = bool
|
||||
description = "Whether to install Auggie CLI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "auggie_version" {
|
||||
type = string
|
||||
description = "The version of Auggie to install."
|
||||
default = "" # empty string means the latest available version
|
||||
validation {
|
||||
condition = var.auggie_version == "" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.auggie_version))
|
||||
error_message = "auggie_version must be empty (for latest) or a valid semantic version like 'v1.2.3' or '1.2.3'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.6.0"
|
||||
validation {
|
||||
condition = can(regex("^v[0-9]+\\.[0-9]+\\.[0-9]+", var.agentapi_version))
|
||||
error_message = "agentapi_version must be a valid semantic version starting with 'v', like 'v0.3.3'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Auggie."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Auggie."
|
||||
default = null
|
||||
}
|
||||
|
||||
# ----------------------------------------------
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for the Auggie CLI"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "MCP configuration as a JSON string for the auggie cli, check https://docs.augmentcode.com/cli/integrations#mcp-integrations"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "mcp_files" {
|
||||
type = list(string)
|
||||
description = "MCP configuration from a JSON file for the auggie cli, check https://docs.augmentcode.com/cli/integrations#mcp-integrations"
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "rules" {
|
||||
type = string
|
||||
description = "Additional rules to append to workspace guidelines (markdown format)"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "continue_previous_conversation" {
|
||||
type = bool
|
||||
description = "Whether to resume the previous conversation."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "interaction_mode" {
|
||||
type = string
|
||||
description = "Interaction mode with the Auggie CLI. Options: interactive, print, quiet, compact. https://docs.augmentcode.com/cli/reference#cli-flags"
|
||||
default = "interactive"
|
||||
validation {
|
||||
condition = contains(["interactive", "print", "quiet", "compact"], var.interaction_mode)
|
||||
error_message = "interaction_mode must be one of: interactive, print, quiet, compact."
|
||||
}
|
||||
}
|
||||
|
||||
variable "augment_session_token" {
|
||||
type = string
|
||||
description = "Auggie session token for authentication. https://docs.augmentcode.com/cli/setup-auggie/authentication"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "auggie_model" {
|
||||
type = string
|
||||
description = "The model to use for Auggie, find available models using auggie --list-models"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI via AgentAPI"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Auggie"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app"
|
||||
default = "Auggie"
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app"
|
||||
default = "Auggie CLI"
|
||||
}
|
||||
|
||||
resource "coder_env" "auggie_session_auth" {
|
||||
agent_id = var.agent_id
|
||||
name = "AUGMENT_SESSION_AUTH"
|
||||
value = var.augment_session_token
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "auggie"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".auggie-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_AUGGIE_START_DIRECTORY='${var.folder}' \
|
||||
ARG_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_MCP_FILES='${jsonencode(var.mcp_files)}' \
|
||||
ARG_AUGGIE_RULES='${base64encode(var.rules)}' \
|
||||
ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION='${var.continue_previous_conversation}' \
|
||||
ARG_AUGGIE_INTERACTION_MODE='${var.interaction_mode}' \
|
||||
ARG_AUGMENT_SESSION_AUTH='${var.augment_session_token}' \
|
||||
ARG_AUGGIE_MODEL='${var.auggie_model}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_AUGGIE_INSTALL='${var.install_auggie}' \
|
||||
ARG_AUGGIE_VERSION='${var.auggie_version}' \
|
||||
ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_AUGGIE_RULES='${base64encode(var.rules)}' \
|
||||
ARG_MCP_CONFIG='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_AUGGIE_INSTALL=${ARG_AUGGIE_INSTALL:-true}
|
||||
ARG_AUGGIE_VERSION=${ARG_AUGGIE_VERSION:-}
|
||||
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
|
||||
ARG_AUGGIE_RULES=$(echo -n "${ARG_AUGGIE_RULES:-}" | base64 -d)
|
||||
ARG_MCP_CONFIG=${ARG_MCP_CONFIG:-}
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "install auggie: %s\n" "$ARG_AUGGIE_INSTALL"
|
||||
printf "auggie_version: %s\n" "$ARG_AUGGIE_VERSION"
|
||||
printf "app_slug: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
|
||||
printf "rules: %s\n" "$ARG_AUGGIE_RULES"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
function check_dependencies() {
|
||||
if ! command_exists node; then
|
||||
printf "Error: Node.js is not installed. Please install Node.js manually or use the pre_install_script to install it.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command_exists npm; then
|
||||
printf "Error: npm is not installed. Please install npm manually or use the pre_install_script to install it.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "Node.js version: %s\n" "$(node --version)"
|
||||
printf "npm version: %s\n" "$(npm --version)"
|
||||
}
|
||||
|
||||
function install_auggie() {
|
||||
if [ "${ARG_AUGGIE_INSTALL}" = "true" ]; then
|
||||
check_dependencies
|
||||
|
||||
printf "%s Installing Auggie CLI\n" "${BOLD}"
|
||||
|
||||
NPM_GLOBAL_PREFIX="${HOME}/.npm-global"
|
||||
if [ ! -d "$NPM_GLOBAL_PREFIX" ]; then
|
||||
mkdir -p "$NPM_GLOBAL_PREFIX"
|
||||
fi
|
||||
|
||||
npm config set prefix "$NPM_GLOBAL_PREFIX"
|
||||
|
||||
export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
|
||||
|
||||
if [ -n "$ARG_AUGGIE_VERSION" ]; then
|
||||
npm install -g "@augmentcode/auggie@$ARG_AUGGIE_VERSION"
|
||||
else
|
||||
npm install -g "@augmentcode/auggie"
|
||||
fi
|
||||
|
||||
if ! grep -q "export PATH=\"\$HOME/.npm-global/bin:\$PATH\"" "$HOME/.bashrc"; then
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
printf "%s Successfully installed Auggie CLI. Version: %s\n" "${BOLD}" "$(auggie --version)"
|
||||
else
|
||||
printf "Skipping Auggie CLI installation (install_auggie=false)\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function create_coder_mcp() {
|
||||
AUGGIE_CODER_MCP_FILE="$HOME/.augment/coder_mcp.json"
|
||||
CODER_MCP=$(
|
||||
cat << EOF
|
||||
{
|
||||
"mcpServers":{
|
||||
"coder": {
|
||||
"args": ["exp", "mcp", "server"],
|
||||
"command": "coder",
|
||||
"env": {
|
||||
"CODER_MCP_APP_STATUS_SLUG": "${ARG_MCP_APP_STATUS_SLUG}",
|
||||
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284",
|
||||
"CODER_AGENT_URL": "${CODER_AGENT_URL:-}",
|
||||
"CODER_AGENT_TOKEN": "${CODER_AGENT_TOKEN:-}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
mkdir -p "$(dirname "$AUGGIE_CODER_MCP_FILE")"
|
||||
echo "$CODER_MCP" > "$AUGGIE_CODER_MCP_FILE"
|
||||
printf "Coder MCP config created at: %s\n" "$AUGGIE_CODER_MCP_FILE"
|
||||
}
|
||||
|
||||
function create_user_mcp() {
|
||||
if [ -n "$ARG_MCP_CONFIG" ]; then
|
||||
USER_MCP_CONFIG_FILE="$HOME/.augment/user_mcp.json"
|
||||
USER_MCP_CONTENT=$(echo -n "$ARG_MCP_CONFIG" | base64 -d)
|
||||
mkdir -p "$(dirname "$USER_MCP_CONFIG_FILE")"
|
||||
echo "$USER_MCP_CONTENT" > "$USER_MCP_CONFIG_FILE"
|
||||
printf "User MCP config created at: %s\n" "$USER_MCP_CONFIG_FILE"
|
||||
else
|
||||
printf "No user MCP config provided, skipping user MCP config creation.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function create_rules_file() {
|
||||
AUGGIE_RULES_FILE="$HOME/.augment/rules.md"
|
||||
if [ -n "$ARG_AUGGIE_RULES" ]; then
|
||||
mkdir -p "$(dirname "$AUGGIE_RULES_FILE")"
|
||||
echo -n "$ARG_AUGGIE_RULES" > "$AUGGIE_RULES_FILE"
|
||||
printf "Rules file created at: %s\n" "$AUGGIE_RULES_FILE"
|
||||
else
|
||||
printf "No rules provided, skipping rules file creation.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
install_auggie
|
||||
create_coder_mcp
|
||||
create_user_mcp
|
||||
create_rules_file
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
ARG_AUGGIE_START_DIRECTORY=${ARG_AUGGIE_START_DIRECTORY:-"$HOME"}
|
||||
ARG_TASK_PROMPT=$(echo -n "${ARG_TASK_PROMPT:-}" | base64 -d)
|
||||
ARG_MCP_FILES=${ARG_MCP_FILES:-[]}
|
||||
ARG_AUGGIE_RULES=${ARG_AUGGIE_RULES:-}
|
||||
ARG_AUGMENT_SESSION_AUTH=${ARG_AUGMENT_SESSION_AUTH:-}
|
||||
ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION=${ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION:-false}
|
||||
ARG_AUGGIE_INTERACTION_MODE=${ARG_AUGGIE_INTERACTION_MODE:-"interactive"}
|
||||
ARG_AUGGIE_MODEL=${ARG_AUGGIE_MODEL:-}
|
||||
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-false}
|
||||
|
||||
ARGS=()
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "auggie_start_directory: %s\n" "$ARG_AUGGIE_START_DIRECTORY"
|
||||
printf "task_prompt: %s\n" "$ARG_TASK_PROMPT"
|
||||
printf "mcp_files: %s\n" "$ARG_MCP_FILES"
|
||||
printf "auggie_rules: %s\n" "$ARG_AUGGIE_RULES"
|
||||
printf "continue_previous_conversation: %s\n" "$ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION"
|
||||
printf "auggie_interaction_mode: %s\n" "$ARG_AUGGIE_INTERACTION_MODE"
|
||||
printf "augment_session_auth: %s\n" "$ARG_AUGMENT_SESSION_AUTH"
|
||||
printf "auggie_model: %s\n" "$ARG_AUGGIE_MODEL"
|
||||
printf "report_tasks: %s\n" "$ARG_REPORT_TASKS"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
function validate_auggie_installation() {
|
||||
if command_exists auggie; then
|
||||
printf "Auggie is installed\n"
|
||||
else
|
||||
printf "Error: Auggie is not installed. Please enable install_auggie or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function build_auggie_args() {
|
||||
if [ -n "$ARG_AUGGIE_INTERACTION_MODE" ]; then
|
||||
if [ "$ARG_AUGGIE_INTERACTION_MODE" != "interactive" ]; then
|
||||
ARGS+=(--"$ARG_AUGGIE_INTERACTION_MODE")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_AUGGIE_MODEL" ]; then
|
||||
ARGS+=(--model "$ARG_AUGGIE_MODEL")
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.augment/user_mcp.json" ]; then
|
||||
ARGS+=(--mcp-config "$HOME/.augment/user_mcp.json")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_MCP_FILES" ] && [ "$ARG_MCP_FILES" != "[]" ]; then
|
||||
for file in $(echo "$ARG_MCP_FILES" | jq -r '.[]'); do
|
||||
ARGS+=(--mcp-config "$file")
|
||||
done
|
||||
fi
|
||||
|
||||
ARGS+=(--mcp-config "$HOME/.augment/coder_mcp.json")
|
||||
|
||||
if [ -n "$ARG_AUGGIE_RULES" ]; then
|
||||
AUGGIE_RULES_FILE="$HOME/.augment/rules.md"
|
||||
ARGS+=(--rules "$AUGGIE_RULES_FILE")
|
||||
fi
|
||||
|
||||
if [ "$ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION" == "true" ]; then
|
||||
ARGS+=(--continue)
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_TASK_PROMPT" ]; then
|
||||
if [ "$ARG_REPORT_TASKS" == "true" ]; then
|
||||
PROMPT="Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_TASK_PROMPT"
|
||||
else
|
||||
PROMPT="$ARG_TASK_PROMPT"
|
||||
fi
|
||||
ARGS+=(--instruction "$PROMPT")
|
||||
fi
|
||||
}
|
||||
|
||||
function start_agentapi_server() {
|
||||
mkdir -p "$ARG_AUGGIE_START_DIRECTORY"
|
||||
cd "$ARG_AUGGIE_START_DIRECTORY"
|
||||
ARGS+=(--workspace-root "$ARG_AUGGIE_START_DIRECTORY")
|
||||
printf "Running auggie with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
|
||||
agentapi server --term-width 67 --term-height 1190 -- auggie "${ARGS[@]}"
|
||||
}
|
||||
|
||||
validate_auggie_installation
|
||||
build_auggie_args
|
||||
start_agentapi_server
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "auggie version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - auggie-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
display_name: Codex CLI
|
||||
icon: ../../../../.icons/openai.svg
|
||||
description: Run Codex CLI in your workspace with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, codex, ai, openai, tasks]
|
||||
---
|
||||
|
||||
# Codex CLI
|
||||
|
||||
Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = var.openai_api_key
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template
|
||||
- OpenAI API key for Codex access
|
||||
|
||||
## Examples
|
||||
|
||||
### Run standalone
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
### Tasks integration
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial prompt for the Codex CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Custom configuration for full auto mode
|
||||
base_config_toml = <<-EOT
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified folder. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: The module installs Codex CLI and sets up the environment
|
||||
- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory
|
||||
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
|
||||
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Configuration
|
||||
|
||||
When no custom `base_config_toml` is provided, the module uses these secure defaults:
|
||||
|
||||
```toml
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
# ... other variables ...
|
||||
|
||||
# Override default configuration
|
||||
base_config_toml = <<-EOT
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
EOT
|
||||
|
||||
# Add extra MCP servers
|
||||
additional_mcp_servers = <<-EOT
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check installation and startup logs in `~/.codex-module/`
|
||||
- Ensure your OpenAI API key has access to the specified model
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
|
||||
> The module automatically configures Codex with your API key and model preferences.
|
||||
> folder is a required variable for the module to function correctly.
|
||||
|
||||
## References
|
||||
|
||||
- [Codex CLI Documentation](https://github.com/openai/codex)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCodexMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_codex: props?.skipCodexMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
codex_model: "gpt-4-turbo",
|
||||
folder: "/home/coder",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipCodexMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/codex",
|
||||
content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("codex", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("install-codex-version", async () => {
|
||||
const version_to_install = "0.10.0";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
codex_version: version_to_install,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/install.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("check-latest-codex-version-works", async () => {
|
||||
const { id } = await setup({
|
||||
skipCodexMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("base-config-toml", async () => {
|
||||
const baseConfig = dedent`
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[custom_section]
|
||||
new_feature = true
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain('sandbox_mode = "danger-full-access"');
|
||||
expect(resp).toContain('preferred_auth_method = "apikey"');
|
||||
expect(resp).toContain("[custom_section]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
});
|
||||
|
||||
test("codex-api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
openai_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("OpenAI API Key: Provided");
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("pre-install-script");
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("folder-variable", async () => {
|
||||
const folder = "/tmp/codex-test-folder";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: false,
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/install.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("additional-mcp-servers", async () => {
|
||||
const additional = dedent`
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
description = "GitHub integration"
|
||||
|
||||
[mcp_servers.FileSystem]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
||||
type = "stdio"
|
||||
description = "File system access"
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
additional_mcp_servers: additional,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain("[mcp_servers.GitHub]");
|
||||
expect(resp).toContain("[mcp_servers.FileSystem]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("GitHub integration");
|
||||
});
|
||||
|
||||
test("full-custom-config", async () => {
|
||||
const baseConfig = dedent`
|
||||
sandbox_mode = "read-only"
|
||||
approval_policy = "untrusted"
|
||||
preferred_auth_method = "chatgpt"
|
||||
custom_setting = "test-value"
|
||||
|
||||
[advanced_settings]
|
||||
timeout = 30000
|
||||
debug = true
|
||||
logging_level = "verbose"
|
||||
`.trim();
|
||||
|
||||
const additionalMCP = dedent`
|
||||
[mcp_servers.CustomTool]
|
||||
command = "/usr/local/bin/custom-tool"
|
||||
args = ["--serve", "--port", "8080"]
|
||||
type = "stdio"
|
||||
description = "Custom development tool"
|
||||
|
||||
[mcp_servers.DatabaseMCP]
|
||||
command = "python"
|
||||
args = ["-m", "database_mcp_server"]
|
||||
type = "stdio"
|
||||
description = "Database query interface"
|
||||
`.trim();
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
additional_mcp_servers: additionalMCP,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
|
||||
// Check base config
|
||||
expect(resp).toContain('sandbox_mode = "read-only"');
|
||||
expect(resp).toContain('preferred_auth_method = "chatgpt"');
|
||||
expect(resp).toContain('custom_setting = "test-value"');
|
||||
expect(resp).toContain("[advanced_settings]");
|
||||
expect(resp).toContain('logging_level = "verbose"');
|
||||
|
||||
// Check MCP servers
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("[mcp_servers.CustomTool]");
|
||||
expect(resp).toContain("[mcp_servers.DatabaseMCP]");
|
||||
expect(resp).toContain("Custom development tool");
|
||||
expect(resp).toContain("Database query interface");
|
||||
});
|
||||
|
||||
test("minimal-default-config", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
// No base_config_toml or additional_mcp_servers - should use defaults
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
|
||||
// Check default base config
|
||||
expect(resp).toContain('sandbox_mode = "workspace-write"');
|
||||
expect(resp).toContain('approval_policy = "never"');
|
||||
expect(resp).toContain("[sandbox_workspace_write]");
|
||||
expect(resp).toContain("network_access = true");
|
||||
|
||||
// Check only Coder MCP server is present
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("Report ALL tasks and statuses");
|
||||
|
||||
// Ensure no additional MCP servers
|
||||
const mcpServerCount = (resp.match(/\[mcp_servers\./g) || []).length;
|
||||
expect(mcpServerCount).toBe(1);
|
||||
});
|
||||
|
||||
test("codex-system-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
codex_system_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("codex-system-prompt-skip-append-if-exists", async () => {
|
||||
const prompt_1 = "This is a system prompt for Codex.";
|
||||
const prompt_2 = "This is a system prompt for Goose.";
|
||||
const prompt_3 = dedent`
|
||||
This is a system prompt for Codex.
|
||||
This is a system prompt for Gemini.
|
||||
`.trim();
|
||||
const pre_install_script = dedent`
|
||||
#!/bin/bash
|
||||
mkdir -p /home/coder/.codex
|
||||
echo -e "${prompt_3}" >> /home/coder/.codex/AGENTS.md
|
||||
`.trim();
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_2,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
|
||||
expect(resp).toContain(prompt_1);
|
||||
expect(resp).toContain(prompt_2);
|
||||
|
||||
// Re-run with a prompt that already exists, it should not append again
|
||||
const { id: id_2 } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_1,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id_2);
|
||||
const resp_2 = await readFileContainer(
|
||||
id_2,
|
||||
"/home/coder/.codex/AGENTS.md",
|
||||
);
|
||||
expect(resp_2).toContain(prompt_1);
|
||||
const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("codex-ai-task-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/agentapi-start.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
test("start-without-prompt", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
codex_system_prompt: "", // Explicitly disable system prompt
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const prompt = await execContainer(id, [
|
||||
"ls",
|
||||
"-l",
|
||||
"/home/coder/.codex/AGENTS.md",
|
||||
]);
|
||||
expect(prompt.exitCode).not.toBe(0);
|
||||
expect(prompt.stderr).toContain("No such file or directory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/openai.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Codex in."
|
||||
}
|
||||
|
||||
variable "install_codex" {
|
||||
type = bool
|
||||
description = "Whether to install Codex."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "codex_version" {
|
||||
type = string
|
||||
description = "The version of Codex to install."
|
||||
default = "" # empty string means the latest available version
|
||||
}
|
||||
|
||||
variable "base_config_toml" {
|
||||
type = string
|
||||
description = "Complete base TOML configuration for Codex (without mcp_servers section). If empty, uses minimal default configuration with workspace-write sandbox mode and never approval policy. For advanced options, see https://github.com/openai/codex/blob/main/codex-rs/config.md"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_mcp_servers" {
|
||||
type = string
|
||||
description = "Additional MCP servers configuration in TOML format. These will be merged with the required Coder MCP server in the [mcp_servers] section."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "openai_api_key" {
|
||||
type = string
|
||||
description = "OpenAI API key for Codex CLI"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
type = string
|
||||
description = "The model for Codex to use. Defaults to gpt-5."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Codex."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Codex."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Initial task prompt for Codex CLI when launched via Tasks"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "codex_system_prompt" {
|
||||
type = string
|
||||
description = "System instructions written to AGENTS.md in the ~/.codex directory"
|
||||
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
||||
}
|
||||
|
||||
resource "coder_env" "openai_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "OPENAI_API_KEY"
|
||||
value = var.openai_api_key
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "codex"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".codex-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Codex"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Codex CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_CODEX_MODEL='${var.codex_model}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL='${var.install_codex}' \
|
||||
ARG_CODEX_VERSION='${var.codex_version}' \
|
||||
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
|
||||
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/bin/bash
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d)
|
||||
ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d)
|
||||
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
|
||||
|
||||
echo "=== Codex Module Configuration ==="
|
||||
printf "Install Codex: %s\n" "$ARG_INSTALL"
|
||||
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
|
||||
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
|
||||
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
|
||||
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
echo "======================================"
|
||||
|
||||
set +o nounset
|
||||
|
||||
function install_node() {
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function install_codex() {
|
||||
if [ "${ARG_INSTALL}" = "true" ]; then
|
||||
install_node
|
||||
|
||||
if ! command_exists nvm; then
|
||||
printf "which node: %s\n" "$(which node)"
|
||||
printf "which npm: %s\n" "$(which npm)"
|
||||
|
||||
mkdir -p "$HOME"/.npm-global
|
||||
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Codex CLI\n" "${BOLD}"
|
||||
|
||||
if [ -n "$ARG_CODEX_VERSION" ]; then
|
||||
npm install -g "@openai/codex@$ARG_CODEX_VERSION"
|
||||
else
|
||||
npm install -g "@openai/codex"
|
||||
fi
|
||||
printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
write_minimal_default_config() {
|
||||
local config_path="$1"
|
||||
cat << EOF > "$config_path"
|
||||
# Minimal Default Codex Configuration
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
append_mcp_servers_section() {
|
||||
local config_path="$1"
|
||||
|
||||
cat << EOF >> "$config_path"
|
||||
|
||||
# MCP Servers Configuration
|
||||
[mcp_servers.Coder]
|
||||
command = "coder"
|
||||
args = ["exp", "mcp", "server"]
|
||||
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284", "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
|
||||
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
|
||||
type = "stdio"
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then
|
||||
printf "Adding additional MCP servers\n"
|
||||
echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path"
|
||||
fi
|
||||
}
|
||||
|
||||
function populate_config_toml() {
|
||||
CONFIG_PATH="$HOME/.codex/config.toml"
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||
|
||||
if [ -n "$ARG_BASE_CONFIG_TOML" ]; then
|
||||
printf "Using provided base configuration\n"
|
||||
echo "$ARG_BASE_CONFIG_TOML" > "$CONFIG_PATH"
|
||||
else
|
||||
printf "Using minimal default configuration\n"
|
||||
write_minimal_default_config "$CONFIG_PATH"
|
||||
fi
|
||||
|
||||
append_mcp_servers_section "$CONFIG_PATH"
|
||||
}
|
||||
|
||||
function add_instruction_prompt_if_exists() {
|
||||
if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then
|
||||
AGENTS_PATH="$HOME/.codex/AGENTS.md"
|
||||
printf "Creating AGENTS.md in .codex directory: %s\\n" "${AGENTS_PATH}"
|
||||
|
||||
mkdir -p "$HOME/.codex"
|
||||
|
||||
if [ -f "${AGENTS_PATH}" ] && grep -Fq "${ARG_CODEX_INSTRUCTION_PROMPT}" "${AGENTS_PATH}"; then
|
||||
printf "AGENTS.md already contains the instruction prompt. Skipping append.\n"
|
||||
else
|
||||
printf "Appending instruction prompt to AGENTS.md in .codex directory\n"
|
||||
echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> "${AGENTS_PATH}"
|
||||
fi
|
||||
|
||||
if [ ! -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Creating start directory '%s'\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
printf "AGENTS.md instruction prompt is not set.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
install_codex
|
||||
codex --version
|
||||
populate_config_toml
|
||||
add_instruction_prompt_if_exists
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
printf "Version: %s\n" "$(codex --version)"
|
||||
set -o nounset
|
||||
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
|
||||
|
||||
echo "=== Codex Launch Configuration ==="
|
||||
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
|
||||
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
|
||||
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
echo "======================================"
|
||||
set +o nounset
|
||||
CODEX_ARGS=()
|
||||
|
||||
if command_exists codex; then
|
||||
printf "Codex is installed\n"
|
||||
else
|
||||
printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_CODEX_MODEL" ]; then
|
||||
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
|
||||
printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT"
|
||||
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
CODEX_ARGS+=("$PROMPT")
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
fi
|
||||
|
||||
# Terminal dimensions optimized for Coder Tasks UI sidebar:
|
||||
# - Width 67: fits comfortably in sidebar
|
||||
# - Height 1190: adjusted due to Codex terminal height bug
|
||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "codex version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - codex-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
display_name: Cursor CLI
|
||||
icon: ../../../../.icons/cursor.svg
|
||||
description: Run Cursor Agent CLI in your workspace for AI pair programming
|
||||
verified: true
|
||||
tags: [agent, cursor, ai, tasks]
|
||||
---
|
||||
|
||||
# Cursor CLI
|
||||
|
||||
Run the Cursor Agent CLI in your workspace for interactive coding assistance and automated task execution.
|
||||
|
||||
```tf
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Basic setup
|
||||
|
||||
A full example with MCP, rules, and pre/post install scripts:
|
||||
|
||||
```tf
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Build a Minesweeper in Python."
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Optional
|
||||
install_cursor_cli = true
|
||||
force = true
|
||||
model = "gpt-5"
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
api_key = "xxxx-xxxx-xxxx" # Required while using tasks, see note below
|
||||
|
||||
# Minimal MCP server (writes `folder/.cursor/mcp.json`):
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
playwright = {
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"]
|
||||
}
|
||||
desktop-commander = {
|
||||
command = "npx"
|
||||
args = ["-y", "@wonderwhy-er/desktop-commander"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Use a pre_install_script to install the CLI
|
||||
pre_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
EOT
|
||||
|
||||
# Use post_install_script to wait for the repo to be ready
|
||||
post_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TARGET="$${FOLDER}/.git/config"
|
||||
echo "[cursor-cli] waiting for $${TARGET}..."
|
||||
for i in $(seq 1 600); do
|
||||
[ -f "$TARGET" ] && { echo "ready"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "timeout waiting for $${TARGET}" >&2
|
||||
EOT
|
||||
|
||||
# Provide a map of file name to content; files are written to `folder/.cursor/rules/<name>`.
|
||||
rules_files = {
|
||||
"python.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
|
||||
"frontend.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> A `.cursor` directory will be created in the specified `folder`, containing the MCP configuration, rules.
|
||||
> To use this module with tasks, please pass the API Key obtained from Cursor to the `api_key` variable. To obtain the api key follow the instructions [here](https://docs.cursor.com/en/cli/reference/authentication#step-1%3A-generate-an-api-key)
|
||||
|
||||
## References
|
||||
|
||||
- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview`
|
||||
- For MCP project config, see `https://docs.cursor.com/en/context/mcp#using-mcp-json`. This module writes your `mcp_json` into `folder/.cursor/mcp.json`.
|
||||
- For Rules, see `https://docs.cursor.com/en/context/rules#project-rules`. Provide `rules_files` (map of file name to content) to populate `folder/.cursor/rules/`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image)
|
||||
- Logs are written to `~/.cursor-cli-module/`
|
||||
@@ -0,0 +1,152 @@
|
||||
run "test_cursor_cli_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/home/coder/projects"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG"
|
||||
error_message = "Status slug environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.value == "cursorcli"
|
||||
error_message = "Status slug value should be 'cursorcli'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.folder == "/home/coder/projects"
|
||||
error_message = "Folder variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
folder = "/home/coder/workspace"
|
||||
api_key = "test-api-key-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].name == "CURSOR_API_KEY"
|
||||
error_message = "Cursor API key environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Cursor API key value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
folder = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
model = "sonnet-4"
|
||||
ai_prompt = "Help me write better code"
|
||||
force = false
|
||||
install_cursor_cli = false
|
||||
install_agentapi = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon variable should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.model == "sonnet-4"
|
||||
error_message = "Model variable should be set to 'sonnet-4'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.ai_prompt == "Help me write better code"
|
||||
error_message = "AI prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.force == false
|
||||
error_message = "Force variable should be set to false"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_mcp_and_rules" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-mcp"
|
||||
folder = "/home/coder/mcp-test"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
test = {
|
||||
command = "test-server"
|
||||
args = ["--config", "test.json"]
|
||||
}
|
||||
}
|
||||
})
|
||||
rules_files = {
|
||||
"general.md" = "# General coding rules\n- Write clean code\n- Add comments"
|
||||
"security.md" = "# Security rules\n- Never commit secrets\n- Validate inputs"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != null
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.rules_files != null
|
||||
error_message = "Rules files should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.rules_files) == 2
|
||||
error_message = "Should have 2 rules files"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
folder = "/home/coder/scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
setDefaultTimeout,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { execContainer, runTerraformInit, writeFileContainer } from "~test";
|
||||
import {
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
loadTestFile,
|
||||
setup as setupUtil,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import {
|
||||
setupContainer,
|
||||
writeExecutable,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFns: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (fn: () => Promise<void>) => cleanupFns.push(fn);
|
||||
|
||||
afterEach(async () => {
|
||||
const fns = cleanupFns.slice().reverse();
|
||||
cleanupFns = [];
|
||||
for (const fn of fns) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCursorCliMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
enable_agentapi: "true",
|
||||
install_cursor_cli: props?.skipCursorCliMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
folder: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipCursorCliMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/cursor-agent",
|
||||
content: await loadTestFile(import.meta.dir, "cursor-cli-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(180 * 1000);
|
||||
|
||||
describe("cursor-cli", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("agentapi-happy-path", async () => {
|
||||
const { id } = await setup({});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("agentapi-mcp-json", async () => {
|
||||
const mcpJson =
|
||||
'{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}';
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp: mcpJson,
|
||||
},
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const mcpContent = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/mcp.json'`,
|
||||
]);
|
||||
expect(mcpContent.exitCode).toBe(0);
|
||||
expect(mcpContent.stdout).toContain("mcpServers");
|
||||
expect(mcpContent.stdout).toContain("test");
|
||||
expect(mcpContent.stdout).toContain("test-cmd");
|
||||
expect(mcpContent.stdout).toContain("/tmp/mcp-hack.sh");
|
||||
expect(mcpContent.stdout).toContain("coder");
|
||||
});
|
||||
|
||||
test("agentapi-rules-files", async () => {
|
||||
const rulesContent = "Always use TypeScript";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
rules_files: JSON.stringify({ "typescript.md": rulesContent }),
|
||||
},
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const rulesFile = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/rules/typescript.md'`,
|
||||
]);
|
||||
expect(rulesFile.exitCode).toBe(0);
|
||||
expect(rulesFile.stdout).toContain(rulesContent);
|
||||
});
|
||||
|
||||
test("agentapi-api-key", async () => {
|
||||
const apiKey = "test-cursor-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
api_key: apiKey,
|
||||
},
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`env | grep CURSOR_API_KEY || echo "CURSOR_API_KEY not found"`,
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("CURSOR_API_KEY");
|
||||
});
|
||||
|
||||
test("agentapi-model-and-force-flags", async () => {
|
||||
const model = "sonnet-4";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
model: model,
|
||||
force: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`-m ${model}`);
|
||||
expect(startLog.stdout).toContain("-f");
|
||||
expect(startLog.stdout).toContain("test prompt");
|
||||
});
|
||||
|
||||
test("agentapi-pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'",
|
||||
},
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const preInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/pre_install.log || true",
|
||||
]);
|
||||
expect(preInstallLog.stdout).toContain("cursor-pre-install-script");
|
||||
|
||||
const postInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/post_install.log || true",
|
||||
]);
|
||||
expect(postInstallLog.stdout).toContain("cursor-post-install-script");
|
||||
});
|
||||
|
||||
test("agentapi-folder-variable", async () => {
|
||||
const folder = "/tmp/cursor-test-folder";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder: folder,
|
||||
},
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const installLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/install.log || true",
|
||||
]);
|
||||
expect(installLog.stdout).toContain(folder);
|
||||
});
|
||||
|
||||
test("install-test-cursor-cli-latest", async () => {
|
||||
const { id } = await setup({
|
||||
skipCursorCliMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/cursor.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Cursor CLI in."
|
||||
}
|
||||
|
||||
variable "install_cursor_cli" {
|
||||
type = bool
|
||||
description = "Whether to install Cursor CLI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
}
|
||||
|
||||
variable "force" {
|
||||
type = bool
|
||||
description = "Force allow commands unless explicitly denied"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "model" {
|
||||
type = string
|
||||
description = "Model to use (e.g., sonnet-4, sonnet-4-thinking, gpt-5)"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "AI prompt/task passed to cursor-agent."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "api_key" {
|
||||
type = string
|
||||
description = "API key for Cursor CLI."
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "Workspace-specific MCP JSON to write to folder/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "rules_files" {
|
||||
type = map(string)
|
||||
description = "Optional map of rule file name to content. Files will be written to folder/.cursor/rules/<name>. See https://docs.cursor.com/en/context/rules#project-rules"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run before installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run after installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "cursorcli"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".cursor-cli-module"
|
||||
}
|
||||
|
||||
# Expose status slug and API key to the agent environment
|
||||
resource "coder_env" "status_slug" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = local.app_slug
|
||||
}
|
||||
|
||||
resource "coder_env" "cursor_api_key" {
|
||||
count = var.api_key != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CURSOR_API_KEY"
|
||||
value = var.api_key
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Cursor CLI"
|
||||
cli_app_slug = local.app_slug
|
||||
cli_app_display_name = "Cursor CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_FORCE='${var.force}' \
|
||||
ARG_MODEL='${var.model}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL='${var.install_cursor_cli}' \
|
||||
ARG_WORKSPACE_MCP_JSON='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
ARG_WORKSPACE_RULES_JSON='${var.rules_files != null ? base64encode(jsonencode(var.rules_files)) : ""}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Inputs
|
||||
ARG_INSTALL=${ARG_INSTALL:-true}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG:-}
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
ARG_WORKSPACE_MCP_JSON=$(echo -n "$ARG_WORKSPACE_MCP_JSON" | base64 -d)
|
||||
ARG_WORKSPACE_RULES_JSON=$(echo -n "$ARG_WORKSPACE_RULES_JSON" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "coder_mcp_app_status_slug: $ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Install Cursor via official installer if requested
|
||||
function install_cursor_cli() {
|
||||
if [ "$ARG_INSTALL" = "true" ]; then
|
||||
echo "Installing Cursor via official installer..."
|
||||
set +e
|
||||
curl https://cursor.com/install -fsS | bash 2>&1
|
||||
CURL_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Cursor installer failed with exit code $CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable; create stable symlink to cursor-agent
|
||||
CANDIDATES=(
|
||||
"$(command -v cursor-agent || true)"
|
||||
"$HOME/.cursor/bin/cursor-agent"
|
||||
)
|
||||
FOUND_BIN=""
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
if [ -n "$c" ] && [ -x "$c" ]; then
|
||||
FOUND_BIN="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
if [ -n "$FOUND_BIN" ]; then
|
||||
ln -sf "$FOUND_BIN" "$HOME/.local/bin/cursor-agent"
|
||||
fi
|
||||
echo "Installed cursor-agent at: $(command -v cursor-agent || true) (resolved: $FOUND_BIN)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Write MCP config to user's home if provided (ARG_FOLDER/.cursor/mcp.json)
|
||||
function write_mcp_config() {
|
||||
TARGET_DIR="$ARG_FOLDER/.cursor"
|
||||
TARGET_FILE="$TARGET_DIR/mcp.json"
|
||||
mkdir -p "$TARGET_DIR"
|
||||
|
||||
CURSOR_MCP_HACK_SCRIPT=$(
|
||||
cat << EOF
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# --- Set environment variables ---
|
||||
export CODER_MCP_APP_STATUS_SLUG="${ARG_CODER_MCP_APP_STATUS_SLUG}"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
export CODER_AGENT_URL="${CODER_AGENT_URL}"
|
||||
export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}"
|
||||
|
||||
# --- Launch the MCP server ---
|
||||
exec coder exp mcp server
|
||||
EOF
|
||||
)
|
||||
echo "$CURSOR_MCP_HACK_SCRIPT" > "/tmp/mcp-hack.sh"
|
||||
chmod +x /tmp/mcp-hack.sh
|
||||
|
||||
CODER_MCP=$(
|
||||
cat << EOF
|
||||
{
|
||||
"coder": {
|
||||
"args": [],
|
||||
"command": "/tmp/mcp-hack.sh",
|
||||
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
|
||||
"name": "Coder",
|
||||
"timeout": 3000,
|
||||
"type": "stdio",
|
||||
"trust": true
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "${ARG_WORKSPACE_MCP_JSON:-{}}" | jq --argjson base "$CODER_MCP" \
|
||||
'.mcpServers = ((.mcpServers // {}) + $base)' > "$TARGET_FILE"
|
||||
echo "Wrote workspace MCP to $TARGET_FILE"
|
||||
}
|
||||
|
||||
# Write rules files to user's home (FOLDER/.cursor/rules)
|
||||
function write_rules_file() {
|
||||
if [ -n "$ARG_WORKSPACE_RULES_JSON" ]; then
|
||||
RULES_DIR="$ARG_FOLDER/.cursor/rules"
|
||||
mkdir -p "$RULES_DIR"
|
||||
echo "$ARG_WORKSPACE_RULES_JSON" | jq -r 'to_entries[] | @base64' | while read -r entry; do
|
||||
_jq() { echo "${entry}" | base64 -d | jq -r ${1}; }
|
||||
NAME=$(_jq '.key')
|
||||
CONTENT=$(_jq '.value')
|
||||
echo "$CONTENT" > "$RULES_DIR/$NAME"
|
||||
echo "Wrote rule: $RULES_DIR/$NAME"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
install_cursor_cli
|
||||
write_mcp_config
|
||||
write_rules_file
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
|
||||
ARG_FORCE=${ARG_FORCE:-false}
|
||||
ARG_MODEL=${ARG_MODEL:-}
|
||||
ARG_OUTPUT_FORMAT=${ARG_OUTPUT_FORMAT:-json}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "version: $ARG_VERSION"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "ai_prompt: $ARG_AI_PROMPT"
|
||||
echo "force: $ARG_FORCE"
|
||||
echo "model: $ARG_MODEL"
|
||||
echo "output_format: $ARG_OUTPUT_FORMAT"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "--------------------------------"
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
# Find cursor agent cli
|
||||
if command_exists cursor-agent; then
|
||||
CURSOR_CMD=cursor-agent
|
||||
elif [ -x "$HOME/.local/bin/cursor-agent" ]; then
|
||||
CURSOR_CMD="$HOME/.local/bin/cursor-agent"
|
||||
else
|
||||
echo "Error: cursor-agent not found. Install it or set install_cursor_cli=true."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure working directory exists
|
||||
if [ -d "$ARG_FOLDER" ]; then
|
||||
cd "$ARG_FOLDER"
|
||||
else
|
||||
mkdir -p "$ARG_FOLDER"
|
||||
cd "$ARG_FOLDER"
|
||||
fi
|
||||
|
||||
ARGS=()
|
||||
|
||||
# global flags
|
||||
if [ -n "$ARG_MODEL" ]; then
|
||||
ARGS+=("-m" "$ARG_MODEL")
|
||||
fi
|
||||
if [ "$ARG_FORCE" = "true" ]; then
|
||||
ARGS+=("-f")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_AI_PROMPT" ]; then
|
||||
printf "AI prompt provided\n"
|
||||
ARGS+=("Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT")
|
||||
fi
|
||||
|
||||
# Log and run in background, redirecting all output to the log file
|
||||
printf "Running: %q %s\n" "$CURSOR_CMD" "$(printf '%q ' "${ARGS[@]}")"
|
||||
|
||||
agentapi server --type cursor --term-width 67 --term-height 1190 -- "$CURSOR_CMD" "${ARGS[@]}"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "cursor-agent version v2.5.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - cursor-agent-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
display_name: Gemini CLI
|
||||
description: Run Gemini CLI in your workspace for AI pair programming
|
||||
icon: ../../../../.icons/gemini.svg
|
||||
verified: true
|
||||
tags: [agent, gemini, ai, google, tasks]
|
||||
---
|
||||
|
||||
# Gemini CLI
|
||||
|
||||
Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace to access Google's Gemini AI models for interactive coding assistance and automated task execution.
|
||||
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive AI Assistance**: Run Gemini CLI directly in your terminal for coding help
|
||||
- **Automated Task Execution**: Execute coding tasks automatically via AgentAPI integration
|
||||
- **Multiple AI Models**: Support for Gemini 2.5 Pro, Flash, and other Google AI models
|
||||
- **API Key Integration**: Seamless authentication with Gemini API
|
||||
- **MCP Server Integration**: Built-in Coder MCP server for task reporting
|
||||
- **Persistent Sessions**: Maintain context across workspace sessions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js and npm must be sourced/available before the gemini module installs** - ensure they are installed in your workspace image or via earlier provisioning steps
|
||||
- The [Coder Login](https://registry.coder.com/modules/coder/coder-login) module is required
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic setup
|
||||
|
||||
```tf
|
||||
variable "gemini_api_key" {
|
||||
type = string
|
||||
description = "Gemini API key"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
This basic setup will:
|
||||
|
||||
- Install Gemini CLI in the workspace
|
||||
- Configure authentication with your API key
|
||||
- Set Gemini to run in `/home/coder/project` directory
|
||||
- Enable interactive use from the terminal
|
||||
- Set up MCP server integration for task reporting
|
||||
|
||||
### Automated task execution (Experimental)
|
||||
|
||||
> This functionality is in early access and is still evolving.
|
||||
> For now, we recommend testing it in a demo or staging environment,
|
||||
> rather than deploying to production
|
||||
>
|
||||
> Learn more in [the Coder documentation](https://coder.com/docs/ai-coder)
|
||||
|
||||
```tf
|
||||
variable "gemini_api_key" {
|
||||
type = string
|
||||
description = "Gemini API key"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "~> 1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Task prompt for automated Gemini execution"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "gemini" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
gemini_model = "gemini-2.5-flash"
|
||||
folder = "/home/coder/project"
|
||||
task_prompt = data.coder_parameter.ai_prompt.value
|
||||
enable_yolo_mode = true # Auto-approve all tool calls for automation
|
||||
gemini_system_prompt = <<-EOT
|
||||
You are a helpful coding assistant. Always explain your code changes clearly.
|
||||
YOU MUST REPORT ALL TASKS TO CODER.
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> YOLO mode automatically approves all tool calls without user confirmation. The agent has access to your machine's file system and terminal. Only enable in trusted, isolated environments.
|
||||
|
||||
### Using Vertex AI (Enterprise)
|
||||
|
||||
For enterprise users who prefer Google's Vertex AI platform:
|
||||
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
use_vertexai = true
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If Gemini CLI is not found, ensure your API key is valid (`install_gemini` defaults to `true`)
|
||||
- Check logs in `~/.gemini-module/` for install/start output
|
||||
- Use the `gemini_api_key` variable to avoid requiring Google sign-in
|
||||
|
||||
The module creates log files in the workspace's `~/.gemini-module` directory for debugging purposes.
|
||||
|
||||
## References
|
||||
|
||||
- [Gemini CLI Documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/ai-coder)
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipGeminiMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_gemini: props?.skipGeminiMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
gemini_model: "test-model",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipGeminiMock) {
|
||||
const geminiMockContent = `#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "gemini version v2.5.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - gemini-mock"
|
||||
sleep 15
|
||||
done`;
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/gemini",
|
||||
content: geminiMockContent,
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("gemini", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("agent-api", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("install-gemini-version", async () => {
|
||||
const version_to_install = "0.1.13";
|
||||
const { id } = await setup({
|
||||
skipGeminiMock: true,
|
||||
moduleVariables: {
|
||||
install_gemini: "true",
|
||||
gemini_version: version_to_install,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.gemini-module/install.log || true`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("install-gemini-latest", async () => {
|
||||
const { id } = await setup({
|
||||
skipGeminiMock: true,
|
||||
moduleVariables: {
|
||||
install_gemini: "true",
|
||||
gemini_version: "",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("gemini-settings-json", async () => {
|
||||
const settings = '{"foo": "bar"}';
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
gemini_settings_json: settings,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini/settings.json",
|
||||
);
|
||||
expect(resp).toContain("foo");
|
||||
expect(resp).toContain("bar");
|
||||
});
|
||||
|
||||
test("gemini-api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
gemini_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("Using direct Gemini API with API key");
|
||||
});
|
||||
|
||||
test("use-vertexai", async () => {
|
||||
const { id } = await setup({
|
||||
skipGeminiMock: false,
|
||||
moduleVariables: {
|
||||
use_vertexai: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("GOOGLE_GENAI_USE_VERTEXAI='true'");
|
||||
});
|
||||
|
||||
test("gemini-model", async () => {
|
||||
const model = "gemini-2.5-pro";
|
||||
const { id } = await setup({
|
||||
skipGeminiMock: false,
|
||||
moduleVariables: {
|
||||
gemini_model: model,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(model);
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini-module/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("pre-install-script");
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini-module/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("folder-variable", async () => {
|
||||
const folder = "/tmp/gemini-test-folder";
|
||||
const { id } = await setup({
|
||||
skipGeminiMock: false,
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("additional-extensions", async () => {
|
||||
const additional = '{"custom": {"enabled": true}}';
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
additional_extensions: additional,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini/settings.json",
|
||||
);
|
||||
expect(resp).toContain("custom");
|
||||
expect(resp).toContain("enabled");
|
||||
});
|
||||
|
||||
test("gemini-system-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Gemini.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
gemini_system_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/GEMINI.md");
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("task-prompt", async () => {
|
||||
const taskPrompt = "Create a simple Hello World function";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
task_prompt: taskPrompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, {
|
||||
GEMINI_TASK_PROMPT: taskPrompt,
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.gemini-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("Running automated task:");
|
||||
});
|
||||
|
||||
test("start-without-prompt", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
const prompt = await execContainer(id, [
|
||||
"ls",
|
||||
"-l",
|
||||
"/home/coder/GEMINI.md",
|
||||
]);
|
||||
expect(prompt.exitCode).not.toBe(0);
|
||||
expect(prompt.stderr).toContain("No such file or directory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/gemini.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Gemini in."
|
||||
default = "/home/coder"
|
||||
}
|
||||
|
||||
variable "install_gemini" {
|
||||
type = bool
|
||||
description = "Whether to install Gemini."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "gemini_version" {
|
||||
type = string
|
||||
description = "The version of Gemini to install."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gemini_settings_json" {
|
||||
type = string
|
||||
description = "json to use in ~/.gemini/settings.json."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "gemini_api_key" {
|
||||
type = string
|
||||
description = "Gemini API Key"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "use_vertexai" {
|
||||
type = bool
|
||||
description = "Whether to use vertex ai"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI for web UI and task automation."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.2.3"
|
||||
}
|
||||
|
||||
variable "gemini_model" {
|
||||
type = string
|
||||
description = "The model to use for Gemini (e.g., gemini-2.5-pro)."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Gemini."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Gemini."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "task_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for automated Gemini execution"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_extensions" {
|
||||
type = string
|
||||
description = "Additional extensions configuration in json format to append to the config."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "gemini_system_prompt" {
|
||||
type = string
|
||||
description = "System prompt for Gemini. It will be added to GEMINI.md in the specified folder."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "enable_yolo_mode" {
|
||||
type = bool
|
||||
description = "Enable YOLO mode to automatically approve all tool calls without user confirmation. Use with caution."
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_env" "gemini_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "GEMINI_API_KEY"
|
||||
value = var.gemini_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "google_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "GOOGLE_API_KEY"
|
||||
value = var.gemini_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "gemini_use_vertex_ai" {
|
||||
agent_id = var.agent_id
|
||||
name = "GOOGLE_GENAI_USE_VERTEXAI"
|
||||
value = var.use_vertexai
|
||||
}
|
||||
|
||||
locals {
|
||||
base_extensions = <<-EOT
|
||||
{
|
||||
"coder": {
|
||||
"args": [
|
||||
"exp",
|
||||
"mcp",
|
||||
"server"
|
||||
],
|
||||
"command": "coder",
|
||||
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
|
||||
"enabled": true,
|
||||
"env": {
|
||||
"CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}",
|
||||
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
|
||||
},
|
||||
"name": "Coder",
|
||||
"timeout": 3000,
|
||||
"type": "stdio",
|
||||
"trust": true
|
||||
}
|
||||
}
|
||||
EOT
|
||||
|
||||
app_slug = "gemini"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".gemini-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Gemini"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Gemini CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL='${var.install_gemini}' \
|
||||
ARG_GEMINI_VERSION='${var.gemini_version}' \
|
||||
ARG_GEMINI_CONFIG='${base64encode(var.gemini_settings_json)}' \
|
||||
BASE_EXTENSIONS='${base64encode(replace(local.base_extensions, "'", "'\\''"))}' \
|
||||
ADDITIONAL_EXTENSIONS='${base64encode(replace(var.additional_extensions != null ? var.additional_extensions : "", "'", "'\\''"))}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_SYSTEM_PROMPT='${base64encode(var.gemini_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
GEMINI_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
|
||||
GEMINI_YOLO_MODE='${var.enable_yolo_mode}' \
|
||||
GEMINI_MODEL='${var.gemini_model}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_TASK_PROMPT='${var.task_prompt}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
source "$HOME"/.bashrc
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
set -o nounset
|
||||
|
||||
ARG_GEMINI_CONFIG=$(echo -n "$ARG_GEMINI_CONFIG" | base64 -d)
|
||||
BASE_EXTENSIONS=$(echo -n "$BASE_EXTENSIONS" | base64 -d)
|
||||
ADDITIONAL_EXTENSIONS=$(echo -n "$ADDITIONAL_EXTENSIONS" | base64 -d)
|
||||
GEMINI_SYSTEM_PROMPT=$(echo -n "$GEMINI_SYSTEM_PROMPT" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
|
||||
printf "install: %s\n" "$ARG_INSTALL"
|
||||
printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION"
|
||||
echo "--------------------------------"
|
||||
|
||||
set +o nounset
|
||||
|
||||
function check_dependencies() {
|
||||
if ! command_exists node; then
|
||||
printf "Error: Node.js is not installed. Please install Node.js manually or use the pre_install_script to install it.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command_exists npm; then
|
||||
printf "Error: npm is not installed. Please install npm manually or use the pre_install_script to install it.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "Node.js version: %s\n" "$(node --version)"
|
||||
printf "npm version: %s\n" "$(npm --version)"
|
||||
}
|
||||
|
||||
function install_gemini() {
|
||||
if [ "${ARG_INSTALL}" = "true" ]; then
|
||||
check_dependencies
|
||||
|
||||
printf "%s Installing Gemini CLI\n" "${BOLD}"
|
||||
|
||||
NPM_GLOBAL_PREFIX="${HOME}/.npm-global"
|
||||
if [ ! -d "$NPM_GLOBAL_PREFIX" ]; then
|
||||
mkdir -p "$NPM_GLOBAL_PREFIX"
|
||||
fi
|
||||
|
||||
npm config set prefix "$NPM_GLOBAL_PREFIX"
|
||||
|
||||
export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
|
||||
|
||||
if [ -n "$ARG_GEMINI_VERSION" ]; then
|
||||
npm install -g "@google/gemini-cli@$ARG_GEMINI_VERSION"
|
||||
else
|
||||
npm install -g "@google/gemini-cli"
|
||||
fi
|
||||
|
||||
if ! grep -q "export PATH=\"\$HOME/.npm-global/bin:\$PATH\"" "$HOME/.bashrc"; then
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
printf "%s Successfully installed Gemini CLI. Version: %s\n" "${BOLD}" "$(gemini --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
function populate_settings_json() {
|
||||
if [ "${ARG_GEMINI_CONFIG}" != "" ]; then
|
||||
SETTINGS_PATH="$HOME/.gemini/settings.json"
|
||||
mkdir -p "$(dirname "$SETTINGS_PATH")"
|
||||
printf "Custom gemini_config is provided !\n"
|
||||
echo "${ARG_GEMINI_CONFIG}" > "$HOME/.gemini/settings.json"
|
||||
else
|
||||
printf "No custom gemini_config provided, using default settings.json.\n"
|
||||
append_extensions_to_settings_json
|
||||
fi
|
||||
}
|
||||
|
||||
function append_extensions_to_settings_json() {
|
||||
SETTINGS_PATH="$HOME/.gemini/settings.json"
|
||||
mkdir -p "$(dirname "$SETTINGS_PATH")"
|
||||
printf "[append_extensions_to_settings_json] Starting extension merge process...\n"
|
||||
if [ -z "${BASE_EXTENSIONS:-}" ]; then
|
||||
printf "[append_extensions_to_settings_json] BASE_EXTENSIONS is empty, skipping merge.\n"
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$SETTINGS_PATH" ]; then
|
||||
printf "%s does not exist. Creating with merged mcpServers structure.\n" "$SETTINGS_PATH"
|
||||
ADD_EXT_JSON='{}'
|
||||
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
|
||||
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
|
||||
fi
|
||||
printf '{"mcpServers":%s}\n' "$(jq -s 'add' <(echo "$BASE_EXTENSIONS") <(echo "$ADD_EXT_JSON"))" > "$SETTINGS_PATH"
|
||||
fi
|
||||
|
||||
TMP_SETTINGS=$(mktemp)
|
||||
ADD_EXT_JSON='{}'
|
||||
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
|
||||
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is set.\n"
|
||||
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
|
||||
else
|
||||
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is empty or not set.\n"
|
||||
fi
|
||||
|
||||
printf "[append_extensions_to_settings_json] Merging BASE_EXTENSIONS and ADDITIONAL_EXTENSIONS into mcpServers...\n"
|
||||
jq --argjson base "$BASE_EXTENSIONS" --argjson add "$ADD_EXT_JSON" \
|
||||
'.mcpServers = (.mcpServers // {} + $base + $add)' \
|
||||
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
|
||||
|
||||
jq '.theme = "Default" | .selectedAuthType = "gemini-api-key"' "$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
|
||||
|
||||
printf "[append_extensions_to_settings_json] Merge complete.\n"
|
||||
}
|
||||
|
||||
function add_system_prompt_if_exists() {
|
||||
if [ -n "${GEMINI_SYSTEM_PROMPT:-}" ]; then
|
||||
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
cd "${GEMINI_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
mkdir -p "${GEMINI_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
cd "${GEMINI_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
touch GEMINI.md
|
||||
printf "Setting GEMINI.md\n"
|
||||
echo "${GEMINI_SYSTEM_PROMPT}" > GEMINI.md
|
||||
else
|
||||
printf "GEMINI.md is not set.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function configure_mcp() {
|
||||
export CODER_MCP_APP_STATUS_SLUG="gemini"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
coder exp mcp configure gemini "${GEMINI_START_DIRECTORY}"
|
||||
}
|
||||
|
||||
install_gemini
|
||||
populate_settings_json
|
||||
add_system_prompt_if_exists
|
||||
configure_mcp
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
printf "Version: %s\n" "$(gemini --version)"
|
||||
|
||||
MODULE_DIR="$HOME/.gemini-module"
|
||||
mkdir -p "$MODULE_DIR"
|
||||
|
||||
if command_exists gemini; then
|
||||
printf "Gemini is installed\n"
|
||||
else
|
||||
printf "Error: Gemini is not installed. Please enable install_gemini or install it manually :)\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
cd "${GEMINI_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
mkdir -p "${GEMINI_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
cd "${GEMINI_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_TASK_PROMPT" ]; then
|
||||
printf "Running automated task: %s\n" "$GEMINI_TASK_PROMPT"
|
||||
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GEMINI_TASK_PROMPT"
|
||||
PROMPT_FILE="$MODULE_DIR/prompt.txt"
|
||||
echo -n "$PROMPT" > "$PROMPT_FILE"
|
||||
GEMINI_ARGS=(--prompt-interactive "$PROMPT")
|
||||
else
|
||||
printf "Starting Gemini CLI in interactive mode.\n"
|
||||
GEMINI_ARGS=()
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_YOLO_MODE" ] && [ "$GEMINI_YOLO_MODE" = "true" ]; then
|
||||
printf "YOLO mode enabled - will auto-approve all tool calls\n"
|
||||
GEMINI_ARGS+=(--yolo)
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_API_KEY" ] || [ -n "$GOOGLE_API_KEY" ]; then
|
||||
if [ -n "$GOOGLE_GENAI_USE_VERTEXAI" ] && [ "$GOOGLE_GENAI_USE_VERTEXAI" = "true" ]; then
|
||||
printf "Using Vertex AI with API key\n"
|
||||
else
|
||||
printf "Using direct Gemini API with API key\n"
|
||||
fi
|
||||
else
|
||||
printf "No API key provided (neither GEMINI_API_KEY nor GOOGLE_API_KEY)\n"
|
||||
fi
|
||||
|
||||
agentapi server --term-width 67 --term-height 1190 -- \
|
||||
bash -c "$(printf '%q ' gemini "${GEMINI_ARGS[@]}")"
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
display_name: Amp CLI
|
||||
icon: ../../../../.icons/sourcegraph-amp.svg
|
||||
description: Sourcegraph's AI coding agent with deep codebase understanding and intelligent code search capabilities
|
||||
verified: false
|
||||
tags: [agent, sourcegraph, amp, ai, tasks]
|
||||
---
|
||||
|
||||
# Sourcegraph Amp CLI
|
||||
|
||||
Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI-powered code search and analysis tools, with AgentAPI integration for seamless Coder Tasks support.
|
||||
|
||||
```tf
|
||||
module "amp-cli" {
|
||||
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key
|
||||
install_sourcegraph_amp = true
|
||||
agentapi_version = "latest"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Include the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module in your template
|
||||
- Node.js and npm are automatically installed (via NVM) if not already available
|
||||
|
||||
## Usage Example
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
name = "AI Prompt"
|
||||
description = "Write an initial prompt for Amp to work on."
|
||||
type = "string"
|
||||
default = ""
|
||||
mutable = true
|
||||
|
||||
}
|
||||
|
||||
# Set system prompt for Amp CLI via environment variables
|
||||
resource "coder_agent" "main" {
|
||||
# ...
|
||||
env = {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT = <<-EOT
|
||||
You are an Amp assistant that helps developers debug and write code efficiently.
|
||||
|
||||
Always log task status to Coder.
|
||||
EOT
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT = data.coder_parameter.ai_prompt.value
|
||||
}
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
type = string
|
||||
description = "Sourcegraph Amp API key. Get one at https://ampcode.com/settings"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "amp-cli" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key # recommended for authenticated usage
|
||||
install_sourcegraph_amp = true
|
||||
}
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: Installs Sourcegraph Amp CLI using npm (installs Node.js via NVM if required)
|
||||
- **Start**: Launches Amp CLI in the specified directory, wrapped with AgentAPI to enable tasks and AI interactions
|
||||
- **Environment Variables**: Sets `SOURCEGRAPH_AMP_API_KEY` and `SOURCEGRAPH_AMP_START_DIRECTORY` for the CLI execution
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `amp` is not found, ensure `install_sourcegraph_amp = true` and your API key is valid
|
||||
- Logs are written under `/home/coder/.sourcegraph-amp-module/` (`install.log`, `agentapi-start.log`) for debugging
|
||||
- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts
|
||||
|
||||
> [!IMPORTANT]
|
||||
> For using **Coder Tasks** with Amp CLI, make sure to pass the `AI Prompt` parameter and set `sourcegraph_amp_api_key`.
|
||||
> This ensures task reporting and status updates work seamlessly.
|
||||
|
||||
## References
|
||||
|
||||
- [Amp CLI Documentation](https://ampcode.com/manual)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipAmpMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_sourcegraph_amp: props?.skipAmpMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
sourcegraph_amp_model: "test-model",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
|
||||
// Place the AMP mock CLI binary inside the container
|
||||
if (!props?.skipAmpMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/amp",
|
||||
content: await loadTestFile(`${import.meta.dir}`, "amp-mock.sh"),
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("sourcegraph-amp", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
sourcegraph_amp_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("sourcegraph_amp_api_key provided !");
|
||||
});
|
||||
|
||||
test("custom-folder", async () => {
|
||||
const folder = "/tmp/sourcegraph-amp-test";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/install.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const preLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/pre_install.log",
|
||||
);
|
||||
expect(preLog).toContain("pre-install-script");
|
||||
const postLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/post_install.log",
|
||||
);
|
||||
expect(postLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("system-prompt", async () => {
|
||||
const prompt = "this is a system prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT: prompt,
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/SYSTEM_PROMPT.md",
|
||||
);
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("task-prompt", async () => {
|
||||
const prompt = "this is a task prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT: prompt,
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(`sourcegraph amp task prompt provided : ${prompt}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/sourcegraph-amp.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run sourcegraph_amp in."
|
||||
default = "/home/coder"
|
||||
}
|
||||
|
||||
variable "install_sourcegraph_amp" {
|
||||
type = bool
|
||||
description = "Whether to install sourcegraph-amp."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
type = string
|
||||
description = "sourcegraph-amp API Key"
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "sourcegraph_amp_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "SOURCEGRAPH_AMP_API_KEY"
|
||||
value = var.sourcegraph_amp_api_key
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.0"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing sourcegraph_amp"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing sourcegraph_amp."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "base_amp_config" {
|
||||
type = string
|
||||
description = <<-EOT
|
||||
Base AMP configuration in JSON format. Can be overridden to customize AMP settings.
|
||||
|
||||
If empty, defaults enable thinking and todos for autonomous operation. Additional options include:
|
||||
- "amp.permissions": [] (tool permissions)
|
||||
- "amp.tools.stopTimeout": 600 (extend timeout for long operations)
|
||||
- "amp.terminal.commands.nodeSpawn.loadProfile": "daily" (environment loading)
|
||||
- "amp.tools.disable": ["builtin:open"] (disable tools for containers)
|
||||
- "amp.git.commit.ampThread.enabled": true (link commits to threads)
|
||||
- "amp.git.commit.coauthor.enabled": true (add Amp as co-author)
|
||||
|
||||
Reference: https://ampcode.com/manual
|
||||
EOT
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_mcp_servers" {
|
||||
type = string
|
||||
description = "Additional MCP servers configuration in JSON format to append to amp.mcpServers."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "amp"
|
||||
|
||||
default_base_config = {
|
||||
"amp.anthropic.thinking.enabled" = true
|
||||
"amp.todos.enabled" = true
|
||||
}
|
||||
|
||||
# Use provided config or default, then extract base settings (excluding mcpServers)
|
||||
user_config = var.base_amp_config != "" ? jsondecode(var.base_amp_config) : local.default_base_config
|
||||
base_amp_settings = { for k, v in local.user_config : k => v if k != "amp.mcpServers" }
|
||||
|
||||
coder_mcp = {
|
||||
"coder" = {
|
||||
"command" = "coder"
|
||||
"args" = ["exp", "mcp", "server"]
|
||||
"env" = {
|
||||
"CODER_MCP_APP_STATUS_SLUG" = local.app_slug
|
||||
"CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284"
|
||||
}
|
||||
"type" = "stdio"
|
||||
}
|
||||
}
|
||||
|
||||
additional_mcp = var.additional_mcp_servers != null ? jsondecode(var.additional_mcp_servers) : {}
|
||||
|
||||
merged_mcp_servers = merge(
|
||||
lookup(local.user_config, "amp.mcpServers", {}),
|
||||
local.coder_mcp,
|
||||
local.additional_mcp
|
||||
)
|
||||
|
||||
final_config = merge(local.base_amp_settings, {
|
||||
"amp.mcpServers" = local.merged_mcp_servers
|
||||
})
|
||||
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".sourcegraph-amp-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.0.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Sourcegraph Amp"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Sourcegraph Amp CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
SOURCEGRAPH_AMP_API_KEY='${var.sourcegraph_amp_api_key}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL_SOURCEGRAPH_AMP='${var.install_sourcegraph_amp}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
ARG_AMP_CONFIG="$(echo -n '${base64encode(jsonencode(local.final_config))}' | base64 -d)" \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ANSI colors
|
||||
BOLD='\033[1m'
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "Install flag: $ARG_INSTALL_SOURCEGRAPH_AMP"
|
||||
echo "Workspace: $SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Helper function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_node() {
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
# Temporarily disable nounset (-u) for nvm to avoid PROVIDED_VERSION error
|
||||
set +u
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
set -u
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function install_sourcegraph_amp() {
|
||||
if [ "${ARG_INSTALL_SOURCEGRAPH_AMP}" = "true" ]; then
|
||||
install_node
|
||||
|
||||
# If nvm is not used, set up user npm global directory
|
||||
if ! command_exists nvm; then
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Sourcegraph AMP CLI...\n" "${BOLD}"
|
||||
npm install -g @sourcegraph/amp@0.0.1754179307-gba1f97
|
||||
printf "%s Successfully installed Sourcegraph AMP CLI. Version: %s\n" "${BOLD}" "$(amp --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
function setup_system_prompt() {
|
||||
if [ -n "${SOURCEGRAPH_AMP_SYSTEM_PROMPT:-}" ]; then
|
||||
echo "Setting Sourcegraph AMP system prompt..."
|
||||
mkdir -p "$HOME/.sourcegraph-amp-module"
|
||||
echo "$SOURCEGRAPH_AMP_SYSTEM_PROMPT" > "$HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
echo "System prompt saved to $HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
else
|
||||
echo "No system prompt provided for Sourcegraph AMP."
|
||||
fi
|
||||
}
|
||||
|
||||
function configure_amp_settings() {
|
||||
echo "Configuring AMP settings..."
|
||||
SETTINGS_PATH="$HOME/.config/amp/settings.json"
|
||||
mkdir -p "$(dirname "$SETTINGS_PATH")"
|
||||
|
||||
if [ -z "${ARG_AMP_CONFIG:-}" ]; then
|
||||
echo "No AMP config provided, skipping configuration"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Writing AMP configuration to $SETTINGS_PATH"
|
||||
printf '%s\n' "$ARG_AMP_CONFIG" > "$SETTINGS_PATH"
|
||||
|
||||
echo "AMP configuration complete"
|
||||
}
|
||||
|
||||
install_sourcegraph_amp
|
||||
setup_system_prompt
|
||||
configure_amp_settings
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load user environment
|
||||
# shellcheck source=/dev/null
|
||||
source "$HOME/.bashrc"
|
||||
# shellcheck source=/dev/null
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
function ensure_command() {
|
||||
command -v "$1" &> /dev/null || {
|
||||
echo "Error: '$1' not found." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_command amp
|
||||
echo "AMP version: $(amp --version)"
|
||||
|
||||
dir="$SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo "Using existing directory: $dir"
|
||||
else
|
||||
echo "Creating directory: $dir"
|
||||
mkdir -p "$dir"
|
||||
fi
|
||||
cd "$dir"
|
||||
|
||||
if [ -n "$SOURCEGRAPH_AMP_API_KEY" ]; then
|
||||
printf "sourcegraph_amp_api_key provided !\n"
|
||||
export AMP_API_KEY=$SOURCEGRAPH_AMP_API_KEY
|
||||
else
|
||||
printf "sourcegraph_amp_api_key not provided\n"
|
||||
fi
|
||||
|
||||
if [ -n "${SOURCEGRAPH_AMP_TASK_PROMPT:-}" ]; then
|
||||
printf "sourcegraph amp task prompt provided : $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
|
||||
# Pipe the prompt into amp, which will be run inside agentapi
|
||||
agentapi server --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp"
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
agentapi server --term-width=67 --term-height=1190 -- amp
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mock behavior of the AMP CLI
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "AMP CLI mock version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Simulate AMP running in a loop for AgentAPI to connect
|
||||
set -e
|
||||
while true; do
|
||||
echo "$(date) - AMP mock is running..."
|
||||
sleep 15
|
||||
done
|
||||
@@ -8,6 +8,10 @@ tags: [docker, container, dockerfile]
|
||||
|
||||
# Remote Development on Docker Containers (Build from Dockerfile)
|
||||
|
||||
> [!NOTE]
|
||||
> This template is designed to be a starting point for testing purposes.
|
||||
> In a production environment, you would want to move away from storing the Dockerfile in-template and move towards using a centralized image registry.
|
||||
|
||||
Build and provision Docker containers from a Dockerfile as [Coder workspaces](https://coder.com/docs/workspaces) with this example template.
|
||||
|
||||
This template builds a custom Docker image from the included Dockerfile, allowing you to customize the development environment by modifying the Dockerfile rather than using a pre-built image.
|
||||
@@ -18,7 +22,22 @@ This template builds a custom Docker image from the included Dockerfile, allowin
|
||||
|
||||
### Infrastructure
|
||||
|
||||
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
#### Running Coder inside Docker
|
||||
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
|
||||
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
FROM ubuntu
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
golang \
|
||||
sudo \
|
||||
vim \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
golang \
|
||||
sudo \
|
||||
vim \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG USER=coder
|
||||
RUN useradd --groups sudo --no-create-home --shell /bin/bash ${USER} \
|
||||
&& echo "${USER} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/${USER} \
|
||||
&& chmod 0440 /etc/sudoers.d/${USER}
|
||||
&& echo "${USER} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/${USER} \
|
||||
&& chmod 0440 /etc/sudoers.d/${USER}
|
||||
USER ${USER}
|
||||
WORKDIR /home/${USER}
|
||||
|
||||
@@ -63,7 +63,7 @@ Visit this URL for your Coder deployment:
|
||||
https://coder.example.com/templates/new?exampleId=scratch
|
||||
```
|
||||
|
||||
After creating the template, paste the contents from [main.tf](./main.tf) into the template editor and save.
|
||||
After creating the template, paste the contents from [main.tf](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf) into the template editor and save.
|
||||
|
||||
Alternatively, you can use the Coder CLI to [push the template](https://coder.com/docs/reference/cli/templates_push)
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ data "coder_workspace_preset" "default" {
|
||||
EOT
|
||||
"preview_port" = "4200"
|
||||
"container_image" = "codercom/example-universal:ubuntu"
|
||||
"jetbrains_ide" = "PY"
|
||||
}
|
||||
|
||||
# Pre-builds is a Coder Premium
|
||||
@@ -182,7 +181,7 @@ resource "coder_env" "claude_task_prompt" {
|
||||
resource "coder_env" "app_status_slug" {
|
||||
agent_id = coder_agent.main.id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = "claude-code"
|
||||
value = "ccw"
|
||||
}
|
||||
resource "coder_env" "claude_system_prompt" {
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
---
|
||||
display_name: AgentAPI
|
||||
description: Building block for modules that need to run an agentapi server
|
||||
description: Building block for modules that need to run an AgentAPI server
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: true
|
||||
tags: [internal]
|
||||
tags: [internal, library]
|
||||
---
|
||||
|
||||
# AgentAPI
|
||||
|
||||
The AgentAPI module is a building block for modules that need to run an agentapi server. It is intended primarily for internal use by Coder to create modules compatible with Tasks.
|
||||
> [!CAUTION]
|
||||
> We do not recommend using this module directly. Instead, please consider using one of our [Tasks-compatible AI agent modules](https://registry.coder.com/modules?search=tag%3Atasks).
|
||||
|
||||
We do not recommend using this module directly. Instead, please consider using one of our [Tasks-compatible AI agent modules](https://registry.coder.com/modules?search=tag%3Atasks).
|
||||
The AgentAPI module is a building block for modules that need to run an AgentAPI server. It is intended primarily for internal use by Coder to create modules compatible with Tasks.
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -50,4 +51,4 @@ module "agentapi" {
|
||||
|
||||
## For module developers
|
||||
|
||||
For a complete example of how to use this module, see the [goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||
|
||||
@@ -148,4 +148,113 @@ describe("agentapi", async () => {
|
||||
]);
|
||||
expect(respAgentAPI.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("no-subdomain-base-path", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
agentapi_subdomain: "false",
|
||||
},
|
||||
});
|
||||
|
||||
const respModuleScript = await execModuleScript(id);
|
||||
expect(respModuleScript.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/test-agentapi-start.log",
|
||||
);
|
||||
expect(agentApiStartLog).toContain(
|
||||
"Using AGENTAPI_CHAT_BASE_PATH: /@default/default.foo/apps/agentapi-web/chat",
|
||||
);
|
||||
});
|
||||
|
||||
test("validate-agentapi-version", async () => {
|
||||
const cases = [
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v0.3.2",
|
||||
},
|
||||
shouldThrow: "",
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v0.3.3",
|
||||
},
|
||||
shouldThrow: "",
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v0.0.1",
|
||||
agentapi_subdomain: "false",
|
||||
},
|
||||
shouldThrow:
|
||||
"Running with subdomain = false is only supported by agentapi >= v0.3.3.",
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v0.3.2",
|
||||
agentapi_subdomain: "false",
|
||||
},
|
||||
shouldThrow:
|
||||
"Running with subdomain = false is only supported by agentapi >= v0.3.3.",
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v0.3.3",
|
||||
agentapi_subdomain: "false",
|
||||
},
|
||||
shouldThrow: "",
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v0.3.999",
|
||||
agentapi_subdomain: "false",
|
||||
},
|
||||
shouldThrow: "",
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v0.999.999",
|
||||
agentapi_subdomain: "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "v999.999.999",
|
||||
agentapi_subdomain: "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
moduleVariables: {
|
||||
agentapi_version: "arbitrary-string-bypasses-validation",
|
||||
},
|
||||
shouldThrow: "",
|
||||
},
|
||||
];
|
||||
for (const { moduleVariables, shouldThrow } of cases) {
|
||||
if (shouldThrow) {
|
||||
expect(
|
||||
setup({ moduleVariables: moduleVariables as Record<string, string> }),
|
||||
).rejects.toThrow(shouldThrow);
|
||||
} else {
|
||||
expect(
|
||||
setup({ moduleVariables: moduleVariables as Record<string, string> }),
|
||||
).resolves.toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("agentapi-allowed-hosts", async () => {
|
||||
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
|
||||
// set in main.sh
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.2.3"
|
||||
default = "v0.3.3"
|
||||
}
|
||||
|
||||
variable "agentapi_port" {
|
||||
@@ -126,6 +126,31 @@ variable "agentapi_port" {
|
||||
default = 3284
|
||||
}
|
||||
|
||||
locals {
|
||||
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
|
||||
# Initial support was added in v0.3.1 but configuration via environment variable
|
||||
# was added in v0.3.3.
|
||||
# This is unfortunately a regex because there is no builtin way to compare semantic versions in Terraform.
|
||||
# See: https://regex101.com/r/oHPyRa/1
|
||||
agentapi_subdomain_false_min_version_expr = "^v(0\\.(3\\.[3-9]|3.[1-9]\\d+|[4-9]\\.\\d+|[1-9]\\d+\\.\\d+)|[1-9]\\d*\\.\\d+\\.\\d+)$"
|
||||
}
|
||||
|
||||
variable "agentapi_subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = true
|
||||
validation {
|
||||
condition = var.agentapi_subdomain || (
|
||||
# If version doesn't look like a valid semantic version, just allow it.
|
||||
# Note that boolean operators do not short-circuit in Terraform.
|
||||
can(regex("^v\\d+\\.\\d+\\.\\d+$", var.agentapi_version)) ?
|
||||
can(regex(local.agentapi_subdomain_false_min_version_expr, var.agentapi_version)) :
|
||||
true
|
||||
)
|
||||
error_message = "Running with subdomain = false is only supported by agentapi >= v0.3.3."
|
||||
}
|
||||
}
|
||||
|
||||
variable "module_dir_name" {
|
||||
type = string
|
||||
description = "Name of the subdirectory in the home directory for module files."
|
||||
@@ -140,7 +165,14 @@ locals {
|
||||
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
|
||||
agentapi_start_script_b64 = base64encode(var.start_script)
|
||||
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
|
||||
main_script = file("${path.module}/scripts/main.sh")
|
||||
// Chat base path is only set if not using a subdomain.
|
||||
// NOTE:
|
||||
// - Initial support for --chat-base-path was added in v0.3.1 but configuration
|
||||
// via environment variable AGENTAPI_CHAT_BASE_PATH was added in v0.3.3.
|
||||
// - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID
|
||||
// for backward compatibility.
|
||||
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
|
||||
main_script = file("${path.module}/scripts/main.sh")
|
||||
}
|
||||
|
||||
resource "coder_script" "agentapi" {
|
||||
@@ -165,6 +197,7 @@ resource "coder_script" "agentapi" {
|
||||
ARG_WAIT_FOR_START_SCRIPT="$(echo -n '${local.agentapi_wait_for_start_script_b64}' | base64 -d)" \
|
||||
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
|
||||
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
||||
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
||||
/tmp/main.sh
|
||||
EOT
|
||||
run_on_start = true
|
||||
@@ -178,7 +211,7 @@ resource "coder_app" "agentapi_web" {
|
||||
icon = var.web_app_icon
|
||||
order = var.web_app_order
|
||||
group = var.web_app_group
|
||||
subdomain = true
|
||||
subdomain = var.agentapi_subdomain
|
||||
healthcheck {
|
||||
url = "http://localhost:${var.agentapi_port}/status"
|
||||
interval = 3
|
||||
|
||||
@@ -11,22 +11,22 @@ agentapi_started=false
|
||||
|
||||
echo "Waiting for agentapi server to start on port $port..."
|
||||
for i in $(seq 1 150); do
|
||||
for j in $(seq 1 3); do
|
||||
sleep 0.1
|
||||
if curl -fs -o /dev/null "http://localhost:$port/status"; then
|
||||
echo "agentapi response received ($j/3)"
|
||||
else
|
||||
echo "agentapi server not responding ($i/15)"
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
agentapi_started=true
|
||||
break
|
||||
for j in $(seq 1 3); do
|
||||
sleep 0.1
|
||||
if curl -fs -o /dev/null "http://localhost:$port/status"; then
|
||||
echo "agentapi response received ($j/3)"
|
||||
else
|
||||
echo "agentapi server not responding ($i/15)"
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
agentapi_started=true
|
||||
break
|
||||
done
|
||||
|
||||
if [ "$agentapi_started" != "true" ]; then
|
||||
echo "Error: agentapi server did not start on port $port after 15 seconds."
|
||||
exit 1
|
||||
echo "Error: agentapi server did not start on port $port after 15 seconds."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "agentapi server started on port $port."
|
||||
|
||||
@@ -13,84 +13,89 @@ START_SCRIPT="$ARG_START_SCRIPT"
|
||||
WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
|
||||
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
|
||||
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
||||
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
set +o nounset
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
module_path="$HOME/${MODULE_DIR_NAME}"
|
||||
mkdir -p "$module_path/scripts"
|
||||
|
||||
if [ ! -d "${WORKDIR}" ]; then
|
||||
echo "Warning: The specified folder '${WORKDIR}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "${WORKDIR}"
|
||||
echo "Folder created successfully."
|
||||
echo "Warning: The specified folder '${WORKDIR}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "${WORKDIR}"
|
||||
echo "Folder created successfully."
|
||||
fi
|
||||
if [ -n "${PRE_INSTALL_SCRIPT}" ]; then
|
||||
echo "Running pre-install script..."
|
||||
echo -n "${PRE_INSTALL_SCRIPT}" >"$module_path/pre_install.sh"
|
||||
chmod +x "$module_path/pre_install.sh"
|
||||
"$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log"
|
||||
echo "Running pre-install script..."
|
||||
echo -n "${PRE_INSTALL_SCRIPT}" > "$module_path/pre_install.sh"
|
||||
chmod +x "$module_path/pre_install.sh"
|
||||
"$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log"
|
||||
fi
|
||||
|
||||
echo "Running install script..."
|
||||
echo -n "${INSTALL_SCRIPT}" >"$module_path/install.sh"
|
||||
echo -n "${INSTALL_SCRIPT}" > "$module_path/install.sh"
|
||||
chmod +x "$module_path/install.sh"
|
||||
"$module_path/install.sh" 2>&1 | tee "$module_path/install.log"
|
||||
|
||||
# Install AgentAPI if enabled
|
||||
if [ "${INSTALL_AGENTAPI}" = "true" ]; then
|
||||
echo "Installing AgentAPI..."
|
||||
arch=$(uname -m)
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
binary_name="agentapi-linux-amd64"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
binary_name="agentapi-linux-arm64"
|
||||
else
|
||||
echo "Error: Unsupported architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${AGENTAPI_VERSION}" = "latest" ]; then
|
||||
# for the latest release the download URL pattern is different than for tagged releases
|
||||
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
|
||||
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
|
||||
else
|
||||
download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name"
|
||||
fi
|
||||
curl \
|
||||
--retry 5 \
|
||||
--retry-delay 5 \
|
||||
--fail \
|
||||
--retry-all-errors \
|
||||
-L \
|
||||
-C - \
|
||||
-o agentapi \
|
||||
"$download_url"
|
||||
chmod +x agentapi
|
||||
sudo mv agentapi /usr/local/bin/agentapi
|
||||
echo "Installing AgentAPI..."
|
||||
arch=$(uname -m)
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
binary_name="agentapi-linux-amd64"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
binary_name="agentapi-linux-arm64"
|
||||
else
|
||||
echo "Error: Unsupported architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${AGENTAPI_VERSION}" = "latest" ]; then
|
||||
# for the latest release the download URL pattern is different than for tagged releases
|
||||
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
|
||||
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
|
||||
else
|
||||
download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name"
|
||||
fi
|
||||
curl \
|
||||
--retry 5 \
|
||||
--retry-delay 5 \
|
||||
--fail \
|
||||
--retry-all-errors \
|
||||
-L \
|
||||
-C - \
|
||||
-o agentapi \
|
||||
"$download_url"
|
||||
chmod +x agentapi
|
||||
sudo mv agentapi /usr/local/bin/agentapi
|
||||
fi
|
||||
if ! command_exists agentapi; then
|
||||
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
|
||||
exit 1
|
||||
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "${START_SCRIPT}" >"$module_path/scripts/agentapi-start.sh"
|
||||
echo -n "${WAIT_FOR_START_SCRIPT}" >"$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
echo -n "${START_SCRIPT}" > "$module_path/scripts/agentapi-start.sh"
|
||||
echo -n "${WAIT_FOR_START_SCRIPT}" > "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
|
||||
if [ -n "${POST_INSTALL_SCRIPT}" ]; then
|
||||
echo "Running post-install script..."
|
||||
echo -n "${POST_INSTALL_SCRIPT}" >"$module_path/post_install.sh"
|
||||
chmod +x "$module_path/post_install.sh"
|
||||
"$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log"
|
||||
echo "Running post-install script..."
|
||||
echo -n "${POST_INSTALL_SCRIPT}" > "$module_path/post_install.sh"
|
||||
chmod +x "$module_path/post_install.sh"
|
||||
"$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log"
|
||||
fi
|
||||
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${WORKDIR}"
|
||||
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &>"$module_path/agentapi-start.log" &
|
||||
|
||||
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
|
||||
|
||||
@@ -24,7 +24,22 @@ export const setupContainer = async ({
|
||||
});
|
||||
const coderScript = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer(image ?? "codercom/enterprise-node:latest");
|
||||
return { id, coderScript, cleanup: () => removeContainer(id) };
|
||||
return {
|
||||
id,
|
||||
coderScript,
|
||||
cleanup: async () => {
|
||||
if (
|
||||
process.env["DEBUG"] === "true" ||
|
||||
process.env["DEBUG"] === "1" ||
|
||||
process.env["DEBUG"] === "yes"
|
||||
) {
|
||||
console.log(`Not removing container ${id} in debug mode`);
|
||||
console.log(`Run "docker rm -f ${id}" to remove it manually.`);
|
||||
} else {
|
||||
await removeContainer(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const loadTestFile = async (
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const args = process.argv.slice(2);
|
||||
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
|
||||
const port = portIdx ? args[portIdx] : 3284;
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
fs.writeFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
|
||||
);
|
||||
|
||||
http
|
||||
.createServer(function (_request, response) {
|
||||
|
||||
@@ -8,9 +8,15 @@ port=${2:-3284}
|
||||
module_path="$HOME/.agentapi-module"
|
||||
log_file_path="$module_path/agentapi.log"
|
||||
|
||||
echo "using prompt: $use_prompt" >>/home/coder/test-agentapi-start.log
|
||||
echo "using port: $port" >>/home/coder/test-agentapi-start.log
|
||||
echo "using prompt: $use_prompt" >> /home/coder/test-agentapi-start.log
|
||||
echo "using port: $port" >> /home/coder/test-agentapi-start.log
|
||||
|
||||
AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
|
||||
echo "Using AGENTAPI_CHAT_BASE_PATH: $AGENTAPI_CHAT_BASE_PATH" >> /home/coder/test-agentapi-start.log
|
||||
export AGENTAPI_CHAT_BASE_PATH
|
||||
fi
|
||||
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
bash -c aiagent \
|
||||
>"$log_file_path" 2>&1
|
||||
bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
|
||||
@@ -13,7 +13,7 @@ Run [Aider](https://aider.chat) AI pair programming in your workspace. This modu
|
||||
```tf
|
||||
module "aider" {
|
||||
source = "registry.coder.com/coder/aider/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -30,29 +30,8 @@ module "aider" {
|
||||
|
||||
## Module Parameters
|
||||
|
||||
| Parameter | Description | Type | Default |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------- | -------- | ------------------- |
|
||||
| `agent_id` | The ID of a Coder agent (required) | `string` | - |
|
||||
| `folder` | The folder to run Aider in | `string` | `/home/coder` |
|
||||
| `install_aider` | Whether to install Aider | `bool` | `true` |
|
||||
| `aider_version` | The version of Aider to install | `string` | `"latest"` |
|
||||
| `use_screen` | Whether to use screen for running Aider in the background | `bool` | `true` |
|
||||
| `use_tmux` | Whether to use tmux instead of screen for running Aider in the background | `bool` | `false` |
|
||||
| `session_name` | Name for the persistent session (screen or tmux) | `string` | `"aider"` |
|
||||
| `order` | Position of the app in the UI presentation | `number` | `null` |
|
||||
| `icon` | The icon to use for the app | `string` | `"/icon/aider.svg"` |
|
||||
| `experiment_report_tasks` | Whether to enable task reporting | `bool` | `true` |
|
||||
| `system_prompt` | System prompt for instructing Aider on task reporting and behavior | `string` | See default in code |
|
||||
| `task_prompt` | Task prompt to use with Aider | `string` | `""` |
|
||||
| `ai_provider` | AI provider to use with Aider (openai, anthropic, azure, etc.) | `string` | `"anthropic"` |
|
||||
| `ai_model` | AI model to use (can use Aider's built-in aliases like "sonnet", "4o") | `string` | `"sonnet"` |
|
||||
| `ai_api_key` | API key for the selected AI provider | `string` | `""` |
|
||||
| `custom_env_var_name` | Custom environment variable name when using custom provider | `string` | `""` |
|
||||
| `experiment_pre_install_script` | Custom script to run before installing Aider | `string` | `null` |
|
||||
| `experiment_post_install_script` | Custom script to run after installing Aider | `string` | `null` |
|
||||
| `experiment_additional_extensions` | Additional extensions configuration in YAML format to append to the config | `string` | `null` |
|
||||
|
||||
> **Note**: `use_screen` and `use_tmux` cannot both be enabled at the same time. By default, `use_screen` is set to `true` and `use_tmux` is set to `false`.
|
||||
> [!NOTE]
|
||||
> The `use_screen` and `use_tmux` parameters cannot both be enabled at the same time. By default, `use_screen` is set to `true` and `use_tmux` is set to `false`.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -68,7 +47,7 @@ variable "anthropic_api_key" {
|
||||
module "aider" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/aider/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
ai_api_key = var.anthropic_api_key
|
||||
}
|
||||
@@ -93,7 +72,7 @@ variable "openai_api_key" {
|
||||
module "aider" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/aider/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
use_tmux = true
|
||||
ai_provider = "openai"
|
||||
@@ -114,7 +93,7 @@ variable "custom_api_key" {
|
||||
module "aider" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/aider/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
ai_provider = "custom"
|
||||
custom_env_var_name = "MY_CUSTOM_API_KEY"
|
||||
@@ -131,7 +110,7 @@ You can extend Aider's capabilities by adding custom extensions:
|
||||
module "aider" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/aider/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
ai_api_key = var.anthropic_api_key
|
||||
|
||||
@@ -210,7 +189,7 @@ data "coder_parameter" "ai_prompt" {
|
||||
module "aider" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/aider/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
ai_api_key = var.anthropic_api_key
|
||||
task_prompt = data.coder_parameter.ai_prompt.value
|
||||
@@ -308,7 +287,3 @@ If you encounter issues:
|
||||
3. **Browser mode issues**: If the browser interface doesn't open, check that you're accessing it from a machine that can reach your Coder workspace
|
||||
|
||||
For more information on using Aider, see the [Aider documentation](https://aider.chat/docs/).
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -13,8 +13,9 @@ Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's A
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
# Required: see below for how to generate
|
||||
experiment_auth_tarball = var.amazon_q_auth_tarball
|
||||
}
|
||||
@@ -81,7 +82,7 @@ module "amazon-q" {
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
experiment_auth_tarball = var.amazon_q_auth_tarball
|
||||
experiment_use_tmux = true
|
||||
@@ -93,7 +94,7 @@ module "amazon-q" {
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
experiment_auth_tarball = var.amazon_q_auth_tarball
|
||||
experiment_report_tasks = true
|
||||
@@ -105,7 +106,7 @@ module "amazon-q" {
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
experiment_auth_tarball = var.amazon_q_auth_tarball
|
||||
experiment_pre_install_script = "echo Pre-install!"
|
||||
|
||||
@@ -125,24 +125,7 @@ variable "ai_prompt" {
|
||||
locals {
|
||||
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
|
||||
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
|
||||
# We need to use allowed tools to limit the context Amazon Q receives.
|
||||
# Amazon Q can't handle big contexts, and the `create_template_version` tool
|
||||
# has a description that's too long.
|
||||
mcp_json = <<EOT
|
||||
{
|
||||
"mcpServers": {
|
||||
"coder": {
|
||||
"command": "coder",
|
||||
"args": ["exp", "mcp", "server", "--allowed-tools", "coder_report_task"],
|
||||
"env": {
|
||||
"CODER_MCP_APP_STATUS_SLUG": "amazon-q"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOT
|
||||
encoded_mcp_json = base64encode(local.mcp_json)
|
||||
full_prompt = <<-EOT
|
||||
full_prompt = <<-EOT
|
||||
${var.system_prompt}
|
||||
|
||||
Your first task is:
|
||||
@@ -211,6 +194,12 @@ resource "coder_script" "amazon_q" {
|
||||
cd "$PREV_DIR"
|
||||
echo "Extracted auth tarball"
|
||||
|
||||
if [ "${var.experiment_report_tasks}" = "true" ]; then
|
||||
echo "Configuring Amazon Q to report tasks via Coder MCP..."
|
||||
q mcp add --name coder --command "coder" --args "exp,mcp,server,--allowed-tools,coder_report_task" --env "CODER_MCP_APP_STATUS_SLUG=amazon-q" --scope global --force
|
||||
echo "Added Coder MCP server to Amazon Q configuration"
|
||||
fi
|
||||
|
||||
if [ -n "${local.encoded_post_install_script}" ]; then
|
||||
echo "Running post-install script..."
|
||||
echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
|
||||
@@ -218,13 +207,6 @@ resource "coder_script" "amazon_q" {
|
||||
/tmp/post_install.sh
|
||||
fi
|
||||
|
||||
if [ "${var.experiment_report_tasks}" = "true" ]; then
|
||||
echo "Configuring Amazon Q to report tasks via Coder MCP..."
|
||||
mkdir -p ~/.aws/amazonq
|
||||
echo "${local.encoded_mcp_json}" | base64 -d > ~/.aws/amazonq/mcp.json
|
||||
echo "Created the ~/.aws/amazonq/mcp.json configuration file"
|
||||
fi
|
||||
|
||||
if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then
|
||||
echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously."
|
||||
echo "Please set only one of them to true."
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.3"
|
||||
version = "2.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -28,7 +28,6 @@ module "claude-code" {
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js and npm must be installed in your workspace to install Claude Code
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
|
||||
|
||||
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
|
||||
@@ -84,7 +83,7 @@ resource "coder_agent" "main" {
|
||||
module "claude-code" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.3"
|
||||
version = "2.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -102,7 +101,7 @@ Run Claude Code as a standalone app in your workspace. This will install Claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.3"
|
||||
version = "2.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
|
||||
@@ -10,6 +10,7 @@ import path from "path";
|
||||
import {
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
@@ -319,4 +320,21 @@ describe("claude-code", async () => {
|
||||
agentApiUrl: "http://localhost:3284",
|
||||
});
|
||||
});
|
||||
|
||||
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
|
||||
// set in main.tf
|
||||
test("agentapi-allowed-hosts", async () => {
|
||||
const { id } = await setup();
|
||||
|
||||
const respModuleScript = await execModuleScript(id);
|
||||
expect(respModuleScript.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,13 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.2.3"
|
||||
default = "v0.3.3"
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for the Claude Code app."
|
||||
default = true
|
||||
}
|
||||
|
||||
locals {
|
||||
@@ -111,8 +117,17 @@ locals {
|
||||
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
|
||||
agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh"))
|
||||
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
|
||||
claude_code_app_slug = "ccw"
|
||||
// Chat base path is only set if not using a subdomain.
|
||||
// NOTE:
|
||||
// - Initial support for --chat-base-path was added in v0.3.1 but configuration
|
||||
// via environment variable AGENTAPI_CHAT_BASE_PATH was added in v0.3.3.
|
||||
// - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID
|
||||
// for backward compatibility.
|
||||
agentapi_chat_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}/chat"
|
||||
server_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}"
|
||||
healthcheck_url = "http://localhost:3284${local.server_base_path}/status"
|
||||
}
|
||||
|
||||
# Install and Initialize Claude Code
|
||||
@@ -129,6 +144,21 @@ resource "coder_script" "claude_code" {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_claude_code_cli() {
|
||||
echo "Installing Claude Code via official installer"
|
||||
set +e
|
||||
curl -fsSL claude.ai/install.sh | bash -s -- "${var.claude_code_version}" 2>&1
|
||||
CURL_EXIT=$${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Claude Code installer failed with exit code $$CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable.
|
||||
export PATH="~/.local/bin:$PATH"
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
}
|
||||
|
||||
if [ ! -d "${local.workdir}" ]; then
|
||||
echo "Warning: The specified folder '${local.workdir}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
@@ -143,37 +173,7 @@ resource "coder_script" "claude_code" {
|
||||
fi
|
||||
|
||||
if [ "${var.install_claude_code}" = "true" ]; then
|
||||
if ! command_exists npm; then
|
||||
echo "npm not found, checking for Node.js installation..."
|
||||
if ! command_exists node; then
|
||||
echo "Node.js not found, installing Node.js via NVM..."
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
|
||||
echo "Node.js installed: $(node --version)"
|
||||
echo "npm installed: $(npm --version)"
|
||||
else
|
||||
echo "Node.js is installed but npm is not available. Please install npm manually."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "Installing Claude Code..."
|
||||
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
|
||||
fi
|
||||
|
||||
if ! command_exists node; then
|
||||
echo "Error: Node.js is not installed. Please install Node.js manually."
|
||||
exit 1
|
||||
install_claude_code_cli
|
||||
fi
|
||||
|
||||
# Install AgentAPI if enabled
|
||||
@@ -214,7 +214,7 @@ resource "coder_script" "claude_code" {
|
||||
|
||||
echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh"
|
||||
echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
|
||||
@@ -241,6 +241,13 @@ resource "coder_script" "claude_code" {
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${local.workdir}"
|
||||
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
# Set chat base path for non-subdomain routing (only set if not using subdomain)
|
||||
export AGENTAPI_CHAT_BASE_PATH="${local.agentapi_chat_base_path}"
|
||||
|
||||
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
EOT
|
||||
@@ -256,9 +263,9 @@ resource "coder_app" "claude_code_web" {
|
||||
icon = var.icon
|
||||
order = var.order
|
||||
group = var.group
|
||||
subdomain = true
|
||||
subdomain = var.subdomain
|
||||
healthcheck {
|
||||
url = "http://localhost:3284/status"
|
||||
url = local.healthcheck_url
|
||||
interval = 3
|
||||
threshold = 20
|
||||
}
|
||||
@@ -288,4 +295,4 @@ resource "coder_ai_task" "claude_code" {
|
||||
sidebar_app {
|
||||
id = coder_app.claude_code_web.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,33 +9,33 @@ log_file_path="$module_path/agentapi.log"
|
||||
|
||||
# if the first argument is not empty, start claude with the prompt
|
||||
if [ -n "$1" ]; then
|
||||
cp "$module_path/prompt.txt" /tmp/claude-code-prompt
|
||||
cp "$module_path/prompt.txt" /tmp/claude-code-prompt
|
||||
else
|
||||
rm -f /tmp/claude-code-prompt
|
||||
rm -f /tmp/claude-code-prompt
|
||||
fi
|
||||
|
||||
# if the log file already exists, archive it
|
||||
if [ -f "$log_file_path" ]; then
|
||||
mv "$log_file_path" "$log_file_path"".$(date +%s)"
|
||||
mv "$log_file_path" "$log_file_path"".$(date +%s)"
|
||||
fi
|
||||
|
||||
# see the remove-last-session-id.js script for details
|
||||
# see the remove-last-session-id.sh script for details
|
||||
# about why we need it
|
||||
# avoid exiting if the script fails
|
||||
node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true
|
||||
bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2> /dev/null || true
|
||||
|
||||
# we'll be manually handling errors from this point on
|
||||
set +o errexit
|
||||
|
||||
function start_agentapi() {
|
||||
local continue_flag="$1"
|
||||
local prompt_subshell='"$(cat /tmp/claude-code-prompt)"'
|
||||
|
||||
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
|
||||
# visible in the terminal screen by default.
|
||||
agentapi server --term-width 67 --term-height 1190 -- \
|
||||
bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \
|
||||
> "$log_file_path" 2>&1
|
||||
local continue_flag="$1"
|
||||
local prompt_subshell='"$(cat /tmp/claude-code-prompt)"'
|
||||
|
||||
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
|
||||
# visible in the terminal screen by default.
|
||||
agentapi server --term-width 67 --term-height 1190 -- \
|
||||
bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \
|
||||
> "$log_file_path" 2>&1
|
||||
}
|
||||
|
||||
echo "Starting AgentAPI..."
|
||||
@@ -47,15 +47,15 @@ exit_code=$?
|
||||
echo "First AgentAPI exit code: $exit_code"
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
exit 0
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# if there was no conversation to continue, claude exited with an error.
|
||||
# start claude without the --continue flag.
|
||||
if grep -q "No conversation found to continue" "$log_file_path"; then
|
||||
echo "AgentAPI with --continue flag failed, starting claude without it."
|
||||
start_agentapi
|
||||
exit_code=$?
|
||||
echo "AgentAPI with --continue flag failed, starting claude without it."
|
||||
start_agentapi
|
||||
exit_code=$?
|
||||
fi
|
||||
|
||||
echo "Second AgentAPI exit code: $exit_code"
|
||||
|
||||
@@ -9,22 +9,22 @@ agentapi_started=false
|
||||
|
||||
echo "Waiting for agentapi server to start on port 3284..."
|
||||
for i in $(seq 1 150); do
|
||||
for j in $(seq 1 3); do
|
||||
sleep 0.1
|
||||
if curl -fs -o /dev/null "http://localhost:3284/status"; then
|
||||
echo "agentapi response received ($j/3)"
|
||||
else
|
||||
echo "agentapi server not responding ($i/15)"
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
agentapi_started=true
|
||||
break
|
||||
for j in $(seq 1 3); do
|
||||
sleep 0.1
|
||||
if curl -fs -o /dev/null "http://localhost:3284/status"; then
|
||||
echo "agentapi response received ($j/3)"
|
||||
else
|
||||
echo "agentapi server not responding ($i/15)"
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
agentapi_started=true
|
||||
break
|
||||
done
|
||||
|
||||
if [ "$agentapi_started" != "true" ]; then
|
||||
echo "Error: agentapi server did not start on port 3284 after 15 seconds."
|
||||
exit 1
|
||||
echo "Error: agentapi server did not start on port 3284 after 15 seconds."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "agentapi server started on port 3284."
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
// conversation starting from that session. The problem is that lastSessionId
|
||||
// doesn't always point to the last session. The field is updated by claude only
|
||||
// at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
// restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
// will start from an old session.
|
||||
//
|
||||
// If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
// start using the conversation history - even if the CLI previously exited with
|
||||
// an error.
|
||||
//
|
||||
// This script removes the lastSessionId field from .claude.json.
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
|
||||
const workingDirArg = process.argv[2]
|
||||
if (!workingDirArg) {
|
||||
console.log("No working directory provided - it must be the first argument")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const workingDir = path.resolve(workingDirArg)
|
||||
console.log("workingDir", workingDir)
|
||||
|
||||
|
||||
const claudeJsonPath = path.join(process.env.HOME, ".claude.json")
|
||||
console.log(".claude.json path", claudeJsonPath)
|
||||
if (!fs.existsSync(claudeJsonPath)) {
|
||||
console.log("No .claude.json file found")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"))
|
||||
if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) {
|
||||
delete claudeJson.projects[workingDir].lastSessionId
|
||||
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2))
|
||||
console.log("Removed lastSessionId from .claude.json")
|
||||
} else {
|
||||
console.log("No lastSessionId found in .claude.json - nothing to do")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
# conversation starting from that session. The problem is that lastSessionId
|
||||
# doesn't always point to the last session. The field is updated by claude only
|
||||
# at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
# restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
# will start from an old session.
|
||||
#
|
||||
# If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
# start using the conversation history - even if the CLI previously exited with
|
||||
# an error.
|
||||
#
|
||||
# This script removes the lastSessionId field from .claude.json.
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "No working directory provided - it must be the first argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get absolute path of working directory
|
||||
working_dir=$(realpath "$1")
|
||||
echo "workingDir $working_dir"
|
||||
|
||||
# Path to .claude.json
|
||||
claude_json_path="$HOME/.claude.json"
|
||||
echo ".claude.json path $claude_json_path"
|
||||
|
||||
# Check if .claude.json exists
|
||||
if [ ! -f "$claude_json_path" ]; then
|
||||
echo "No .claude.json file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use jq to check if lastSessionId exists for the working directory and remove it
|
||||
|
||||
if jq -e ".projects[\"$working_dir\"].lastSessionId" "$claude_json_path" > /dev/null 2>&1; then
|
||||
# Remove lastSessionId and update the file
|
||||
jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"
|
||||
echo "Removed lastSessionId from .claude.json"
|
||||
else
|
||||
echo "No lastSessionId found in .claude.json - nothing to do"
|
||||
fi
|
||||
@@ -20,6 +20,11 @@ if (
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
|
||||
);
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
|
||||
http
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
run "required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
}
|
||||
|
||||
run "offline_and_use_cached_conflict" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
use_cached = true
|
||||
offline = true
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.code-server
|
||||
]
|
||||
}
|
||||
|
||||
run "offline_disallows_extensions" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
offline = true
|
||||
extensions = ["ms-python.python", "golang.go"]
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.code-server
|
||||
]
|
||||
}
|
||||
|
||||
run "url_with_folder_query" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder/project"
|
||||
port = 13337
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.code-server.url == "http://localhost:13337/?folder=%2Fhome%2Fcoder%2Fproject"
|
||||
error_message = "coder_app URL must include encoded folder query param"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,8 +29,39 @@ module "cursor" {
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
### Configure MCP servers for Cursor
|
||||
|
||||
Provide a JSON-encoded string via the `mcp` input. When set, the module writes the value to `~/.cursor/mcp.json` using a `coder_script` on workspace start.
|
||||
|
||||
The following example configures Cursor to use the GitHub MCP server with authentication facilitated by the [`coder_external_auth`](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) resource.
|
||||
|
||||
```tf
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
"github" : {
|
||||
"url" : "https://api.githubcopilot.com/mcp/",
|
||||
"headers" : {
|
||||
"Authorization" : "Bearer ${data.coder_external_auth.github.access_token}",
|
||||
},
|
||||
"type" : "http"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
data "coder_external_auth" "github" {
|
||||
id = "github"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
runContainer,
|
||||
execContainer,
|
||||
removeContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
} from "~test";
|
||||
|
||||
describe("cursor", async () => {
|
||||
@@ -85,4 +90,32 @@ describe("cursor", async () => {
|
||||
expect(coder_app?.instances.length).toBe(1);
|
||||
expect(coder_app?.instances[0].attributes.order).toBe(22);
|
||||
});
|
||||
|
||||
it("writes ~/.cursor/mcp.json when mcp provided", async () => {
|
||||
const id = await runContainer("alpine");
|
||||
try {
|
||||
const mcp = JSON.stringify({
|
||||
servers: { demo: { url: "http://localhost:1234" } },
|
||||
});
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
mcp,
|
||||
});
|
||||
const script = findResourceInstance(
|
||||
state,
|
||||
"coder_script",
|
||||
"cursor_mcp",
|
||||
).script;
|
||||
const resp = await execContainer(id, ["sh", "-c", script]);
|
||||
if (resp.exitCode !== 0) {
|
||||
console.log(resp.stdout);
|
||||
console.log(resp.stderr);
|
||||
}
|
||||
expect(resp.exitCode).toBe(0);
|
||||
const content = await readFileContainer(id, "/root/.cursor/mcp.json");
|
||||
expect(content).toBe(mcp);
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,9 +50,20 @@ variable "display_name" {
|
||||
default = "Cursor Desktop"
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "JSON-encoded string to configure MCP servers for Cursor. When set, writes ~/.cursor/mcp.json."
|
||||
default = ""
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
mcp_b64 = var.mcp != "" ? base64encode(var.mcp) : ""
|
||||
}
|
||||
|
||||
resource "coder_app" "cursor" {
|
||||
agent_id = var.agent_id
|
||||
external = true
|
||||
@@ -75,6 +86,22 @@ resource "coder_app" "cursor" {
|
||||
])
|
||||
}
|
||||
|
||||
resource "coder_script" "cursor_mcp" {
|
||||
count = var.mcp != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
display_name = "Cursor MCP"
|
||||
icon = "/icon/cursor.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
script = <<-EOT
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mkdir -p "$HOME/.cursor"
|
||||
echo -n "${local.mcp_b64}" | base64 -d > "$HOME/.cursor/mcp.json"
|
||||
chmod 600 "$HOME/.cursor/mcp.json"
|
||||
EOT
|
||||
}
|
||||
|
||||
output "cursor_url" {
|
||||
value = coder_app.cursor.url
|
||||
description = "Cursor IDE Desktop URL."
|
||||
|
||||
@@ -15,7 +15,7 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl
|
||||
```tf
|
||||
module "devcontainers-cli" {
|
||||
source = "registry.coder.com/coder/devcontainers-cli/coder"
|
||||
version = "1.0.31"
|
||||
version = "1.0.32"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
@@ -45,6 +45,8 @@ const executeScriptInContainerWithPackageManager = async (
|
||||
|
||||
console.log(path);
|
||||
|
||||
await execContainer(id, [shell, "-c", "mkdir -p /tmp/coder-script-data"]);
|
||||
|
||||
const resp = await execContainer(
|
||||
id,
|
||||
[shell, "-c", instance.script],
|
||||
@@ -52,6 +54,8 @@ const executeScriptInContainerWithPackageManager = async (
|
||||
"--env",
|
||||
"CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin",
|
||||
"--env",
|
||||
"CODER_SCRIPT_DATA_DIR=/tmp/coder-script-data",
|
||||
"--env",
|
||||
`PATH=${path}:/tmp/coder-script-data/bin`,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,55 +1,61 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# We want to cd into `$CODER_SCRIPT_DATA_DIR` as the current directory
|
||||
# might contain a `package.json` with `packageManager` set to something
|
||||
# other than the detected package manager. When this happens, it can
|
||||
# cause the installation to fail.
|
||||
cd "$CODER_SCRIPT_DATA_DIR"
|
||||
|
||||
# If @devcontainers/cli is already installed, we can skip
|
||||
if command -v devcontainer >/dev/null 2>&1; then
|
||||
echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!"
|
||||
exit 0
|
||||
if command -v devcontainer > /dev/null 2>&1; then
|
||||
echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if docker is installed
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available."
|
||||
if ! command -v docker > /dev/null 2>&1; then
|
||||
echo "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available."
|
||||
fi
|
||||
|
||||
# Determine the package manager to use: npm, pnpm, or yarn
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
PACKAGE_MANAGER="yarn"
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
PACKAGE_MANAGER="npm"
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
PACKAGE_MANAGER="pnpm"
|
||||
if command -v yarn > /dev/null 2>&1; then
|
||||
PACKAGE_MANAGER="yarn"
|
||||
elif command -v npm > /dev/null 2>&1; then
|
||||
PACKAGE_MANAGER="npm"
|
||||
elif command -v pnpm > /dev/null 2>&1; then
|
||||
PACKAGE_MANAGER="pnpm"
|
||||
else
|
||||
echo "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first." 1>&2
|
||||
exit 1
|
||||
echo "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first." 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install() {
|
||||
echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..."
|
||||
if [ "$PACKAGE_MANAGER" = "npm" ]; then
|
||||
npm install -g @devcontainers/cli
|
||||
elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then
|
||||
# Check if PNPM_HOME is set, if not, set it to the script's bin directory
|
||||
# pnpm needs this to be set to install binaries
|
||||
# coder agent ensures this part is part of the PATH
|
||||
# so that the devcontainer command is available
|
||||
if [ -z "$PNPM_HOME" ]; then
|
||||
PNPM_HOME="$CODER_SCRIPT_BIN_DIR"
|
||||
export M_HOME
|
||||
fi
|
||||
pnpm add -g @devcontainers/cli
|
||||
elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
|
||||
yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")"
|
||||
echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..."
|
||||
if [ "$PACKAGE_MANAGER" = "npm" ]; then
|
||||
npm install -g @devcontainers/cli
|
||||
elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then
|
||||
# Check if PNPM_HOME is set, if not, set it to the script's bin directory
|
||||
# pnpm needs this to be set to install binaries
|
||||
# coder agent ensures this part is part of the PATH
|
||||
# so that the devcontainer command is available
|
||||
if [ -z "$PNPM_HOME" ]; then
|
||||
PNPM_HOME="$CODER_SCRIPT_BIN_DIR"
|
||||
export PNPM_HOME
|
||||
fi
|
||||
pnpm add -g @devcontainers/cli
|
||||
elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
|
||||
yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")"
|
||||
fi
|
||||
}
|
||||
|
||||
if ! install; then
|
||||
echo "Failed to install @devcontainers/cli" >&2
|
||||
exit 1
|
||||
echo "Failed to install @devcontainers/cli" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v devcontainer >/dev/null 2>&1; then
|
||||
echo "Installation completed but 'devcontainer' command not found in PATH" >&2
|
||||
exit 1
|
||||
if ! command -v devcontainer > /dev/null 2>&1; then
|
||||
echo "Installation completed but 'devcontainer' command not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🥳 @devcontainers/cli has been installed into $(which devcontainer)!"
|
||||
|
||||