feat(desktop): remove packages/desktop after OpenWork fork; keep the Tauri upgrade bridge (#9085)
OpenWork (modelstudioai/openwork) has forked the Electron desktop code and is self-contained now, so retire the Electron package and its release/sync machinery from this repo: - Delete packages/desktop (Electron app, live-host app, bun workspace). - Retire scripts/desktop-openwork-sync.ts and the desktop-openwork-sync root script; the OpenWork sync is no longer needed. - Retire .github/workflows/live-host.yml, live-host-release.yml and sync-live-host-to-oss.yml; live-host releases now live in OpenWork. The CLI-side packages/cli/src/serve/live code stays for now (separate cleanup). - Retire scripts/check-voice-guard-sync.js (cli<->desktop parity only) and its CI step. - Clean up remaining references: root package.json workspaces negation and package-lock.json, eslint/prettier/yamllint ignores, architecture docs, web-shell skill descriptions, and review-lib workspace fixtures/comments (renamed to point at packages/desktop-shell, the remaining negation). Deliberately kept: the Electron->Tauri upgrade bridge — desktop-release.yml (incl. the electron_bridge input), create-electron-bridge-manifest.mjs, sync-desktop-to-oss.yml (mirrors Tauri desktop-shell artifacts only) and everything under packages/desktop-shell.
No files matched your search
@@ -26,8 +26,6 @@
|
||||
10005 dsw-swe-verified-release.yml
|
||||
12340 e2e.yml
|
||||
11394 finalize-release.yml
|
||||
15871 live-host-release.yml
|
||||
6384 live-host.yml
|
||||
7642 main-ci-failure-issue.yml
|
||||
1686 npm-cache.yml
|
||||
7299 pr-force-push-reminder.yml
|
||||
@@ -56,7 +54,6 @@
|
||||
17013 serve-ab.yml
|
||||
2641 stale.yml
|
||||
10920 sync-desktop-to-oss.yml
|
||||
10018 sync-live-host-to-oss.yml
|
||||
10138 sync-release-to-oss.yml
|
||||
3303 update-ecs-runner-qwen.yml
|
||||
2307 web-shell-visuals-cleanup.yml
|
||||
|
||||
@@ -398,10 +398,6 @@ jobs:
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
run: 'npm run check:desktop-isolation'
|
||||
|
||||
- name: 'Check voice guard mirror sync'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
run: 'npm run check:voice-guard-sync'
|
||||
|
||||
- name: 'Install linters'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
|
||||
run: 'node scripts/lint.js --setup'
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
name: 'Qwen Live Host Release'
|
||||
|
||||
run-name: "Qwen Live Host release ${{ inputs.version || format('PR #{0} dry run', github.event.pull_request.number) }}"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/live-host-release.yml'
|
||||
- 'packages/desktop/apps/live-host/**'
|
||||
- 'packages/desktop/bun.lock'
|
||||
- 'packages/desktop/package.json'
|
||||
- 'packages/desktop/scripts/bump-live-host-version.ts'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Live Host version, for example 0.1.0 or v0.1.0.'
|
||||
required: true
|
||||
type: 'string'
|
||||
dry_run:
|
||||
description: 'Build unsigned packages without publishing.'
|
||||
required: true
|
||||
default: true
|
||||
type: 'boolean'
|
||||
draft:
|
||||
description: 'Create a draft GitHub release.'
|
||||
required: true
|
||||
default: true
|
||||
type: 'boolean'
|
||||
prerelease:
|
||||
description: 'Mark the release as a prerelease.'
|
||||
required: true
|
||||
default: false
|
||||
type: 'boolean'
|
||||
clobber:
|
||||
description: 'Replace same-named assets in an existing release.'
|
||||
required: true
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
permissions:
|
||||
actions: 'read'
|
||||
contents: 'read'
|
||||
|
||||
concurrency:
|
||||
group: 'live-host-release-${{ github.event.pull_request.number || inputs.version }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BUN_VERSION: '1.3.9'
|
||||
NODE_VERSION: '22.20.0'
|
||||
LIVE_HOST_FEED_TAG: 'live-host-latest'
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: 'Prepare Live Host release'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
tag: '${{ steps.version.outputs.tag }}'
|
||||
version: '${{ steps.version.outputs.version }}'
|
||||
steps:
|
||||
- name: 'Resolve version'
|
||||
id: 'version'
|
||||
shell: 'bash'
|
||||
env:
|
||||
INPUT_VERSION: '${{ inputs.version }}'
|
||||
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$GITHUB_EVENT_NAME" = 'pull_request' ]; then
|
||||
version="0.0.$PR_NUMBER"
|
||||
else
|
||||
version="${INPUT_VERSION#v}"
|
||||
fi
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Live Host version must be valid SemVer: $INPUT_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$version" == *+* ]]; then
|
||||
echo '::error::Live Host releases do not support SemVer build metadata.'
|
||||
exit 1
|
||||
fi
|
||||
if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ] && [ "${{ inputs.dry_run }}" = 'false' ] && [ "$GITHUB_REF_NAME" != 'main' ]; then
|
||||
echo '::error::Published Live Host releases must run from main.'
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=live-host-v$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: 'Build Qwen Live Host'
|
||||
needs: 'prepare'
|
||||
runs-on: 'macos-latest'
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version: '${{ env.NODE_VERSION }}'
|
||||
|
||||
- uses: 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' # v2
|
||||
with:
|
||||
bun-version: '${{ env.BUN_VERSION }}'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun install --frozen-lockfile'
|
||||
|
||||
- name: 'Set Live Host version'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run bump-live-host-version "${{ needs.prepare.outputs.version }}"'
|
||||
|
||||
- name: 'Test Live Host'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run live-host:typecheck && bun run live-host:test'
|
||||
|
||||
- name: 'Import macOS certificate'
|
||||
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false }}"
|
||||
shell: 'bash'
|
||||
env:
|
||||
APPLE_CERTIFICATE: '${{ secrets.APPLE_CERTIFICATE }}'
|
||||
APPLE_CERTIFICATE_PASSWORD: '${{ secrets.APPLE_CERTIFICATE_PASSWORD }}'
|
||||
LEGACY_APPLE_CERTIFICATE: '${{ secrets.MAC_CSC_LINK }}'
|
||||
LEGACY_APPLE_CERTIFICATE_PASSWORD: '${{ secrets.MAC_CSC_KEY_PASSWORD }}'
|
||||
KEYCHAIN_PASSWORD: '${{ secrets.APPLE_KEYCHAIN_PASSWORD }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$APPLE_CERTIFICATE" ] && [ -n "$APPLE_CERTIFICATE_PASSWORD" ]; then
|
||||
certificate_data="$APPLE_CERTIFICATE"
|
||||
certificate_password="$APPLE_CERTIFICATE_PASSWORD"
|
||||
elif [ -n "$LEGACY_APPLE_CERTIFICATE" ] && [ -n "$LEGACY_APPLE_CERTIFICATE_PASSWORD" ]; then
|
||||
certificate_data="$LEGACY_APPLE_CERTIFICATE"
|
||||
certificate_password="$LEGACY_APPLE_CERTIFICATE_PASSWORD"
|
||||
else
|
||||
echo '::error::A complete APPLE_CERTIFICATE pair or MAC_CSC pair is required for published Qwen Live Host releases.'
|
||||
exit 1
|
||||
fi
|
||||
keychain_password="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 32)}"
|
||||
certificate="$RUNNER_TEMP/qwen-live-host.p12"
|
||||
keychain="$RUNNER_TEMP/qwen-live-host.keychain-db"
|
||||
certificate_data="${certificate_data#*base64,}"
|
||||
printf '%s' "$certificate_data" | base64 --decode > "$certificate"
|
||||
security create-keychain -p "$keychain_password" "$keychain"
|
||||
security set-keychain-settings -lut 21600 "$keychain"
|
||||
security unlock-keychain -p "$keychain_password" "$keychain"
|
||||
security import "$certificate" -P "$certificate_password" -A -t cert -f pkcs12 -k "$keychain"
|
||||
security list-keychains -d user -s "$keychain" login.keychain-db
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain"
|
||||
identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)"
|
||||
if [ -z "$identity" ]; then echo '::error::Developer ID Application identity was not found.'; exit 1; fi
|
||||
identity_name="${identity#Developer ID Application: }"
|
||||
{
|
||||
echo "CSC_NAME=$identity_name"
|
||||
echo 'CSC_IDENTITY_AUTO_DISCOVERY=true'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 'Configure notarization'
|
||||
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false }}"
|
||||
shell: 'bash'
|
||||
env:
|
||||
APPLE_API_ISSUER: '${{ secrets.APPLE_API_ISSUER }}'
|
||||
APPLE_API_KEY_ID: '${{ secrets.APPLE_API_KEY }}'
|
||||
APPLE_API_KEY_P8: '${{ secrets.APPLE_API_KEY_P8 }}'
|
||||
LEGACY_APPLE_API_ISSUER: '${{ secrets.APPLE_NOTARY_ISSUER_ID }}'
|
||||
LEGACY_APPLE_API_KEY_ID: '${{ secrets.APPLE_NOTARY_KEY_ID }}'
|
||||
LEGACY_APPLE_API_KEY_P8: '${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }}'
|
||||
APPLE_TEAM_ID: '${{ secrets.APPLE_TEAM_ID }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$APPLE_API_ISSUER" ] && [ -n "$APPLE_API_KEY_ID" ] && [ -n "$APPLE_API_KEY_P8" ]; then
|
||||
api_issuer="$APPLE_API_ISSUER"
|
||||
api_key_id="$APPLE_API_KEY_ID"
|
||||
key_path="$RUNNER_TEMP/AuthKey_${api_key_id}.p8"
|
||||
printf '%s' "$APPLE_API_KEY_P8" > "$key_path"
|
||||
elif [ -n "$LEGACY_APPLE_API_ISSUER" ] && [ -n "$LEGACY_APPLE_API_KEY_ID" ] && [ -n "$LEGACY_APPLE_API_KEY_P8" ]; then
|
||||
api_issuer="$LEGACY_APPLE_API_ISSUER"
|
||||
api_key_id="$LEGACY_APPLE_API_KEY_ID"
|
||||
key_path="$RUNNER_TEMP/AuthKey_${api_key_id}.p8"
|
||||
printf '%s' "$LEGACY_APPLE_API_KEY_P8" | base64 --decode > "$key_path"
|
||||
else
|
||||
echo '::error::A complete APPLE_API notarization set or APPLE_NOTARY set is required for Qwen Live Host notarization.'
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$APPLE_TEAM_ID" ]; then echo '::error::APPLE_TEAM_ID is required for Qwen Live Host notarization.'; exit 1; fi
|
||||
{
|
||||
echo "APPLE_API_KEY=$key_path"
|
||||
echo "APPLE_API_KEY_ID=$api_key_id"
|
||||
echo "APPLE_API_ISSUER=$api_issuer"
|
||||
echo "APPLE_TEAM_ID=$APPLE_TEAM_ID"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 'Build packages'
|
||||
working-directory: 'packages/desktop'
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false }}"
|
||||
run: 'bun run live-host:dist:mac:no-publish'
|
||||
|
||||
- name: 'Verify release assets'
|
||||
working-directory: 'packages/desktop/apps/live-host'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for asset in \
|
||||
Qwen-Live-Host-arm64.dmg \
|
||||
Qwen-Live-Host-x64.dmg \
|
||||
Qwen-Live-Host-arm64.zip \
|
||||
Qwen-Live-Host-x64.zip \
|
||||
Qwen-Live-Host-manifest.json; do
|
||||
test -f "release/$asset"
|
||||
done
|
||||
node --input-type=module -e '
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
const manifest = JSON.parse(readFileSync("release/Qwen-Live-Host-manifest.json", "utf8"));
|
||||
if (manifest.version !== process.env.RELEASE_VERSION) {
|
||||
throw new Error("Manifest version " + manifest.version + " does not match " + process.env.RELEASE_VERSION + ".");
|
||||
}
|
||||
for (const architecture of ["arm64", "x64"]) {
|
||||
const name = "Qwen-Live-Host-" + architecture + ".zip";
|
||||
const path = "release/" + name;
|
||||
const bytes = readFileSync(path);
|
||||
const asset = manifest.assets?.[architecture];
|
||||
if (asset?.name !== name || asset.size !== statSync(path).size || asset.sha256 !== createHash("sha256").update(bytes).digest("hex")) {
|
||||
throw new Error("Manifest asset verification failed for " + architecture + ".");
|
||||
}
|
||||
}
|
||||
'
|
||||
|
||||
- name: 'Verify signing and notarization'
|
||||
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false }}"
|
||||
working-directory: 'packages/desktop'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_count=0
|
||||
while IFS= read -r -d '' app; do
|
||||
app_count=$((app_count + 1))
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
signature="$(codesign -dv --verbose=4 "$app" 2>&1)"
|
||||
if ! grep -q '^Authority=Developer ID Application:' <<<"$signature" || ! grep -qx 'TeamIdentifier=NF4574S59H' <<<"$signature"; then
|
||||
echo '::error::Qwen Live Host was not signed by the expected Developer ID team.'
|
||||
exit 1
|
||||
fi
|
||||
spctl -a -vv -t exec "$app"
|
||||
xcrun stapler validate "$app"
|
||||
done < <(find apps/live-host/release -mindepth 2 -maxdepth 2 -type d -name '*.app' -print0)
|
||||
if [ "$app_count" -eq 0 ]; then echo '::error::No packaged Qwen Live Host application was found.'; exit 1; fi
|
||||
dmg_count=0
|
||||
while IFS= read -r -d '' dmg; do
|
||||
dmg_count=$((dmg_count + 1))
|
||||
hdiutil verify "$dmg"
|
||||
done < <(find apps/live-host/release -type f -name '*.dmg' -print0)
|
||||
if [ "$dmg_count" -eq 0 ]; then echo '::error::No Qwen Live Host disk image was found.'; exit 1; fi
|
||||
|
||||
- uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'qwen-live-host-macos'
|
||||
path: |
|
||||
packages/desktop/apps/live-host/release/*.dmg
|
||||
packages/desktop/apps/live-host/release/*-manifest.json
|
||||
packages/desktop/apps/live-host/release/*.zip
|
||||
if-no-files-found: 'error'
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
name: 'Publish Qwen Live Host release'
|
||||
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false && github.repository == 'QwenLM/qwen-code' }}"
|
||||
needs:
|
||||
- 'prepare'
|
||||
- 'build'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: 'write'
|
||||
env:
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
steps:
|
||||
- uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
path: 'release-assets'
|
||||
merge-multiple: true
|
||||
|
||||
- name: 'Generate checksums'
|
||||
working-directory: 'release-assets'
|
||||
run: 'sha256sum -- * > SHA256SUMS.txt'
|
||||
|
||||
- name: 'Create GitHub release'
|
||||
id: 'release'
|
||||
env:
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
RELEASE_TAG: '${{ needs.prepare.outputs.tag }}'
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
|
||||
RELEASE_DRAFT: '${{ inputs.draft }}'
|
||||
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
|
||||
RELEASE_CLOBBER: '${{ inputs.clobber }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=("$RELEASE_TAG" release-assets/* --target "$GITHUB_SHA" --title "Qwen Live Host v$RELEASE_VERSION" --generate-notes --latest=false)
|
||||
if [ "$RELEASE_DRAFT" = 'true' ]; then args+=(--draft); fi
|
||||
if [ "$RELEASE_PRERELEASE" = 'true' ]; then args+=(--prerelease); fi
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
if [ "$RELEASE_CLOBBER" != 'true' ]; then
|
||||
echo "::error::Release $RELEASE_TAG already exists. Re-run with clobber=true to replace its assets."
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" release-assets/* --clobber
|
||||
edit_args=()
|
||||
if [ "$RELEASE_DRAFT" = 'true' ]; then edit_args+=(--draft); else edit_args+=(--draft=false); fi
|
||||
if [ "$RELEASE_PRERELEASE" = 'true' ]; then edit_args+=(--prerelease); else edit_args+=(--prerelease=false); fi
|
||||
gh release edit "$RELEASE_TAG" "${edit_args[@]}"
|
||||
release_url="$(gh release view "$RELEASE_TAG" --json url --jq '.url')"
|
||||
else
|
||||
release_url="$(gh release create "${args[@]}")"
|
||||
fi
|
||||
echo "url=$release_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Update stable Live Host feed'
|
||||
if: '${{ inputs.draft == false && inputs.prerelease == false }}'
|
||||
env:
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
FEED_TAG: '${{ env.LIVE_HOST_FEED_TAG }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stable_assets=(
|
||||
release-assets/Qwen-Live-Host-manifest.json
|
||||
release-assets/Qwen-Live-Host-arm64.zip
|
||||
release-assets/Qwen-Live-Host-x64.zip
|
||||
)
|
||||
if gh release view "$FEED_TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$FEED_TAG" "${stable_assets[@]}" --clobber
|
||||
else
|
||||
gh release create "$FEED_TAG" "${stable_assets[@]}" --title 'Qwen Live Host latest' --notes 'Stable Qwen Live Host installer feed.' --latest=false
|
||||
fi
|
||||
|
||||
- name: 'Publish release summary'
|
||||
env:
|
||||
RELEASE_URL: '${{ steps.release.outputs.url }}'
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
|
||||
run: |
|
||||
{
|
||||
echo '## Qwen Live Host release'
|
||||
echo
|
||||
echo "Version: $RELEASE_VERSION"
|
||||
echo "Release: $RELEASE_URL"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
sync-oss:
|
||||
name: 'Mirror stable Qwen Live Host release to Aliyun OSS'
|
||||
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false && github.repository == 'QwenLM/qwen-code' }}"
|
||||
needs:
|
||||
- 'prepare'
|
||||
- 'build'
|
||||
- 'publish'
|
||||
uses: './.github/workflows/sync-live-host-to-oss.yml'
|
||||
with:
|
||||
version: '${{ needs.prepare.outputs.version }}'
|
||||
source: 'artifact'
|
||||
secrets:
|
||||
ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}'
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}'
|
||||
@@ -1,148 +0,0 @@
|
||||
name: 'Qwen Live Host CI'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/live-host.yml'
|
||||
- '.github/workflows/live-host-release.yml'
|
||||
- 'packages/cli/src/serve/conversations/**'
|
||||
- 'packages/cli/src/serve/live/**'
|
||||
- 'packages/desktop/apps/live-host/**'
|
||||
- 'packages/desktop/bun.lock'
|
||||
- 'packages/desktop/package.json'
|
||||
- 'packages/desktop/scripts/bump-live-host-version.ts'
|
||||
- 'packages/sdk-typescript/src/daemon/types.ts'
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: 'Live Host (macos-latest)'
|
||||
runs-on: 'macos-latest'
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: 'Check out source'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- name: 'Set up Node'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: 'Set up Bun'
|
||||
uses: 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' # v2
|
||||
with:
|
||||
bun-version: '1.3.9'
|
||||
|
||||
- name: 'Install desktop dependencies'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun install --frozen-lockfile'
|
||||
|
||||
- name: 'Typecheck Live Host'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run live-host:typecheck'
|
||||
|
||||
- name: 'Test Live Host'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run live-host:test'
|
||||
|
||||
- name: 'Build Live Host'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run live-host:build'
|
||||
|
||||
- name: 'Package unsigned Live Host app'
|
||||
working-directory: 'packages/desktop/apps/live-host'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$(uname -m)" in
|
||||
arm64) builder_arch='--arm64' ;;
|
||||
x86_64) builder_arch='--x64' ;;
|
||||
*) echo "::error::Unsupported runner architecture: $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
CSC_IDENTITY_AUTO_DISCOVERY=false bunx electron-builder --config electron-builder.yml --mac dir "$builder_arch"
|
||||
|
||||
app="$(find release -type d -name 'Qwen Live Host.app' -print -quit)"
|
||||
if [ -z "$app" ]; then
|
||||
echo '::error::Packaged Qwen Live Host.app was not found.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -x "$app/Contents/MacOS/Qwen Live Host"
|
||||
test -f "$app/Contents/Resources/app.asar"
|
||||
|
||||
asar_entries="$(node ../../node_modules/@electron/asar/bin/asar.mjs list "$app/Contents/Resources/app.asar")"
|
||||
if printf '%s\n' "$asar_entries" | grep -Eiq '(^|/)(native/|.*command[-_]?monitor|.*commandtap)'; then
|
||||
echo '::error::Packaged app.asar contains a removed keyboard monitor or native helper.'
|
||||
exit 1
|
||||
fi
|
||||
native_files="$(find "$app/Contents/Resources/native" -type f -print 2>/dev/null || true)"
|
||||
expected_native="$app/Contents/Resources/native/qwen-live-appshot.node"
|
||||
if [ "$native_files" != "$expected_native" ] || find "$app/Contents/Resources" -type f -print | grep -Eiq 'command[-_]?monitor|commandtap'; then
|
||||
echo '::error::Packaged resources do not contain exactly the built-in Appshot module.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
extracted_asar="$(mktemp -d)"
|
||||
packaged_entitlements="$(mktemp)"
|
||||
trap 'rm -rf "$extracted_asar"; rm -f "$packaged_entitlements"' EXIT
|
||||
node ../../node_modules/@electron/asar/bin/asar.mjs extract "$app/Contents/Resources/app.asar" "$extracted_asar"
|
||||
test -d "$extracted_asar/dist"
|
||||
if grep -RIEq 'openWebShellWindow|web-shell-security|host\.open_session|inputMonitoring|installUrl|loadURL\(|@modelcontextprotocol|node:child_process' "$extracted_asar/dist"; then
|
||||
echo '::error::Packaged app contains a forbidden external backend, process launcher, WebShell window, or Input Monitoring path.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info_plist="$app/Contents/Info.plist"
|
||||
if /usr/libexec/PlistBuddy -c 'Print :NSInputMonitoringUsageDescription' "$info_plist" >/dev/null 2>&1; then
|
||||
echo '::error::Packaged app declares an Input Monitoring usage description.'
|
||||
exit 1
|
||||
fi
|
||||
for unused_permission in NSBluetoothAlwaysUsageDescription NSBluetoothPeripheralUsageDescription NSCameraUsageDescription; do
|
||||
if /usr/libexec/PlistBuddy -c "Print :$unused_permission" "$info_plist" >/dev/null 2>&1; then
|
||||
echo "::error::Packaged app declares unused permission $unused_permission."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
/usr/libexec/PlistBuddy -c 'Print :NSMicrophoneUsageDescription' "$info_plist" >/dev/null
|
||||
|
||||
assert_live_host_entitlements() {
|
||||
local entitlements_file="$1"
|
||||
/usr/bin/plutil -convert json -o - "$entitlements_file" | node -e '
|
||||
const chunks = [];
|
||||
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
||||
process.stdin.on("end", () => {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
const expected = [
|
||||
"com.apple.security.cs.allow-jit",
|
||||
"com.apple.security.cs.allow-unsigned-executable-memory",
|
||||
"com.apple.security.device.audio-input",
|
||||
];
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index]) ||
|
||||
expected.some((key) => value[key] !== true)
|
||||
) {
|
||||
console.error(
|
||||
"Unexpected Live Host entitlements: " + JSON.stringify(value),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
'
|
||||
}
|
||||
|
||||
assert_live_host_entitlements build/entitlements.mac.plist
|
||||
/usr/bin/codesign --force --deep --sign - --entitlements build/entitlements.mac.plist "$app"
|
||||
/usr/bin/codesign --verify --deep --strict "$app"
|
||||
/usr/bin/codesign -d --entitlements :- "$app" >"$packaged_entitlements" 2>/dev/null
|
||||
assert_live_host_entitlements "$packaged_entitlements"
|
||||
@@ -1,222 +0,0 @@
|
||||
name: 'Sync Qwen Live Host to Aliyun OSS'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: 'string'
|
||||
source:
|
||||
required: true
|
||||
type: 'string'
|
||||
secrets:
|
||||
ALIYUN_OSS_ACCESS_KEY_ID:
|
||||
required: true
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Stable Live Host version to mirror, for example 0.1.0 or v0.1.0.'
|
||||
required: true
|
||||
type: 'string'
|
||||
source:
|
||||
description: 'Download the assets from the matching GitHub release.'
|
||||
required: true
|
||||
default: 'release'
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'release'
|
||||
|
||||
concurrency:
|
||||
group: 'sync-live-host-to-oss'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: 'Mirror Qwen Live Host to Aliyun OSS'
|
||||
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: 'production-release'
|
||||
permissions:
|
||||
actions: 'read'
|
||||
contents: 'read'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- name: 'Resolve release'
|
||||
id: 'release'
|
||||
env:
|
||||
INPUT_VERSION: '${{ inputs.version }}'
|
||||
INPUT_SOURCE: '${{ inputs.source }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${INPUT_VERSION#v}"
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Live Host OSS mirrors require a stable X.Y.Z version (got '$INPUT_VERSION')."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$INPUT_SOURCE" != 'artifact' && "$INPUT_SOURCE" != 'release' ]]; then
|
||||
echo "::error::Live Host mirror source must be artifact or release (got '$INPUT_SOURCE')."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "source=$INPUT_SOURCE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Download release workflow artifact'
|
||||
if: "${{ steps.release.outputs.source == 'artifact' }}"
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
name: 'qwen-live-host-macos'
|
||||
path: 'dist/live-host'
|
||||
|
||||
- name: 'Download GitHub release assets'
|
||||
if: "${{ steps.release.outputs.source == 'release' }}"
|
||||
env:
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
VERSION: '${{ steps.release.outputs.version }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
metadata="$(gh release view "live-host-v${VERSION}" --json isDraft,isPrerelease)"
|
||||
if ! jq -e '.isDraft == false and .isPrerelease == false' <<<"$metadata" >/dev/null; then
|
||||
echo "::error::live-host-v${VERSION} is not a published stable release."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p dist/live-host
|
||||
gh release download "live-host-v${VERSION}" \
|
||||
--dir dist/live-host \
|
||||
--pattern 'Qwen-Live-Host-manifest.json' \
|
||||
--pattern 'Qwen-Live-Host-arm64.zip' \
|
||||
--pattern 'Qwen-Live-Host-x64.zip'
|
||||
|
||||
- name: 'Verify release assets'
|
||||
env:
|
||||
VERSION: '${{ steps.release.outputs.version }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# shellcheck disable=SC2016
|
||||
node --input-type=module -e '
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
const directory = "dist/live-host";
|
||||
const manifest = JSON.parse(readFileSync(`${directory}/Qwen-Live-Host-manifest.json`, "utf8"));
|
||||
if (manifest.version !== process.env.VERSION) throw new Error(`Manifest version ${manifest.version} does not match ${process.env.VERSION}.`);
|
||||
for (const architecture of ["arm64", "x64"]) {
|
||||
const name = `Qwen-Live-Host-${architecture}.zip`;
|
||||
const file = `${directory}/${name}`;
|
||||
const asset = manifest.assets?.[architecture];
|
||||
const checksum = createHash("sha256").update(readFileSync(file)).digest("hex");
|
||||
if (asset?.name !== name || asset.size !== statSync(file).size || asset.sha256 !== checksum) throw new Error(`Manifest asset verification failed for ${architecture}.`);
|
||||
}
|
||||
'
|
||||
|
||||
- name: 'Install ossutil'
|
||||
env:
|
||||
OSSUTIL_URL: "${{ vars.OSSUTIL_URL || 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' }}"
|
||||
OSSUTIL_SHA256: "${{ vars.OSSUTIL_SHA256 || 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tmp_dir="$(mktemp -d)"
|
||||
curl -fsSL --connect-timeout 15 --max-time 300 "$OSSUTIL_URL" -o "$tmp_dir/ossutil.zip"
|
||||
echo "$OSSUTIL_SHA256 $tmp_dir/ossutil.zip" | sha256sum -c -
|
||||
unzip -q "$tmp_dir/ossutil.zip" -d "$tmp_dir"
|
||||
ossutil_path="$(find "$tmp_dir" -type f \( -name 'ossutil' -o -name 'ossutil64' \) -print -quit)"
|
||||
if [[ -z "$ossutil_path" ]]; then echo '::error::ossutil binary not found'; exit 1; fi
|
||||
chmod +x "$ossutil_path"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
install -m 0755 "$ossutil_path" "$HOME/.local/bin/ossutil"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
rm -rf "$tmp_dir"
|
||||
"$HOME/.local/bin/ossutil" >/dev/null
|
||||
|
||||
- name: 'Configure Aliyun OSS credentials'
|
||||
env:
|
||||
ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}'
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}'
|
||||
ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$ALIYUN_OSS_ACCESS_KEY_ID" || -z "$ALIYUN_OSS_ACCESS_KEY_SECRET" ]]; then
|
||||
echo '::error::Missing Aliyun OSS credentials in the production-release environment.'
|
||||
exit 1
|
||||
fi
|
||||
ossutil config -e "$ALIYUN_OSS_ENDPOINT" -i "$ALIYUN_OSS_ACCESS_KEY_ID" -k "$ALIYUN_OSS_ACCESS_KEY_SECRET" -L EN -c "$RUNNER_TEMP/.ossutilconfig"
|
||||
|
||||
- name: 'Upload versioned assets to Aliyun OSS'
|
||||
env:
|
||||
ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}"
|
||||
VERSION: '${{ steps.release.outputs.version }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/upload-aliyun-oss-assets.js \
|
||||
--bucket "$ALIYUN_OSS_BUCKET" \
|
||||
--config "$RUNNER_TEMP/.ossutilconfig" \
|
||||
--prefix "live-host/v${VERSION}" \
|
||||
dist/live-host/Qwen-Live-Host-arm64.zip \
|
||||
dist/live-host/Qwen-Live-Host-x64.zip \
|
||||
dist/live-host/Qwen-Live-Host-manifest.json
|
||||
|
||||
- name: 'Verify versioned assets on Aliyun OSS'
|
||||
env:
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}"
|
||||
VERSION: '${{ steps.release.outputs.version }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="$ALIYUN_OSS_PUBLIC_BASE_URL/live-host/v${VERSION}"
|
||||
directory="$(mktemp -d)"
|
||||
trap 'rm -rf "$directory"' EXIT
|
||||
for asset in Qwen-Live-Host-manifest.json Qwen-Live-Host-arm64.zip Qwen-Live-Host-x64.zip; do
|
||||
curl -fsSL --connect-timeout 15 --max-time 3600 "$base/$asset" -o "$directory/$asset"
|
||||
done
|
||||
# shellcheck disable=SC2016
|
||||
VERSION="$VERSION" DIRECTORY="$directory" node --input-type=module -e '
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
const manifest = JSON.parse(readFileSync(`${process.env.DIRECTORY}/Qwen-Live-Host-manifest.json`, "utf8"));
|
||||
if (manifest.version !== process.env.VERSION) throw new Error("Mirrored manifest version mismatch.");
|
||||
for (const architecture of ["arm64", "x64"]) {
|
||||
const name = `Qwen-Live-Host-${architecture}.zip`;
|
||||
const file = `${process.env.DIRECTORY}/${name}`;
|
||||
const asset = manifest.assets?.[architecture];
|
||||
const checksum = createHash("sha256").update(readFileSync(file)).digest("hex");
|
||||
if (asset?.name !== name || asset.size !== statSync(file).size || asset.sha256 !== checksum) throw new Error(`Mirrored asset verification failed for ${architecture}.`);
|
||||
}
|
||||
'
|
||||
|
||||
- name: 'Confirm latest manifest matches GitHub stable feed'
|
||||
env:
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$(mktemp -d)"
|
||||
trap 'rm -rf "$directory"' EXIT
|
||||
gh release download 'live-host-latest' \
|
||||
--dir "$directory" \
|
||||
--pattern 'Qwen-Live-Host-manifest.json'
|
||||
cmp dist/live-host/Qwen-Live-Host-manifest.json \
|
||||
"$directory/Qwen-Live-Host-manifest.json"
|
||||
|
||||
- name: 'Publish latest manifest to Aliyun OSS'
|
||||
env:
|
||||
ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}"
|
||||
run: |
|
||||
node scripts/upload-aliyun-oss-assets.js \
|
||||
--bucket "$ALIYUN_OSS_BUCKET" \
|
||||
--config "$RUNNER_TEMP/.ossutilconfig" \
|
||||
--prefix 'live-host/latest' \
|
||||
dist/live-host/Qwen-Live-Host-manifest.json
|
||||
|
||||
- name: 'Verify latest manifest on Aliyun OSS'
|
||||
env:
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL --connect-timeout 15 --max-time 300 "$ALIYUN_OSS_PUBLIC_BASE_URL/live-host/latest/Qwen-Live-Host-manifest.json" -o "$RUNNER_TEMP/Qwen-Live-Host-manifest.json"
|
||||
cmp dist/live-host/Qwen-Live-Host-manifest.json "$RUNNER_TEMP/Qwen-Live-Host-manifest.json"
|
||||
|
||||
- name: 'Cleanup Aliyun OSS credentials'
|
||||
if: '${{ always() }}'
|
||||
run: 'rm -f "$RUNNER_TEMP/.ossutilconfig"'
|
||||
@@ -23,4 +23,3 @@ Thumbs.db
|
||||
packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
packages/cli/src/services/insight/templates/insightTemplate.ts
|
||||
packages/cua-driver/
|
||||
packages/desktop/
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Desktop Pet Spritesheet Generator for OpenWork.
|
||||
Desktop Pet Spritesheet Generator for Qwen Code.
|
||||
|
||||
Generates a 1536×1872 pixel-art chibi spritesheet (8 cols × 9 rows, 192×208 px cells)
|
||||
customized via a JSON config describing colors, headgear, and features.
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
name: openwork-desktop-sync
|
||||
description: Sync qwen-code packages/desktop with modelstudioai/openwork using commit-by-commit path migration, not subtree split or tree overwrite. Use when exporting qwen-code desktop changes to OpenWork, importing OpenWork desktop changes into qwen-code, preserving target-owned overlay files such as README.md, resolving sync conflicts, or preparing sync PR branches between the two repositories.
|
||||
---
|
||||
|
||||
# OpenWork Desktop Sync
|
||||
|
||||
Use this skill to sync desktop changes between this qwen-code repo and an
|
||||
OpenWork checkout. The repository script owns the Git mechanics:
|
||||
|
||||
```bash
|
||||
OPENWORK_DIR=/path/to/openwork bun run desktop-openwork-sync --mode export
|
||||
```
|
||||
|
||||
Default overlay is `README.md`. Overlay paths are excluded from migrated
|
||||
commits and stay target-owned.
|
||||
|
||||
```bash
|
||||
OPENWORK_OVERLAY_PATHS='README.md'
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
This is commit-by-commit path migration, not snapshot replacement. The script
|
||||
walks source commits from `source-base..source-head`, rewrites paths between
|
||||
qwen-code `packages/desktop` and the OpenWork repository root, then applies each
|
||||
commit with `git apply -3`.
|
||||
|
||||
Commits that already came from the receiving repository are skipped by their
|
||||
sync trailers. During import, qwen-code-origin export commits are skipped;
|
||||
during export, OpenWork-origin import commits are skipped.
|
||||
|
||||
Merge commits are not migrated as merge commits. The script migrates the regular
|
||||
commits inside the merged branch; when it later sees the merge wrapper, it
|
||||
checks that the regular commits were already handled and that the merge tree
|
||||
matches Git's automatic merge result. If the merge wrapper contains manual
|
||||
resolution changes, the sync stops so the agent can convert that resolution into
|
||||
a normal follow-up commit.
|
||||
|
||||
Target-side changes are preserved unless a migrated source commit touches the
|
||||
same hunk. If that happens, Git leaves a normal conflict for the agent to
|
||||
resolve. Do not use `git subtree split` or full tree replacement for normal
|
||||
sync.
|
||||
|
||||
Successful sync commits include trailers such as `Qwen-Code-Commit` or
|
||||
`OpenWork-Commit`. Later syncs can use the latest trailer as the next source
|
||||
base. The first sync needs an explicit source base when no previous sync trailer
|
||||
exists:
|
||||
|
||||
```bash
|
||||
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
|
||||
bun run desktop-openwork-sync --mode import --source-base <openwork-ref>
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
- `--mode export`: qwen-code `packages/desktop` commits -> OpenWork.
|
||||
- `--mode import`: OpenWork commits -> qwen-code `packages/desktop`.
|
||||
- `--mode auto`: guardrail only; use explicit directions for real sync.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm repo paths and clean worktrees:
|
||||
|
||||
```bash
|
||||
git rev-parse --show-toplevel
|
||||
git -C /path/to/openwork rev-parse --show-toplevel
|
||||
git status --short
|
||||
git -C /path/to/openwork status --short
|
||||
```
|
||||
|
||||
2. Run the requested direction:
|
||||
|
||||
```bash
|
||||
OPENWORK_DIR=/path/to/openwork \
|
||||
OPENWORK_OVERLAY_PATHS='README.md' \
|
||||
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
|
||||
```
|
||||
|
||||
3. If Git reports conflicts, resolve only the conflicted hunks, preserving
|
||||
target-owned repository metadata unless the source change intentionally
|
||||
updates that same behavior.
|
||||
|
||||
4. After sync, verify:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --check HEAD
|
||||
git diff --name-status <target-base>..HEAD
|
||||
```
|
||||
|
||||
5. If the user asked to publish, push the branch and create a PR after the
|
||||
branch is clean.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep only `README.md` as the default overlay unless the user adds paths to
|
||||
`OPENWORK_OVERLAY_PATHS`.
|
||||
- OpenWork-specific files not touched by source commits must remain unchanged.
|
||||
- Prefer PR branches. The script prints the push command for export branches.
|
||||
- Do not manually import PR merge commits. Let the script migrate regular
|
||||
commits and treat merge commits as wrappers.
|
||||
@@ -88,5 +88,3 @@ ignore:
|
||||
- 'vendor/'
|
||||
- 'node_modules/'
|
||||
- 'integration-tests/terminal-bench/'
|
||||
- 'packages/desktop/.github/'
|
||||
- 'packages/desktop/apps/electron/electron-builder.yml'
|
||||
@@ -77,23 +77,23 @@ an HTTP daemon. See the
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Responsibility |
|
||||
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `packages/cli` | The `qwen` executable, argument parsing, configuration assembly, Ink TUI, headless output, ACP entry point, `qwen serve`, and command-specific adapters. |
|
||||
| `packages/core` | UI-independent agent orchestration, model-provider integration, prompt and context construction, tool registration and execution, permissions, sessions, memory, telemetry, and shared services. |
|
||||
| `packages/acp-bridge` | ACP channel lifecycle, session multiplexing, event delivery, permission mediation, process spawning, and the filesystem seam shared by daemon and adapter hosts. |
|
||||
| `packages/sdk-typescript` | Programmatic process execution through `query()` plus HTTP/SSE clients and transcript projection for `qwen serve`. |
|
||||
| `packages/webui` | Shared React components and the daemon React adapter built on the TypeScript SDK. |
|
||||
| `packages/web-shell` | The terminal-style browser UI built on `packages/webui` and the daemon SDK. |
|
||||
| `packages/web-templates` | Web templates packaged as embeddable JavaScript and CSS strings. |
|
||||
| `packages/audio-capture` | Native microphone capture for voice input. |
|
||||
| `packages/channels` | The shared channel runtime and platform adapters for messaging services. |
|
||||
| `packages/desktop`, `packages/vscode-ide-companion`, `packages/chrome-extension`, `packages/zed-extension` | Product and editor surfaces that adapt Qwen Code to their host environments. |
|
||||
| `packages/sdk-java`, `packages/sdk-python` | Language-specific programmatic clients. |
|
||||
| `packages/cua-driver`, `packages/mobile-mcp` | Computer-use and mobile-device integrations exposed through MCP-compatible boundaries. |
|
||||
| `integration-tests` | End-to-end coverage for CLI, interactive, SDK, sandbox, hook, and terminal behavior. |
|
||||
| `docs` and `docs-site` | User, developer, protocol, and design documentation plus the documentation site. |
|
||||
| `scripts` | Build, packaging, release, validation, and repository-maintenance automation. |
|
||||
| Path | Responsibility |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `packages/cli` | The `qwen` executable, argument parsing, configuration assembly, Ink TUI, headless output, ACP entry point, `qwen serve`, and command-specific adapters. |
|
||||
| `packages/core` | UI-independent agent orchestration, model-provider integration, prompt and context construction, tool registration and execution, permissions, sessions, memory, telemetry, and shared services. |
|
||||
| `packages/acp-bridge` | ACP channel lifecycle, session multiplexing, event delivery, permission mediation, process spawning, and the filesystem seam shared by daemon and adapter hosts. |
|
||||
| `packages/sdk-typescript` | Programmatic process execution through `query()` plus HTTP/SSE clients and transcript projection for `qwen serve`. |
|
||||
| `packages/webui` | Shared React components and the daemon React adapter built on the TypeScript SDK. |
|
||||
| `packages/web-shell` | The terminal-style browser UI built on `packages/webui` and the daemon SDK. |
|
||||
| `packages/web-templates` | Web templates packaged as embeddable JavaScript and CSS strings. |
|
||||
| `packages/audio-capture` | Native microphone capture for voice input. |
|
||||
| `packages/channels` | The shared channel runtime and platform adapters for messaging services. |
|
||||
| `packages/desktop-shell`, `packages/vscode-ide-companion`, `packages/chrome-extension`, `packages/zed-extension` | Product and editor surfaces that adapt Qwen Code to their host environments. |
|
||||
| `packages/sdk-java`, `packages/sdk-python` | Language-specific programmatic clients. |
|
||||
| `packages/cua-driver`, `packages/mobile-mcp` | Computer-use and mobile-device integrations exposed through MCP-compatible boundaries. |
|
||||
| `integration-tests` | End-to-end coverage for CLI, interactive, SDK, sandbox, hook, and terminal behavior. |
|
||||
| `docs` and `docs-site` | User, developer, protocol, and design documentation plus the documentation site. |
|
||||
| `scripts` | Build, packaging, release, validation, and repository-maintenance automation. |
|
||||
|
||||
Most code lives in npm workspaces under `packages/`. A package should depend on
|
||||
another package through its declared public exports rather than through a
|
||||
|
||||
@@ -51,7 +51,6 @@ export default tseslint.config(
|
||||
'docs-site/.next/**',
|
||||
'docs-site/out/**',
|
||||
'.qwen/**',
|
||||
'packages/desktop/**',
|
||||
'packages/desktop-shell/runtime/**',
|
||||
'packages/desktop-shell/src-tauri/target/**',
|
||||
'packages/cua-driver/**', // vendored trycua/cua driver (Rust + scripts); not qwen-code TS
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"composite": false,
|
||||
// The root turns `noPropertyAccessFromIndexSignature` on, which forced
|
||||
// bracket-access rewrites in production SDK sources just to satisfy this
|
||||
// test program (packages/desktop already sets it false). Relax it here so
|
||||
// test program. Relax it here so
|
||||
// packages keep their own compiler regime and the tests keep dot access.
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
// Matches packages/cli. The suite drives browser-side code in
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"packages/channels/gitlab",
|
||||
"packages/channels/plugin-example",
|
||||
"integrations/external-context",
|
||||
"!packages/desktop",
|
||||
"!packages/desktop-shell"
|
||||
],
|
||||
"dependencies": {
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"packages/channels/gitlab",
|
||||
"packages/channels/plugin-example",
|
||||
"integrations/external-context",
|
||||
"!packages/desktop",
|
||||
"!packages/desktop-shell"
|
||||
],
|
||||
"repository": {
|
||||
@@ -90,9 +89,7 @@
|
||||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"check:desktop-isolation": "node scripts/check-desktop-isolation.js",
|
||||
"check:voice-guard-sync": "node scripts/check-voice-guard-sync.js",
|
||||
"check:serve-fast-path-bundle": "node scripts/clean-package-build-artifacts.js && npm run build -- --cli-only && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.js",
|
||||
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
|
||||
"clean": "node scripts/clean.js",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
|
||||
@@ -2056,7 +2056,7 @@ describe('runBuildTest', () => {
|
||||
});
|
||||
|
||||
it('discloses a diff inside a negated member — softly, never as an incomplete scope', () => {
|
||||
// packages/desktop is a separate toolchain (its own lockfile); a diff
|
||||
// packages/desktop-shell is a separate toolchain (its own lockfile); a diff
|
||||
// inside it cannot fail any npm workspace's suite, so "nothing to run"
|
||||
// stays the answer — disclosed softly (its own suite did not run), never
|
||||
// as an incomplete scope.
|
||||
@@ -2064,7 +2064,7 @@ describe('runBuildTest', () => {
|
||||
join(root, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'r',
|
||||
workspaces: ['packages/*', '!packages/desktop'],
|
||||
workspaces: ['packages/*', '!packages/desktop-shell'],
|
||||
scripts: { test: 'exit 0' },
|
||||
}),
|
||||
);
|
||||
@@ -2072,11 +2072,11 @@ describe('runBuildTest', () => {
|
||||
name: '@x/core',
|
||||
scripts: { build: 'exit 0', test: 'exit 0' },
|
||||
});
|
||||
pkg('packages/desktop', {
|
||||
pkg('packages/desktop-shell', {
|
||||
name: '@x/desktop',
|
||||
scripts: { build: 'exit 0', test: 'exit 0' },
|
||||
});
|
||||
writePlan(['packages/desktop/src/main.rs']);
|
||||
writePlan(['packages/desktop-shell/src/main.rs']);
|
||||
|
||||
const rep = runBuildTest({
|
||||
plan: planPath,
|
||||
@@ -2088,7 +2088,9 @@ describe('runBuildTest', () => {
|
||||
expect(rep.build).toEqual([]);
|
||||
expect(rep.test).toEqual([]);
|
||||
expect(rep.testScope?.workspaces).toEqual([]);
|
||||
expect(rep.testScope?.caveat).toContain('packages/desktop/src/main.rs');
|
||||
expect(rep.testScope?.caveat).toContain(
|
||||
'packages/desktop-shell/src/main.rs',
|
||||
);
|
||||
expect(rep.testScope?.caveat).toContain('were not run');
|
||||
expect(rep.note).toContain('were not run');
|
||||
});
|
||||
@@ -2332,7 +2334,7 @@ describe('runBuildTest', () => {
|
||||
|
||||
it('excludes a negated workspace from the build set (integration)', () => {
|
||||
// `!packages/excluded` must keep that package out — building it could fail on a
|
||||
// repo where it is a separate toolchain (e.g. packages/desktop, its own lockfile).
|
||||
// repo where it is a separate toolchain (e.g. packages/desktop-shell, its own lockfile).
|
||||
writeFileSync(
|
||||
join(root, 'package.json'),
|
||||
JSON.stringify({
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('classifyPath', () => {
|
||||
it('recognises generated and vendored files', () => {
|
||||
const paths = [
|
||||
'package-lock.json',
|
||||
'packages/desktop/bun.lock',
|
||||
'packages/desktop-shell/bun.lock',
|
||||
'packages/vscode-ide-companion/NOTICES.txt',
|
||||
'dist/bundle.min.js',
|
||||
'vendor/lib.go',
|
||||
|
||||
@@ -136,18 +136,18 @@ describe('resolveTestScope', () => {
|
||||
});
|
||||
|
||||
it('discloses — softly, not as incompleteness — a member a negation excludes', () => {
|
||||
// !packages/desktop is a separate toolchain with its own lockfile; a diff
|
||||
// !packages/desktop-shell is a separate toolchain with its own lockfile; a diff
|
||||
// inside it cannot fail any included workspace's suite, so it earns no
|
||||
// incomplete-scope caveat. But its own suite was not run either, and
|
||||
// "nothing is silent" covers that: a softer line says what did not run.
|
||||
const scope = resolveTestScope({
|
||||
changed: ['packages/desktop/src/main.rs'],
|
||||
globs: ['packages/*', '!packages/desktop'],
|
||||
changed: ['packages/desktop-shell/src/main.rs'],
|
||||
globs: ['packages/*', '!packages/desktop-shell'],
|
||||
packages: PKGS,
|
||||
skipped: [],
|
||||
});
|
||||
expect(scope.workspaces).toEqual([]);
|
||||
expect(scope.caveat).toContain('packages/desktop/src/main.rs');
|
||||
expect(scope.caveat).toContain('packages/desktop-shell/src/main.rs');
|
||||
expect(scope.caveat).toContain('were not run');
|
||||
expect(scope.caveat).not.toContain('outside every workspace');
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ export function resolveTestScope(input: {
|
||||
const unmapped = affected.filter(
|
||||
(d) => d !== '.' && !scriptsOf.has(d) && !skipped.includes(d),
|
||||
);
|
||||
// Files a negation excludes (!packages/desktop — a separate toolchain with
|
||||
// Files a negation excludes (!packages/desktop-shell — a separate toolchain with
|
||||
// its own lockfile) cannot affect any included workspace's tests, so they
|
||||
// earn no incomplete-scope caveat — but their own suites were not run
|
||||
// either, and "nothing is silent" covers that too: disclose it as the
|
||||
|
||||
@@ -35,7 +35,7 @@ const GLOBS = [
|
||||
'packages/channels/base',
|
||||
'packages/channels/telegram',
|
||||
'packages/channels/qqbot',
|
||||
'!packages/desktop',
|
||||
'!packages/desktop-shell',
|
||||
];
|
||||
|
||||
describe('workspaceDirFor', () => {
|
||||
@@ -54,37 +54,48 @@ describe('workspaceDirFor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('honours a negation, so a separate bun workspace is not a member', () => {
|
||||
// packages/desktop has its own lockfile and is not part of this npm workspace.
|
||||
it('honours a negation, so a separate toolchain is not a member', () => {
|
||||
// packages/desktop-shell has its own lockfile and is not part of this npm workspace.
|
||||
// Building it from the root fails.
|
||||
expect(
|
||||
workspaceDirFor('packages/desktop/apps/electron/src/main.ts', GLOBS),
|
||||
workspaceDirFor(
|
||||
'packages/desktop-shell/apps/electron/src/main.ts',
|
||||
GLOBS,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(isWorkspaceMember('packages/desktop/src/a.test.ts', GLOBS)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isWorkspaceMember('packages/desktop-shell/src/a.test.ts', GLOBS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-includes what a negation excluded when a later glob matches again', () => {
|
||||
// npm's own rule: last match wins, whichever direction it points.
|
||||
const globs = ['packages/*', '!packages/desktop', 'packages/desktop'];
|
||||
expect(workspaceDirFor('packages/desktop/src/a.ts', globs)).toBe(
|
||||
'packages/desktop',
|
||||
const globs = [
|
||||
'packages/*',
|
||||
'!packages/desktop-shell',
|
||||
'packages/desktop-shell',
|
||||
];
|
||||
expect(workspaceDirFor('packages/desktop-shell/src/a.ts', globs)).toBe(
|
||||
'packages/desktop-shell',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the surviving OUTER member when a negation excludes a nested one', () => {
|
||||
// npm keeps packages/desktop in the graph — only src is excluded — and
|
||||
// npm keeps packages/desktop-shell in the graph — only src is excluded — and
|
||||
// desktop's test runner collects src/**, so the file is felt by the outer
|
||||
// member's suite. Declaring it felt by NOTHING would certify "a complete
|
||||
// answer" over a suite that can fail.
|
||||
const globs = ['packages/*', 'packages/desktop/*', '!packages/desktop/src'];
|
||||
expect(workspaceDirFor('packages/desktop/src/x.test.ts', globs)).toBe(
|
||||
'packages/desktop',
|
||||
);
|
||||
expect(isNegationExcluded('packages/desktop/src/x.test.ts', globs)).toBe(
|
||||
false,
|
||||
const globs = [
|
||||
'packages/*',
|
||||
'packages/desktop-shell/*',
|
||||
'!packages/desktop-shell/src',
|
||||
];
|
||||
expect(workspaceDirFor('packages/desktop-shell/src/x.test.ts', globs)).toBe(
|
||||
'packages/desktop-shell',
|
||||
);
|
||||
expect(
|
||||
isNegationExcluded('packages/desktop-shell/src/x.test.ts', globs),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a ./-prefixed glob like its bare form', () => {
|
||||
@@ -201,9 +212,9 @@ describe('readRootPackage', () => {
|
||||
|
||||
describe('isNegationExcluded', () => {
|
||||
it('is true when a positive glob claims the file but a negation excludes it', () => {
|
||||
expect(isNegationExcluded('packages/desktop/src/main.rs', GLOBS)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isNegationExcluded('packages/desktop-shell/src/main.rs', GLOBS),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a file inside an included workspace', () => {
|
||||
@@ -216,22 +227,28 @@ describe('isNegationExcluded', () => {
|
||||
});
|
||||
|
||||
it('is false when a later glob re-includes what the negation excluded', () => {
|
||||
const globs = ['packages/*', '!packages/desktop', 'packages/desktop'];
|
||||
expect(isNegationExcluded('packages/desktop/src/a.ts', globs)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a member owned under a partial negation (`!packages/desktop/*`)', () => {
|
||||
// npm keeps packages/desktop itself a member — a glob with a subpath
|
||||
// cannot match the dir itself — so a file under it is still owned and its
|
||||
// suite can feel a change there; it is NOT negation-excluded.
|
||||
const globs = ['packages/*', '!packages/desktop/*'];
|
||||
expect(workspaceDirFor('packages/desktop/src/main.ts', globs)).toBe(
|
||||
'packages/desktop',
|
||||
);
|
||||
expect(isNegationExcluded('packages/desktop/src/main.ts', globs)).toBe(
|
||||
const globs = [
|
||||
'packages/*',
|
||||
'!packages/desktop-shell',
|
||||
'packages/desktop-shell',
|
||||
];
|
||||
expect(isNegationExcluded('packages/desktop-shell/src/a.ts', globs)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a member owned under a partial negation (`!packages/desktop-shell/*`)', () => {
|
||||
// npm keeps packages/desktop-shell itself a member — a glob with a subpath
|
||||
// cannot match the dir itself — so a file under it is still owned and its
|
||||
// suite can feel a change there; it is NOT negation-excluded.
|
||||
const globs = ['packages/*', '!packages/desktop-shell/*'];
|
||||
expect(workspaceDirFor('packages/desktop-shell/src/main.ts', globs)).toBe(
|
||||
'packages/desktop-shell',
|
||||
);
|
||||
expect(
|
||||
isNegationExcluded('packages/desktop-shell/src/main.ts', globs),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readWorkspacePackages', () => {
|
||||
@@ -334,9 +351,9 @@ describe('readWorkspacePackages', () => {
|
||||
});
|
||||
|
||||
it('ignores a broken manifest in a NEGATED dir — not a workspace, not our graph', () => {
|
||||
setup(['packages/*', '!packages/desktop']);
|
||||
setup(['packages/*', '!packages/desktop-shell']);
|
||||
write('packages/good', { name: '@x/good' });
|
||||
write('packages/desktop', '{ not json');
|
||||
write('packages/desktop-shell', '{ not json');
|
||||
const { packages, skipped } = readWorkspacePackages(root);
|
||||
expect(packages.map((p) => p.dir)).toEqual(['packages/good']);
|
||||
expect(skipped).toEqual([]);
|
||||
@@ -442,7 +459,7 @@ describe('hasUnmodeledWorkspaceGlob', () => {
|
||||
it('is false for the shapes the walker models — literals and a trailing /*', () => {
|
||||
expect(hasUnmodeledWorkspaceGlob(GLOBS)).toBe(false);
|
||||
expect(hasUnmodeledWorkspaceGlob(['packages/*', 'apps/web'])).toBe(false);
|
||||
expect(hasUnmodeledWorkspaceGlob(['!packages/desktop'])).toBe(false);
|
||||
expect(hasUnmodeledWorkspaceGlob(['!packages/desktop-shell'])).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for `**`, an inner `*`, or a `foo-*` prefix the walker cannot model', () => {
|
||||
|
||||
@@ -79,10 +79,10 @@ export function workspaceDirFor(
|
||||
const norm = filePath.replace(/^\.\//, '');
|
||||
let owner: string | null = null;
|
||||
// The owners that came before the current one — where a negation falls back
|
||||
// TO. When a negation excludes a NESTED member (`packages/desktop/*` claimed
|
||||
// `packages/desktop/src`, then `!packages/desktop/src` excluded it), the
|
||||
// TO. When a negation excludes a NESTED member (`packages/desktop-shell/*` claimed
|
||||
// `packages/desktop-shell/src`, then `!packages/desktop-shell/src` excluded it), the
|
||||
// still-included outer member keeps owning the file: npm keeps
|
||||
// `packages/desktop` in the graph, and its test runner collects `src/**`.
|
||||
// `packages/desktop-shell` in the graph, and its test runner collects `src/**`.
|
||||
// Falling back to the previous owner is what lets that suite feel the
|
||||
// change instead of the file being declared felt by nothing.
|
||||
const previous: Array<string | null> = [];
|
||||
@@ -111,15 +111,15 @@ export function workspaceDirFor(
|
||||
}
|
||||
} else if (dir === owner) {
|
||||
// A negation only excludes the file when it excludes the member that
|
||||
// currently owns it. `!packages/desktop/*` matches a deeper pseudo-dir
|
||||
// currently owns it. `!packages/desktop-shell/*` matches a deeper pseudo-dir
|
||||
// than `packages/*` does, and npm keeps the member itself in the graph
|
||||
// (a glob with a subpath cannot match a dir with no subpath), so the
|
||||
// member's suite can still feel a change there. When the negation DOES
|
||||
// exclude the owner, ownership falls back to the previous, outer member
|
||||
// — only a negation of THAT one leaves the file owned by nothing. (The
|
||||
// pop does not re-check the popped owner against negations already
|
||||
// walked past: a contrived ordering like `!packages/desktop` BEFORE
|
||||
// `!packages/desktop/src` can resurrect an excluded owner. Realistic
|
||||
// walked past: a contrived ordering like `!packages/desktop-shell` BEFORE
|
||||
// `!packages/desktop-shell/src` can resurrect an excluded owner. Realistic
|
||||
// orderings — the outer negation written last — are exact.)
|
||||
owner = previous.pop() ?? null;
|
||||
}
|
||||
@@ -132,11 +132,11 @@ export function workspaceDirFor(
|
||||
* every member.
|
||||
*
|
||||
* Such a file belongs to a workspace the npm graph does not contain — this
|
||||
* repo's `!packages/desktop` is a separate bun workspace with its own
|
||||
* repo's `!packages/desktop-shell` is a separate toolchain with its own
|
||||
* lockfile — so no included workspace's tests can feel a change to it, and it
|
||||
* must not earn the incomplete-scope caveat a genuinely outside file does. A
|
||||
* file whose nested member is negated while an OUTER member survives
|
||||
* (`!packages/desktop/src` under `packages/desktop`) is NOT excluded here:
|
||||
* (`!packages/desktop-shell/src` under `packages/desktop-shell`) is NOT excluded here:
|
||||
* `workspaceDirFor` falls back to the outer member, whose suite collects it.
|
||||
*/
|
||||
export function isNegationExcluded(
|
||||
@@ -351,8 +351,8 @@ export function readWorkspacePackages(root: string): WorkspaceGraph {
|
||||
if (!existsSync(manifest)) continue;
|
||||
if (workspaceDirFor(`${dir}/package.json`, globs) !== dir) {
|
||||
// A directory a negation excludes is not a workspace, and its own
|
||||
// `package.json` says nothing about that — `packages/desktop` is a
|
||||
// separate bun workspace with its own lockfile, and building it from
|
||||
// `package.json` says nothing about that — `packages/desktop-shell` is a
|
||||
// separate toolchain with its own lockfile, and building it from
|
||||
// here fails. The tell: the POSITIVE globs alone still make the dir its
|
||||
// own owner, so a negation is what took the ownership away.
|
||||
const positives = globs.filter((g) => !g.startsWith('!'));
|
||||
|
||||
@@ -51,7 +51,7 @@ const GLOBS = [
|
||||
'packages/*',
|
||||
'packages/channels/base',
|
||||
'packages/channels/telegram',
|
||||
'!packages/desktop',
|
||||
'!packages/desktop-shell',
|
||||
];
|
||||
|
||||
describe('isWorkspaceMember', () => {
|
||||
@@ -77,22 +77,30 @@ describe('isWorkspaceMember', () => {
|
||||
});
|
||||
|
||||
it('honours a negated glob', () => {
|
||||
expect(isWorkspaceMember('packages/desktop/src/a.test.ts', GLOBS)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isWorkspaceMember('packages/desktop-shell/src/a.test.ts', GLOBS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('honours workspace-glob ORDER — a positive after a negation re-includes', () => {
|
||||
// npm evaluates the list in order. Filtering all negations first let a
|
||||
// negation win wherever it sat, which would file a false `unreachable`.
|
||||
const globs = ['packages/*', '!packages/desktop', 'packages/desktop'];
|
||||
expect(isWorkspaceMember('packages/desktop/src/a.test.ts', globs)).toBe(
|
||||
true,
|
||||
);
|
||||
const reordered = ['packages/*', 'packages/desktop', '!packages/desktop'];
|
||||
expect(isWorkspaceMember('packages/desktop/src/a.test.ts', reordered)).toBe(
|
||||
false,
|
||||
);
|
||||
const globs = [
|
||||
'packages/*',
|
||||
'!packages/desktop-shell',
|
||||
'packages/desktop-shell',
|
||||
];
|
||||
expect(
|
||||
isWorkspaceMember('packages/desktop-shell/src/a.test.ts', globs),
|
||||
).toBe(true);
|
||||
const reordered = [
|
||||
'packages/*',
|
||||
'packages/desktop-shell',
|
||||
'!packages/desktop-shell',
|
||||
];
|
||||
expect(
|
||||
isWorkspaceMember('packages/desktop-shell/src/a.test.ts', reordered),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match a sibling directory by prefix', () => {
|
||||
|
||||
@@ -24,9 +24,6 @@ const MIN_KEYTERM_ECHO_TOKENS = 8;
|
||||
const MIN_ABSOLUTE_KEYTERM_ECHO_TOKENS = 10;
|
||||
const MIN_KEYTERM_SET_ECHO_RATIO = 0.3;
|
||||
const debugLogger = createDebugLogger('VOICE_TRANSCRIBER');
|
||||
// The address classification in this file is mirrored in
|
||||
// packages/desktop/packages/server-core/src/voice/net-guard.ts. The bun
|
||||
// workspace boundary prevents sharing a module; keep the two in sync.
|
||||
const BLOCKED_TRANSITION_IPV6_ADDRESSES = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
['64:ff9b:1::', 48],
|
||||
|
||||
@@ -2695,13 +2695,11 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
||||
// agent-level background flag retains its existing meaning, and safe
|
||||
// ordinary one-shot launches default to background.
|
||||
//
|
||||
// This is the source of truth for the background-classification rule. Two
|
||||
// UI classifiers replicate it from tool-call args (they cannot see
|
||||
// This is the source of truth for the background-classification rule. The
|
||||
// web-shell classifier replicates it from tool-call args (it cannot see
|
||||
// subagentConfig.background) and must be kept in sync when it changes:
|
||||
// - packages/web-shell/client/adapters/toolClassification.ts
|
||||
// (isBackgroundSubAgentToolCall)
|
||||
// - packages/desktop/packages/shared/src/agent/tool-matching.ts
|
||||
// (detectBackgroundEvents)
|
||||
//
|
||||
// Background delegation is top-level-only in v1. A nested launcher would
|
||||
// be handed a completion contract it cannot honor — the success guidance
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
name: desktop-brand-builder
|
||||
description: Generate a branded qwen-code desktop package from a minimal brandId and logo. Use when the user wants a custom, white-label, rebranded, ModelStudio/OpenWork/Qwen Code desktop client, installer, DMG/EXE/AppImage, or one-click brand build.
|
||||
---
|
||||
|
||||
# Desktop Brand Builder
|
||||
|
||||
## Goal
|
||||
|
||||
Create a branded desktop package with the least user input possible. The user
|
||||
should usually provide only:
|
||||
|
||||
```text
|
||||
brandId: acme-ai
|
||||
logo: /absolute/path/to/logo.png
|
||||
website: https://acme.ai
|
||||
```
|
||||
|
||||
`website` is optional. Do not ask for app name, app id, artifact name,
|
||||
copyright, dock icon, renderer symbol, signing, or local installation unless
|
||||
the user explicitly asks to override them.
|
||||
|
||||
## Input Rules
|
||||
|
||||
Required fields:
|
||||
|
||||
- `brandId`: must match `^[a-z][a-z0-9-]*$`
|
||||
- `logo`: local file path; must exist
|
||||
|
||||
Optional overrides:
|
||||
|
||||
- `website`
|
||||
- `appName`
|
||||
- `appId`
|
||||
- `artifactPrefix`
|
||||
- `target`: `mac`, `win`, `linux`, or `all`
|
||||
|
||||
If required input is missing, ask once:
|
||||
|
||||
```text
|
||||
请提供:
|
||||
brandId: 例如 acme-ai,只能小写字母、数字、短横线
|
||||
logo: 本地 logo 文件路径
|
||||
website: 可选
|
||||
```
|
||||
|
||||
Once the required fields are present, proceed without a confirmation step.
|
||||
|
||||
## Derived Defaults
|
||||
|
||||
Infer missing values deterministically:
|
||||
|
||||
- `appName`: title-case the hyphen-separated `brandId`; `acme-ai` becomes
|
||||
`Acme AI`
|
||||
- `artifactPrefix`: title-case the hyphen-separated `brandId` and join with
|
||||
hyphens; `acme-ai` becomes `Acme-AI`
|
||||
- `appId`: if `website` has a valid host, reverse the host labels and append
|
||||
`.desktop`; `https://acme.ai` becomes `ai.acme.desktop`
|
||||
- fallback `appId`: `app.<brandId>.desktop`
|
||||
- `copyright`: `Copyright © <current year> <appName>`
|
||||
- all brand images: generate icon, dock icon, and renderer symbol from `logo`
|
||||
|
||||
Use explicit user-provided override values as-is after basic validation.
|
||||
|
||||
## Build Workflow
|
||||
|
||||
Use an isolated build directory under the current working directory so user
|
||||
changes in the current worktree are not mutated. Default to the qwen-code main
|
||||
branch; do not clone from `craft-agents-oss`, OpenWork, or another local
|
||||
checkout unless the user explicitly asks for that source:
|
||||
|
||||
```bash
|
||||
BUILD_ROOT="$PWD/brand-builds/<brandId>-<timestamp>"
|
||||
mkdir -p "$BUILD_ROOT"
|
||||
git clone --branch main --single-branch \
|
||||
https://github.com/QwenLM/qwen-code.git \
|
||||
"$BUILD_ROOT/qwen-code"
|
||||
cd "$BUILD_ROOT/qwen-code"
|
||||
git checkout -B brand-<brandId> origin/main
|
||||
```
|
||||
|
||||
If the branch fetch or checkout fails, stop and report the failure. Do not
|
||||
continue as if `brand-<brandId>` was created.
|
||||
|
||||
Create a temporary `brand.json` in the build directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"brandId": "acme-ai",
|
||||
"logo": "/absolute/path/to/logo.png",
|
||||
"website": "https://acme.ai",
|
||||
"appName": "Acme AI",
|
||||
"appId": "ai.acme.desktop",
|
||||
"artifactPrefix": "Acme-AI",
|
||||
"copyright": "Copyright © 2026 Acme AI"
|
||||
}
|
||||
```
|
||||
|
||||
Install desktop dependencies if `packages/desktop/node_modules` is missing:
|
||||
|
||||
```bash
|
||||
cd packages/desktop
|
||||
bun install
|
||||
```
|
||||
|
||||
Then run this skill's bundled brand creation script:
|
||||
|
||||
```bash
|
||||
cd /absolute/path/to/qwen-code
|
||||
bun run packages/desktop/.agents/skills/desktop-brand-builder/scripts/brand-create.ts \
|
||||
--desktop-root /absolute/path/to/qwen-code/packages/desktop \
|
||||
--config /absolute/path/to/brand.json
|
||||
```
|
||||
|
||||
The agent should not hand-edit `branding.ts` or brand asset files when this
|
||||
bundled script is available. The bundled script is the source of truth for
|
||||
patching code and generating resources.
|
||||
|
||||
Package with the current host target unless the user requested a target:
|
||||
|
||||
```bash
|
||||
CRAFT_BRAND=<brandId> bun run electron:dist:mac
|
||||
CRAFT_BRAND=<brandId> bun run electron:dist:win
|
||||
CRAFT_BRAND=<brandId> bun run electron:dist:linux
|
||||
```
|
||||
|
||||
For `target: all`, run only targets supported by the current machine or CI
|
||||
environment. Do not claim cross-platform artifacts were produced unless the
|
||||
files exist.
|
||||
|
||||
## Validation
|
||||
|
||||
After packaging:
|
||||
|
||||
1. Confirm the expected artifact exists under
|
||||
`packages/desktop/apps/electron/release/`.
|
||||
2. Compute `sha256sum` or `shasum -a 256` for each artifact.
|
||||
3. On macOS, run `hdiutil verify` for generated DMG files.
|
||||
4. Report the artifact path, SHA-256, app name, app id, and build directory.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Invalid `brandId`: show the regex and ask for a corrected value.
|
||||
- Missing `logo`: ask for a valid local path.
|
||||
- Missing bundled script: report that
|
||||
`packages/desktop/.agents/skills/desktop-brand-builder/scripts/brand-create.ts`
|
||||
is missing, and include the expected command.
|
||||
- Build failure: preserve the build directory, return the last useful error
|
||||
lines, and include the full log path or command that produced the failure.
|
||||
|
||||
Do not delete the build directory on failure.
|
||||
@@ -1,381 +0,0 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
|
||||
interface BrandInput {
|
||||
brandId?: string;
|
||||
logo?: string;
|
||||
website?: string;
|
||||
appName?: string;
|
||||
appId?: string;
|
||||
artifactPrefix?: string;
|
||||
copyright?: string;
|
||||
}
|
||||
|
||||
interface BrandConfig {
|
||||
brandId: string;
|
||||
logo: string;
|
||||
website?: string;
|
||||
appName: string;
|
||||
appId: string;
|
||||
artifactPrefix: string;
|
||||
copyright: string;
|
||||
}
|
||||
|
||||
const BRAND_ID_RE = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
function argValue(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function configPathFromArgs(): string {
|
||||
const value = argValue('--config');
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'Usage: bun run scripts/brand-create.ts --desktop-root /path/to/packages/desktop --config /path/to/brand.json',
|
||||
);
|
||||
}
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
function desktopRootFromArgs(): string {
|
||||
const value = argValue('--desktop-root');
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'Usage: bun run scripts/brand-create.ts --desktop-root /path/to/packages/desktop --config /path/to/brand.json',
|
||||
);
|
||||
}
|
||||
|
||||
const desktopRoot = resolve(value);
|
||||
if (!existsSync(join(desktopRoot, 'package.json'))) {
|
||||
throw new Error(`Desktop package not found: ${desktopRoot}`);
|
||||
}
|
||||
return desktopRoot;
|
||||
}
|
||||
|
||||
function titleWords(brandId: string): string[] {
|
||||
return brandId
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0]!.toUpperCase() + part.slice(1));
|
||||
}
|
||||
|
||||
function deriveAppId(website: string | undefined, brandId: string): string {
|
||||
if (!website) return `app.${brandId}.desktop`;
|
||||
|
||||
try {
|
||||
const withProtocol = website.includes('://')
|
||||
? website
|
||||
: `https://${website}`;
|
||||
const host = new URL(withProtocol).hostname.replace(/^www\./, '');
|
||||
const parts = host.split('.').filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts.reverse().join('.')}.desktop`;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the deterministic fallback.
|
||||
}
|
||||
|
||||
return `app.${brandId}.desktop`;
|
||||
}
|
||||
|
||||
function loadConfig(path: string): BrandConfig {
|
||||
const input = JSON.parse(readFileSync(path, 'utf8')) as BrandInput;
|
||||
const brandId = input.brandId?.trim();
|
||||
const logo = input.logo ? resolve(input.logo) : undefined;
|
||||
|
||||
if (!brandId || !BRAND_ID_RE.test(brandId)) {
|
||||
throw new Error(`brandId must match ${BRAND_ID_RE}`);
|
||||
}
|
||||
if (!logo || !existsSync(logo)) {
|
||||
throw new Error(`Logo file not found: ${logo ?? '(missing)'}`);
|
||||
}
|
||||
|
||||
const words = titleWords(brandId);
|
||||
const appName = input.appName?.trim() || words.join(' ');
|
||||
const artifactPrefix = input.artifactPrefix?.trim() || words.join('-');
|
||||
|
||||
return {
|
||||
brandId,
|
||||
logo,
|
||||
website: input.website?.trim() || undefined,
|
||||
appName,
|
||||
appId: input.appId?.trim() || deriveAppId(input.website, brandId),
|
||||
artifactPrefix,
|
||||
copyright:
|
||||
input.copyright?.trim() ||
|
||||
`Copyright \u00a9 ${new Date().getFullYear()} ${appName}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function run(cmd: string[], cwd: string): Promise<void> {
|
||||
const proc = Bun.spawn({
|
||||
cmd,
|
||||
cwd,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
stdin: 'inherit',
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`${cmd.join(' ')} failed with exit code ${exitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface BrandAssetsResult {
|
||||
macIcon: string;
|
||||
hasAssetsCar: boolean;
|
||||
}
|
||||
|
||||
async function writeBrandAssets(
|
||||
config: BrandConfig,
|
||||
desktopRoot: string,
|
||||
): Promise<BrandAssetsResult> {
|
||||
const requireFromDesktop = createRequire(join(desktopRoot, 'package.json'));
|
||||
const sharp = requireFromDesktop('sharp') as typeof import('sharp');
|
||||
const electronDir = join(desktopRoot, 'apps', 'electron');
|
||||
const brandDir = join(electronDir, 'resources', 'brands', config.brandId);
|
||||
mkdirSync(brandDir, { recursive: true });
|
||||
|
||||
async function writePng(output: string, size: number) {
|
||||
await sharp(config.logo)
|
||||
.resize(size, size, {
|
||||
fit: 'contain',
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toFile(output);
|
||||
}
|
||||
|
||||
const sourceExt = extname(config.logo) || '.logo';
|
||||
copyFileSync(config.logo, join(brandDir, `source${sourceExt}`));
|
||||
|
||||
await writePng(join(brandDir, 'icon.png'), 512);
|
||||
await writePng(join(brandDir, 'dock.png'), 512);
|
||||
await writePng(join(brandDir, 'symbol.png'), 512);
|
||||
|
||||
if (process.platform !== 'darwin') return { macIcon: 'icon.png', hasAssetsCar: false };
|
||||
|
||||
const iconset = join(brandDir, 'icon.iconset');
|
||||
rmSync(iconset, { recursive: true, force: true });
|
||||
mkdirSync(iconset, { recursive: true });
|
||||
|
||||
const sizes = [
|
||||
['icon_16x16.png', 16],
|
||||
['[email protected]', 32],
|
||||
['icon_32x32.png', 32],
|
||||
['[email protected]', 64],
|
||||
['icon_128x128.png', 128],
|
||||
['[email protected]', 256],
|
||||
['icon_256x256.png', 256],
|
||||
['[email protected]', 512],
|
||||
['icon_512x512.png', 512],
|
||||
['[email protected]', 1024],
|
||||
] as const;
|
||||
|
||||
for (const [file, size] of sizes) {
|
||||
await writePng(join(iconset, file), size);
|
||||
}
|
||||
|
||||
await run(
|
||||
['iconutil', '-c', 'icns', iconset, '-o', join(brandDir, 'icon.icns')],
|
||||
brandDir,
|
||||
);
|
||||
|
||||
const hasAssetsCar = await compileAssetsCar(config, brandDir, writePng);
|
||||
return { macIcon: 'icon.icns', hasAssetsCar };
|
||||
}
|
||||
|
||||
async function compileAssetsCar(
|
||||
config: BrandConfig,
|
||||
brandDir: string,
|
||||
writePng: (output: string, size: number) => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
const xcassets = join(brandDir, 'Assets.xcassets');
|
||||
const appiconset = join(xcassets, 'AppIcon.appiconset');
|
||||
rmSync(xcassets, { recursive: true, force: true });
|
||||
mkdirSync(appiconset, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(xcassets, 'Contents.json'),
|
||||
JSON.stringify({ info: { author: 'xcode', version: 1 } }),
|
||||
);
|
||||
|
||||
const entries = [
|
||||
{ file: 'icon_16.png', size: 16, scale: '1x', dims: '16x16' },
|
||||
{ file: 'icon_32.png', size: 32, scale: '2x', dims: '16x16' },
|
||||
{ file: 'icon_32.png', size: 32, scale: '1x', dims: '32x32' },
|
||||
{ file: 'icon_64.png', size: 64, scale: '2x', dims: '32x32' },
|
||||
{ file: 'icon_128.png', size: 128, scale: '1x', dims: '128x128' },
|
||||
{ file: 'icon_256.png', size: 256, scale: '2x', dims: '128x128' },
|
||||
{ file: 'icon_256.png', size: 256, scale: '1x', dims: '256x256' },
|
||||
{ file: 'icon_512.png', size: 512, scale: '2x', dims: '256x256' },
|
||||
{ file: 'icon_512.png', size: 512, scale: '1x', dims: '512x512' },
|
||||
{ file: 'icon_1024.png', size: 1024, scale: '2x', dims: '512x512' },
|
||||
];
|
||||
|
||||
const uniqueSizes = new Set(entries.map((e) => e.size));
|
||||
for (const size of uniqueSizes) {
|
||||
await writePng(join(appiconset, `icon_${size}.png`), size);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(appiconset, 'Contents.json'),
|
||||
JSON.stringify({
|
||||
images: entries.map((e) => ({
|
||||
filename: e.file,
|
||||
idiom: 'mac',
|
||||
scale: e.scale,
|
||||
size: e.dims,
|
||||
})),
|
||||
info: { author: 'xcode', version: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
const outDir = mkdtempSync(join(tmpdir(), 'assets-car-'));
|
||||
const partialPlist = join(outDir, 'partial-info.plist');
|
||||
const proc = Bun.spawn({
|
||||
cmd: [
|
||||
'xcrun', 'actool', xcassets,
|
||||
'--compile', outDir,
|
||||
'--app-icon', 'AppIcon',
|
||||
'--platform', 'macosx',
|
||||
'--minimum-deployment-target', '14.0',
|
||||
'--output-partial-info-plist', partialPlist,
|
||||
],
|
||||
cwd: brandDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
console.log('Warning: actool compilation failed, skipping Assets.car');
|
||||
rmSync(xcassets, { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
const compiledCar = join(outDir, 'Assets.car');
|
||||
if (!existsSync(compiledCar)) {
|
||||
console.log('Warning: actool produced no Assets.car, skipping');
|
||||
rmSync(xcassets, { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
copyFileSync(compiledCar, join(brandDir, 'Assets.car'));
|
||||
rmSync(xcassets, { recursive: true, force: true });
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
console.log('Assets.car compiled successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
function tsString(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
|
||||
}
|
||||
|
||||
function helpMenuLinks(config: BrandConfig): string {
|
||||
if (!config.website) return '[]';
|
||||
|
||||
return `[
|
||||
{
|
||||
labelKey: 'menu.homepage',
|
||||
url: ${tsString(config.website)},
|
||||
icon: 'House',
|
||||
},
|
||||
]`;
|
||||
}
|
||||
|
||||
function brandBlock(config: BrandConfig, macIcon: string, hasAssetsCar: boolean): string {
|
||||
const resourceDir = `resources/brands/${config.brandId}`;
|
||||
const liquidGlassLine = hasAssetsCar
|
||||
? `\n liquidGlassAssetsCar: ${tsString(`${resourceDir}/Assets.car`)},`
|
||||
: '';
|
||||
|
||||
return ` ${tsString(config.brandId)}: {
|
||||
id: ${tsString(config.brandId)},
|
||||
appName: ${tsString(config.appName)},
|
||||
appId: ${tsString(config.appId)},
|
||||
productName: ${tsString(config.appName)},
|
||||
artifactPrefix: ${tsString(config.artifactPrefix)},
|
||||
copyright: ${tsString(config.copyright)},
|
||||
coAuthorLine: ${tsString(`Co-Authored-By: ${config.appName} <noreply@${config.brandId}.local>`)},
|
||||
selfReferName: ${tsString(config.appName)},
|
||||
viewerUrl: 'https://agents.craft.do',
|
||||
helpMenuLinks: ${helpMenuLinks(config)},
|
||||
assets: {
|
||||
resourceDir: ${tsString(resourceDir)},
|
||||
rendererSymbol: ${tsString(`${resourceDir}/symbol.png`)},
|
||||
macIcon: ${tsString(`${resourceDir}/${macIcon}`)},
|
||||
winIcon: ${tsString(`${resourceDir}/icon.png`)},
|
||||
linuxIcon: ${tsString(`${resourceDir}/icon.png`)},
|
||||
devDockIcon: ${tsString(`${resourceDir}/dock.png`)},${liquidGlassLine}
|
||||
},
|
||||
credits: '',
|
||||
creditsShort: '',
|
||||
creditsEntries: [],
|
||||
},
|
||||
`;
|
||||
}
|
||||
|
||||
function registerBrand(
|
||||
config: BrandConfig,
|
||||
desktopRoot: string,
|
||||
macIcon: string,
|
||||
hasAssetsCar: boolean,
|
||||
): void {
|
||||
const brandingPath = join(
|
||||
desktopRoot,
|
||||
'packages',
|
||||
'shared',
|
||||
'src',
|
||||
'branding.ts',
|
||||
);
|
||||
const source = readFileSync(brandingPath, 'utf8');
|
||||
if (
|
||||
source.includes(`${tsString(config.brandId)}:`) ||
|
||||
source.includes(`id: ${tsString(config.brandId)}`)
|
||||
) {
|
||||
throw new Error(`Brand already exists in branding.ts: ${config.brandId}`);
|
||||
}
|
||||
|
||||
const marker = '\n};\n\n/** Active brand';
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(`Could not find BRANDS insertion point in ${brandingPath}`);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
brandingPath,
|
||||
source.replace(marker, `\n${brandBlock(config, macIcon, hasAssetsCar)}${marker}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const desktopRoot = desktopRootFromArgs();
|
||||
const config = loadConfig(configPathFromArgs());
|
||||
const { macIcon, hasAssetsCar } = await writeBrandAssets(config, desktopRoot);
|
||||
registerBrand(config, desktopRoot, macIcon, hasAssetsCar);
|
||||
|
||||
console.log(`Created brand ${config.brandId}`);
|
||||
console.log(`App name: ${config.appName}`);
|
||||
console.log(`App ID: ${config.appId}`);
|
||||
console.log(
|
||||
`Assets: ${join(desktopRoot, 'apps', 'electron', 'resources', 'brands', config.brandId)}`,
|
||||
);
|
||||
if (hasAssetsCar) {
|
||||
console.log('Assets.car: generated (macOS 26+ Liquid Glass icon)');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
name: desktop-develop
|
||||
description: Develop, debug, and verify the OpenWork desktop/Electron app with an agent-readable harness. Use when working on packages/desktop, Electron renderer/main/preload code, desktop UI bugs, local desktop runtime failures, Chrome DevTools MCP investigation, desktop logs, messaging gateway issues, or when improving the development feedback loop for desktop features.
|
||||
---
|
||||
|
||||
# Desktop Development Harness
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill to turn desktop work into a tight harness loop: gather runtime
|
||||
context, reproduce with the UI and logs visible to the agent, make the smallest
|
||||
fix, verify through the same path, and encode any missing affordance back into
|
||||
the repo.
|
||||
|
||||
Read `references/harness-principles.md` when the task changes the development
|
||||
workflow, observability, docs, tests, or agent-facing harness itself.
|
||||
|
||||
## Quick Context
|
||||
|
||||
For bug reports, UI failures, hangs, startup problems, messaging issues, or
|
||||
anything involving the running desktop app, inspect the runtime logs directly.
|
||||
Important paths:
|
||||
|
||||
- `~/Library/Logs/@craft-agent/electron/main.log`
|
||||
- `~/Library/Logs/@craft-agent/electron/main.old.log`
|
||||
- `~/.craft-agent/logs/messaging-gateway.log`
|
||||
|
||||
Search logs before guessing:
|
||||
|
||||
```bash
|
||||
rg -n "error|warn|failed|exception|crash|Unhandled|rejection|browser-cdp|messaging-gateway" \
|
||||
"$HOME/Library/Logs/@craft-agent/electron/main.log" \
|
||||
"$HOME/.craft-agent/logs/messaging-gateway.log"
|
||||
```
|
||||
|
||||
## Harness Loop
|
||||
|
||||
1. **Map the surface.** Identify whether the task touches Electron main,
|
||||
preload, renderer, shared desktop packages, server, messaging, or browser
|
||||
CDP. Read nearby code and tests before editing.
|
||||
2. **Collect live evidence.** Read and tail the relevant log while reproducing.
|
||||
Treat missing or ambiguous logs as part of the bug.
|
||||
3. **Drive the UI.** Use Chrome DevTools MCP when a browser/renderer page is
|
||||
involved: `list_pages`, `select_page`, `take_snapshot`, then console/network
|
||||
inspection. Prefer accessibility snapshots over screenshots for reasoning.
|
||||
4. **Reproduce first.** For bugs, capture the exact observed behavior and the
|
||||
evidence that proves it. If reproduction differs from the user's report,
|
||||
compare environment, app state, build artifact, account, timing, and logs.
|
||||
5. **Patch narrowly.** Keep changes scoped to the proven cause. Add structure
|
||||
only when it removes real repeated work or makes the app more readable to
|
||||
future agents.
|
||||
6. **Verify through the same path.** Re-run the reproduction, inspect logs and
|
||||
DevTools again, then run focused tests/typechecks for touched packages.
|
||||
7. **Improve the harness when needed.** If the fix required hidden knowledge,
|
||||
add a small doc, test, log field, or skill update so the next agent
|
||||
can see it directly.
|
||||
|
||||
## Running Desktop
|
||||
|
||||
Use desktop-specific commands from `packages/desktop`:
|
||||
|
||||
```bash
|
||||
cd packages/desktop
|
||||
bun run electron:dev
|
||||
bun run electron:dev:terminal
|
||||
bun run electron:dev:logs
|
||||
```
|
||||
|
||||
Use `electron:dev:terminal` when the bug involves process output, startup, or
|
||||
shutdown. Use `electron:dev:logs` when the app is already running and you need a
|
||||
live log tail.
|
||||
|
||||
## Chrome DevTools MCP
|
||||
|
||||
If DevTools tools are not loaded, search for `chrome-devtools` tools first.
|
||||
Then:
|
||||
|
||||
1. Call `mcp__chrome_devtools.list_pages`.
|
||||
2. Select the relevant page with `mcp__chrome_devtools.select_page`.
|
||||
3. Capture an accessibility snapshot with
|
||||
`mcp__chrome_devtools.take_snapshot`.
|
||||
4. Inspect runtime failures with
|
||||
`mcp__chrome_devtools.list_console_messages`, then
|
||||
`mcp__chrome_devtools.get_console_message` for important entries.
|
||||
5. Inspect selected network requests with
|
||||
`mcp__chrome_devtools.get_network_request` when network state is involved.
|
||||
6. For memory issues, save a heap snapshot with
|
||||
`mcp__chrome_devtools.take_heapsnapshot` and keep it under `.qwen/` or
|
||||
`/tmp`, not in source directories.
|
||||
|
||||
Always take a fresh snapshot after each UI-changing action. Do not rely on stale
|
||||
element ids or old console state.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
Choose the narrowest checks that cover the touched surface:
|
||||
|
||||
```bash
|
||||
cd packages/desktop && bun run typecheck:electron
|
||||
cd packages/desktop && bun run typecheck:all
|
||||
cd packages/desktop && bun run validate:dev
|
||||
cd packages/desktop/apps/electron && bun run lint
|
||||
cd packages/desktop/packages/shared && bun test path/to/file.test.ts
|
||||
```
|
||||
|
||||
For root CLI/core changes, use the root repository commands from `AGENTS.md`
|
||||
instead. For desktop-only changes, prefer desktop package commands first.
|
||||
|
||||
## Agent-Readable Changes
|
||||
|
||||
Favor changes that future agents can inspect and verify:
|
||||
|
||||
- Add structured log fields near failure boundaries instead of vague messages.
|
||||
- Add accessible names or stable UI affordances when DevTools snapshots are
|
||||
hard to interpret.
|
||||
- Keep docs as maps with links to deeper sources. Do not create giant manuals.
|
||||
- Convert repeated manual debugging steps into docs, tests, or structured logs.
|
||||
- Record non-trivial investigation notes in `.qwen/investigations/`.
|
||||
|
||||
Stop and ask the user only when the missing input cannot be discovered locally
|
||||
and a reasonable assumption would risk changing the wrong behavior.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Desktop Harness Principles
|
||||
|
||||
Use this reference when designing desktop development workflows, debugging
|
||||
loops, observability, or agent-facing docs.
|
||||
|
||||
## Source
|
||||
|
||||
OpenAI, "Engineering: Harnessing Codex in an agent-first world"
|
||||
https://openai.com/zh-Hans-CN/index/harness-engineering/
|
||||
|
||||
## Principles
|
||||
|
||||
- Give the agent a map, not a giant manual. Keep `AGENTS.md` and skills concise
|
||||
entry points that route to focused docs, tests, and logs.
|
||||
- Make the application readable to agents. UI snapshots, logs, metrics, traces,
|
||||
and runtime state should be directly inspectable without asking a human to
|
||||
copy/paste observations.
|
||||
- Treat the repo as the system of record. If a fix depends on tribal knowledge,
|
||||
encode that knowledge as versioned docs, tests, lint rules, or structured
|
||||
logging.
|
||||
- Build feedback loops, not one-off heroics. Reproduce, observe, patch,
|
||||
restart, and verify through the same harness until the evidence changes.
|
||||
- Prefer enforceable constraints over vague preference. If a pattern matters
|
||||
repeatedly, turn it into a test, linter, helper, or review checklist.
|
||||
- Use human attention for judgment. Let agents collect evidence, run tools,
|
||||
draft fixes, and verify; ask humans when product intent or risk cannot be
|
||||
inferred locally.
|
||||
|
||||
## Desktop Application Pattern
|
||||
|
||||
For qwen-code desktop work, the harness is:
|
||||
|
||||
1. Desktop runtime logs under `~/Library/Logs/@craft-agent/electron/`.
|
||||
2. Domain-specific logs such as `~/.craft-agent/logs/messaging-gateway.log`.
|
||||
3. Chrome DevTools MCP snapshots, console messages, network details, and heap
|
||||
snapshots.
|
||||
4. Focused package tests and typechecks under `packages/desktop`.
|
||||
5. Small repo artifacts under `.qwen/` for investigations, E2E notes, and skill
|
||||
improvements.
|
||||
|
||||
When one of these is missing or hard to read, consider improving the harness as
|
||||
part of the development task.
|
||||
@@ -1,235 +0,0 @@
|
||||
---
|
||||
name: desktop-pet
|
||||
description: Create pixel-art desktop pet companions for OpenWork/Qwen Code. Generates a customized chibi spritesheet (1536×1872, 8×9 grid) for any character the user names — F1 drivers, anime characters, celebrities, fictional characters, animals, etc. Use when the user says "桌面宠物", "desktop pet", "想要XXX当桌宠", "换个宠物" or similar.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Desktop Pet Creator
|
||||
|
||||
Create pixel-art chibi desktop pet companions for OpenWork's floating pet window.
|
||||
Given any character name, generate a complete pet package with animated spritesheet
|
||||
and place it in `~/.qwen/pets/` where OpenWork auto-discovers it.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Identify the Character
|
||||
|
||||
Ask the user who they want as their desktop pet if not already specified.
|
||||
Then research the character's visual appearance:
|
||||
|
||||
- **Team/organization colors** (e.g., McLaren papaya orange, Ferrari red)
|
||||
- **Outfit/uniform** (racing suit, school uniform, armor, etc.)
|
||||
- **Distinguishing features** (hair color/style, accessories, number, helmet)
|
||||
- **Personality traits** (for animation style — energetic, calm, goofy, serious)
|
||||
- **Iconic items** (steering wheel, lightsaber, guitar, etc.)
|
||||
|
||||
Use web search if needed to gather visual reference. For well-known characters
|
||||
(F1 drivers, popular anime, etc.), rely on training knowledge.
|
||||
|
||||
### Step 2: Design the Color Palette
|
||||
|
||||
Define 8-12 colors for the character:
|
||||
|
||||
| Color Role | Example (F1 Driver) | Example (Anime Character) |
|
||||
| -------------- | -------------------- | ------------------------- |
|
||||
| Primary outfit | Team color (papaya) | Uniform color (navy) |
|
||||
| Outfit dark | Darker shade | Darker shade |
|
||||
| Outfit light | Lighter shade | Lighter shade |
|
||||
| Skin | Warm skin tone | Skin tone |
|
||||
| Skin dark | Shadow skin | Shadow skin |
|
||||
| Hair | Character hair color | Character hair color |
|
||||
| Accent | Number/logo color | Eye color / accessory |
|
||||
| Shoe | Dark grey/black | Character shoe color |
|
||||
|
||||
**Important:** All colors must be distinct and work at small pixel scale (3x = 9px details).
|
||||
|
||||
### Step 3: Generate the Spritesheet
|
||||
|
||||
Use the template script at `scripts/gen_spritesheet.py` as a starting point.
|
||||
The script generates a **1536×1872 pixel RGBA spritesheet** (8 columns × 9 rows,
|
||||
192×208 px cells) — the exact format OpenWork expects.
|
||||
|
||||
**Run it like this:**
|
||||
|
||||
```bash
|
||||
python3 <skill_dir>/scripts/gen_spritesheet.py \
|
||||
--output ~/.qwen/pets/<character_id>/spritesheet.webp \
|
||||
--config '{"name":"...","colors":{...},"features":{...}}'
|
||||
```
|
||||
|
||||
Or copy and customize the script for characters that need unique visual elements
|
||||
not covered by the parameterized version.
|
||||
|
||||
**The 9 animation rows are:**
|
||||
|
||||
| Row | State | Description |
|
||||
| --- | ------------- | --------------------------------- |
|
||||
| 0 | idle | Breathing + blinking (8 frames) |
|
||||
| 1 | running-right | Running to the right (8 frames) |
|
||||
| 2 | running-left | Running to the left (8 frames) |
|
||||
| 3 | waving | Waving at user (8 frames) |
|
||||
| 4 | jumping | Jumping celebration (8 frames) |
|
||||
| 5 | failed | Sad/collapsed on error (8 frames) |
|
||||
| 6 | waiting | Idle tapping (8 frames) |
|
||||
| 7 | running | Generic running (8 frames) |
|
||||
| 8 | review | Thinking/examining (8 frames) |
|
||||
|
||||
### Step 4: Create pet.json
|
||||
|
||||
Write the manifest to `~/.qwen/pets/<character_id>/pet.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<character_id>",
|
||||
"displayName": "<Display Name>",
|
||||
"description": "<Short description — who is this character?>",
|
||||
"spritesheetPath": "spritesheet.webp"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `id`: lowercase, no spaces, URL-safe (e.g., `piastri`, `satoru`, `goku`)
|
||||
- `displayName`: The name shown in the UI (e.g., "Piastri", "五条悟", "悟空")
|
||||
- `description`: One short sentence describing the character
|
||||
|
||||
### Step 5: Verify and Activate
|
||||
|
||||
1. Confirm the files exist:
|
||||
|
||||
```bash
|
||||
ls -lh ~/.qwen/pets/<character_id>/
|
||||
```
|
||||
|
||||
2. Open the spritesheet in Preview for the user to check:
|
||||
|
||||
```bash
|
||||
open ~/.qwen/pets/<character_id>/spritesheet.webp
|
||||
```
|
||||
|
||||
3. Tell the user to activate:
|
||||
> Open **OpenWork → Settings → Appearance → Pet Companion**,
|
||||
> click **Refresh**, then select **<Display Name>**.
|
||||
|
||||
## Character Design Guidelines
|
||||
|
||||
### Chibi Proportions
|
||||
|
||||
- **Head**: ~40% of total height (big head = cute)
|
||||
- **Body**: ~30% of total height
|
||||
- **Legs**: ~25% of total height
|
||||
- **Scale**: Each "pixel" in the art = 3×3 actual pixels (scale=3)
|
||||
- **Character center**: approximately (96, 124) within the 192×208 cell
|
||||
|
||||
### Drawing Order (back to front)
|
||||
|
||||
1. Legs (behind body)
|
||||
2. Body / outfit
|
||||
3. Arms
|
||||
4. Head shape
|
||||
5. Hair (back layer)
|
||||
6. Hair (front/top layer)
|
||||
7. Face features (eyes, mouth, expression)
|
||||
8. Accessories (hat, helmet, glasses, etc.)
|
||||
9. Foreground details (number, logo, badge)
|
||||
|
||||
### Animation Tips
|
||||
|
||||
- **Idle**: subtle Y bob (0 to -2px) + blink every 3rd-4th frame
|
||||
- **Running**: alternating leg offset (±4px), body tilt (±2px), arm swing
|
||||
- **Waving**: one arm raised high, alternating frames
|
||||
- **Jumping**: Y offset curve (0 → -30 → 0), arms up
|
||||
- **Failed**: body tilt increases, then collapse to sitting pose
|
||||
- **Happy expression**: curved eyes (∧ shape), blush marks on cheeks
|
||||
- **Sad expression**: straight eyebrows, downturned mouth
|
||||
|
||||
### Headgear Variants
|
||||
|
||||
The template supports several headgear types. Set via `features.headgear`:
|
||||
|
||||
- `cap` — baseball cap with brim (default for F1 drivers)
|
||||
- `helmet` — full racing helmet with visor
|
||||
- `none` — no headgear (just hair)
|
||||
- `hat` — generic hat
|
||||
- `hood` — hooded outfit
|
||||
- `crown` — royal crown
|
||||
- `horns` — devil/dragon horns
|
||||
- `ears` — animal ears (cat, dog, etc.)
|
||||
- `halo` — angel halo
|
||||
- `headband` — ninja/sports headband
|
||||
|
||||
### Special Features
|
||||
|
||||
Set via `features.extras` (list):
|
||||
|
||||
- `glasses` — round or rectangular glasses
|
||||
- `scarf` — neck scarf
|
||||
- `tail` — animal tail
|
||||
- `wings` — small wings on back
|
||||
- `number` — chest number (set `features.number` to the number string)
|
||||
- `logo` — chest badge/logo area
|
||||
- `sweat_drop` — anime sweat drop (in waiting/failed states)
|
||||
|
||||
## Example Characters
|
||||
|
||||
### F1 Driver (e.g., Piastri, Norris, Verstappen)
|
||||
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"outfit": [255, 135, 32],
|
||||
"outfit_dark": [220, 110, 20],
|
||||
"outfit_light": [255, 170, 80],
|
||||
"hair": [120, 80, 40],
|
||||
"number": [30, 30, 30]
|
||||
},
|
||||
"features": {
|
||||
"headgear": "cap",
|
||||
"number": "81",
|
||||
"extras": ["logo"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anime Character (e.g., Gojo Satoru)
|
||||
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"outfit": [30, 30, 50],
|
||||
"outfit_dark": [20, 20, 35],
|
||||
"outfit_light": [60, 60, 80],
|
||||
"hair": [230, 230, 250],
|
||||
"accent": [100, 180, 255]
|
||||
},
|
||||
"features": {
|
||||
"headgear": "none",
|
||||
"extras": ["glasses"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animal (e.g., Shiba Inu)
|
||||
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"outfit": [220, 170, 100],
|
||||
"outfit_dark": [180, 130, 70],
|
||||
"outfit_light": [240, 200, 140],
|
||||
"hair": [220, 170, 100],
|
||||
"accent": [255, 255, 255]
|
||||
},
|
||||
"features": {
|
||||
"headgear": "ears",
|
||||
"extras": ["tail"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Pet not showing**: Click Refresh in Settings → Appearance → Pet Companion
|
||||
- **Colors look wrong**: Check that RGB values are tuples, not hex strings
|
||||
- **Spritesheet too large**: Must be under 5MB (webp lossless usually ~8-50KB)
|
||||
- **Animation jittery**: Ensure all 8 frames per row are visually distinct but not jarring
|
||||
@@ -1,558 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Desktop Pet Spritesheet Generator for OpenWork.
|
||||
|
||||
Generates a 1536×1872 pixel-art chibi spritesheet (8 cols × 9 rows, 192×208 px cells)
|
||||
customized via a JSON config describing colors, headgear, and features.
|
||||
|
||||
Usage:
|
||||
python3 gen_spritesheet.py --output ~/.qwen/pets/mychar/spritesheet.webp --config '{...}'
|
||||
python3 gen_spritesheet.py --output out.webp --config-file config.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import math
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("ERROR: Pillow is required. Install with: pip3 install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Atlas layout ---
|
||||
COLS, ROWS = 8, 9
|
||||
CW, CH = 192, 208
|
||||
W, H = COLS * CW, ROWS * CH
|
||||
|
||||
# --- Default color palette (Qwen capybara-like neutral) ---
|
||||
DEFAULT_COLORS = {
|
||||
"outfit": [100, 120, 180],
|
||||
"outfit_dark": [70, 85, 140],
|
||||
"outfit_light": [140, 160, 210],
|
||||
"skin": [255, 218, 185],
|
||||
"skin_dark": [230, 190, 155],
|
||||
"hair": [80, 55, 35],
|
||||
"hair_light": [110, 80, 55],
|
||||
"accent": [30, 30, 30],
|
||||
"shoe": [50, 50, 50],
|
||||
"eye": [50, 70, 90],
|
||||
"eye_white": [245, 245, 245],
|
||||
"blush": [255, 160, 140],
|
||||
"mouth": [180, 80, 80],
|
||||
}
|
||||
|
||||
DEFAULT_FEATURES = {
|
||||
"headgear": "none",
|
||||
"extras": [],
|
||||
"number": "",
|
||||
"hair_style": "short",
|
||||
"helmet_color": None,
|
||||
}
|
||||
|
||||
def tuple_color(c):
|
||||
if isinstance(c, list):
|
||||
return tuple(c)
|
||||
if isinstance(c, str) and c.startswith("#"):
|
||||
h = c.lstrip("#")
|
||||
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
||||
return c
|
||||
|
||||
def darken(color, amount=40):
|
||||
return tuple(max(0, c - amount) for c in color[:3])
|
||||
|
||||
def lighten(color, amount=40):
|
||||
return tuple(min(255, c + amount) for c in color[:3])
|
||||
|
||||
def fill_rect(draw, x, y, w, h, color):
|
||||
draw.rectangle([x, y, x + w - 1, y + h - 1], fill=color)
|
||||
|
||||
|
||||
def draw_headgear(draw, hx, hy, s, colors, features, flip=False):
|
||||
"""Draw headgear on top of the head."""
|
||||
hg = features.get("headgear", "none")
|
||||
outfit = colors["outfit"]
|
||||
outfit_dark = colors["outfit_dark"]
|
||||
helmet_c = tuple_color(features.get("helmet_color") or outfit)
|
||||
|
||||
if hg == "cap":
|
||||
cap_y = hy - 7 * s
|
||||
fill_rect(draw, hx - 2*s, cap_y, 24*s, 5*s, outfit)
|
||||
fill_rect(draw, hx + 2*s, cap_y - 2*s, 16*s, 3*s, outfit)
|
||||
fill_rect(draw, hx + 4*s, cap_y - 3*s, 12*s, 2*s, outfit_dark)
|
||||
if not flip:
|
||||
fill_rect(draw, hx - 4*s, cap_y + 4*s, 26*s, 2*s, outfit_dark)
|
||||
else:
|
||||
fill_rect(draw, hx - 2*s, cap_y + 4*s, 26*s, 2*s, outfit_dark)
|
||||
fill_rect(draw, hx + 7*s, cap_y + s, 6*s, 2*s, colors["accent"])
|
||||
|
||||
elif hg == "helmet":
|
||||
cap_y = hy - 8 * s
|
||||
fill_rect(draw, hx - 3*s, cap_y, 26*s, 8*s, helmet_c)
|
||||
fill_rect(draw, hx + 2*s, cap_y - 2*s, 16*s, 3*s, helmet_c)
|
||||
fill_rect(draw, hx + 4*s, cap_y - 3*s, 12*s, 2*s, darken(helmet_c, 20))
|
||||
fill_rect(draw, hx, cap_y + 5*s, 20*s, 3*s, darken(helmet_c, 60))
|
||||
fill_rect(draw, hx + 2*s, cap_y + 6*s, 16*s, s, lighten(helmet_c, 60))
|
||||
|
||||
elif hg == "hat":
|
||||
cap_y = hy - 6 * s
|
||||
fill_rect(draw, hx - 4*s, cap_y + 3*s, 28*s, 3*s, outfit_dark)
|
||||
fill_rect(draw, hx + 2*s, cap_y - 2*s, 16*s, 6*s, outfit)
|
||||
fill_rect(draw, hx + 4*s, cap_y - 3*s, 12*s, 2*s, outfit_dark)
|
||||
|
||||
elif hg == "hood":
|
||||
cap_y = hy - 5 * s
|
||||
fill_rect(draw, hx - 3*s, cap_y, 26*s, 4*s, outfit)
|
||||
fill_rect(draw, hx - 4*s, cap_y + 2*s, 4*s, 8*s, outfit)
|
||||
fill_rect(draw, hx + 20*s, cap_y + 2*s, 4*s, 8*s, outfit)
|
||||
fill_rect(draw, hx + 4*s, cap_y - 2*s, 12*s, 3*s, outfit_dark)
|
||||
|
||||
elif hg == "crown":
|
||||
cap_y = hy - 8 * s
|
||||
gold = (255, 215, 0)
|
||||
gold_dark = (200, 170, 0)
|
||||
fill_rect(draw, hx + 2*s, cap_y + 2*s, 16*s, 4*s, gold)
|
||||
fill_rect(draw, hx + 2*s, cap_y, 3*s, 3*s, gold)
|
||||
fill_rect(draw, hx + 8*s, cap_y - s, 4*s, 3*s, gold)
|
||||
fill_rect(draw, hx + 15*s, cap_y, 3*s, 3*s, gold)
|
||||
fill_rect(draw, hx + 3*s, cap_y + s, s, s, (200, 50, 50))
|
||||
fill_rect(draw, hx + 9*s, cap_y, 2*s, s, (50, 150, 200))
|
||||
fill_rect(draw, hx + 16*s, cap_y + s, s, s, (50, 200, 50))
|
||||
fill_rect(draw, hx + 2*s, cap_y + 5*s, 16*s, s, gold_dark)
|
||||
|
||||
elif hg == "horns":
|
||||
horn_c = (80, 60, 50)
|
||||
fill_rect(draw, hx - 2*s, hy - 6*s, 3*s, 8*s, horn_c)
|
||||
fill_rect(draw, hx - 3*s, hy - 8*s, 2*s, 3*s, horn_c)
|
||||
fill_rect(draw, hx + 19*s, hy - 6*s, 3*s, 8*s, horn_c)
|
||||
fill_rect(draw, hx + 21*s, hy - 8*s, 2*s, 3*s, horn_c)
|
||||
|
||||
elif hg == "ears":
|
||||
hair_c = colors["hair"]
|
||||
inner = colors["skin"]
|
||||
fill_rect(draw, hx - 3*s, hy - 8*s, 5*s, 8*s, hair_c)
|
||||
fill_rect(draw, hx - 2*s, hy - 6*s, 3*s, 5*s, inner)
|
||||
fill_rect(draw, hx + 18*s, hy - 8*s, 5*s, 8*s, hair_c)
|
||||
fill_rect(draw, hx + 19*s, hy - 6*s, 3*s, 5*s, inner)
|
||||
|
||||
elif hg == "halo":
|
||||
halo_c = (255, 255, 200)
|
||||
cap_y = hy - 10 * s
|
||||
fill_rect(draw, hx + 3*s, cap_y, 14*s, 2*s, halo_c)
|
||||
fill_rect(draw, hx + 2*s, cap_y + s, s, s, halo_c)
|
||||
fill_rect(draw, hx + 17*s, cap_y + s, s, s, halo_c)
|
||||
fill_rect(draw, hx + 3*s, cap_y + 2*s, 14*s, s, darken(halo_c, 30))
|
||||
|
||||
elif hg == "headband":
|
||||
fill_rect(draw, hx - 2*s, hy - 2*s, 24*s, 2*s, colors["accent"])
|
||||
fill_rect(draw, hx + 18*s, hy - 2*s, 4*s, 6*s, colors["accent"])
|
||||
|
||||
|
||||
def draw_extras(draw, cx, cy, s, colors, features, bx, by, expression):
|
||||
"""Draw extra features like glasses, scarf, tail, wings."""
|
||||
extras = features.get("extras", [])
|
||||
hx = cx - 10 * s
|
||||
hy = cy - 16 * s
|
||||
|
||||
if "glasses" in extras:
|
||||
ey = hy + 8 * s
|
||||
glass_c = (60, 60, 80)
|
||||
fill_rect(draw, hx + 3*s, ey - s, 6*s, 5*s, glass_c)
|
||||
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, (200, 220, 240))
|
||||
fill_rect(draw, hx + 11*s, ey - s, 6*s, 5*s, glass_c)
|
||||
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, (200, 220, 240))
|
||||
fill_rect(draw, hx + 9*s, ey + s, 2*s, s, glass_c)
|
||||
|
||||
if "scarf" in extras:
|
||||
scarf_c = lighten(colors["outfit"], 30)
|
||||
fill_rect(draw, bx + 3*s, by - 2*s, 10*s, 3*s, scarf_c)
|
||||
fill_rect(draw, bx + 4*s, by + s, 3*s, 6*s, scarf_c)
|
||||
|
||||
if "tail" in extras:
|
||||
tail_c = colors["hair"]
|
||||
fill_rect(draw, bx + 16*s, by + 10*s, 3*s, 3*s, tail_c)
|
||||
fill_rect(draw, bx + 18*s, by + 8*s, 3*s, 3*s, tail_c)
|
||||
fill_rect(draw, bx + 20*s, by + 6*s, 3*s, 3*s, tail_c)
|
||||
fill_rect(draw, bx + 21*s, by + 4*s, 2*s, 3*s, tail_c)
|
||||
|
||||
if "wings" in extras:
|
||||
wing_c = (240, 240, 255)
|
||||
wing_dark = (200, 200, 220)
|
||||
fill_rect(draw, bx - 6*s, by + 2*s, 5*s, 8*s, wing_c)
|
||||
fill_rect(draw, bx - 8*s, by + 4*s, 3*s, 5*s, wing_c)
|
||||
fill_rect(draw, bx - 5*s, by + 3*s, 3*s, 5*s, wing_dark)
|
||||
fill_rect(draw, bx + 17*s, by + 2*s, 5*s, 8*s, wing_c)
|
||||
fill_rect(draw, bx + 21*s, by + 4*s, 3*s, 5*s, wing_c)
|
||||
fill_rect(draw, bx + 18*s, by + 3*s, 3*s, 5*s, wing_dark)
|
||||
|
||||
if "sweat_drop" in extras and expression in ("waiting", "failed"):
|
||||
fill_rect(draw, hx + 18*s, hy + 2*s, 2*s, 3*s, (150, 200, 255))
|
||||
fill_rect(draw, hx + 18*s, hy + s, s, s, (150, 200, 255))
|
||||
|
||||
|
||||
def draw_character(draw, cx, cy, colors, features, scale=3, flip=False,
|
||||
arm_angle=0, leg_offset=0, body_tilt=0, head_tilt=0,
|
||||
expression="normal", arm_wave=False, jump_y=0, collapsed=False):
|
||||
"""Draw a chibi character centered at (cx, cy)."""
|
||||
s = scale
|
||||
cy += jump_y
|
||||
|
||||
skin = colors["skin"]
|
||||
skin_dark = colors["skin_dark"]
|
||||
hair_c = colors["hair"]
|
||||
hair_light = colors.get("hair_light", lighten(hair_c, 30))
|
||||
outfit = colors["outfit"]
|
||||
outfit_dark = colors["outfit_dark"]
|
||||
outfit_light = colors["outfit_light"]
|
||||
eye_c = colors["eye"]
|
||||
eye_w = colors["eye_white"]
|
||||
blush_c = colors["blush"]
|
||||
mouth_c = colors.get("mouth", darken(skin, 80))
|
||||
accent_c = colors["accent"]
|
||||
shoe_c = colors["shoe"]
|
||||
|
||||
# --- LEGS ---
|
||||
leg_spread = 4 * s
|
||||
leg_left_x = cx - leg_spread - 2*s + body_tilt
|
||||
leg_right_x = cx + leg_spread - 2*s + body_tilt
|
||||
|
||||
if collapsed:
|
||||
fill_rect(draw, leg_left_x, cy + 18*s, 5*s, 6*s, outfit_dark)
|
||||
fill_rect(draw, leg_right_x, cy + 18*s, 5*s, 6*s, outfit_dark)
|
||||
fill_rect(draw, leg_left_x, cy + 24*s, 5*s, 2*s, shoe_c)
|
||||
fill_rect(draw, leg_right_x, cy + 24*s, 5*s, 2*s, shoe_c)
|
||||
else:
|
||||
fill_rect(draw, leg_left_x + leg_offset, cy + 16*s, 5*s, 10*s, outfit_dark)
|
||||
fill_rect(draw, leg_right_x - leg_offset, cy + 16*s, 5*s, 10*s, outfit_dark)
|
||||
fill_rect(draw, leg_left_x + leg_offset - s, cy + 26*s, 7*s, 3*s, shoe_c)
|
||||
fill_rect(draw, leg_right_x - leg_offset - s, cy + 26*s, 7*s, 3*s, shoe_c)
|
||||
|
||||
# --- BODY ---
|
||||
bx = cx - 8*s + body_tilt
|
||||
by = cy + 2*s
|
||||
fill_rect(draw, bx, by, 16*s, 16*s, outfit)
|
||||
fill_rect(draw, bx + s, by + s, 14*s, 2*s, outfit_light)
|
||||
fill_rect(draw, bx + 5*s, by - s, 6*s, 2*s, (255, 255, 255))
|
||||
|
||||
# Number on chest
|
||||
num = features.get("number", "")
|
||||
if num:
|
||||
try:
|
||||
font_size = max(5 * s, 8)
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), num, font=font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
th = bbox[3] - bbox[1]
|
||||
tx = bx + (16*s - tw) // 2
|
||||
ty = by + (14*s - th) // 2 + s
|
||||
draw.text((tx, ty), num, fill=accent_c, font=font)
|
||||
|
||||
# Logo area
|
||||
if "logo" in features.get("extras", []) and not num:
|
||||
fill_rect(draw, bx + 5*s, by + 5*s, 6*s, 4*s, accent_c)
|
||||
fill_rect(draw, bx + 6*s, by + 6*s, 4*s, 2*s, outfit_light)
|
||||
|
||||
# --- ARMS ---
|
||||
arm_y = by + 3*s
|
||||
left_arm_x = bx - 5*s
|
||||
right_arm_x = bx + 16*s
|
||||
|
||||
if arm_wave:
|
||||
fill_rect(draw, left_arm_x, arm_y + 2*s, 5*s, 8*s, outfit)
|
||||
fill_rect(draw, left_arm_x - s, arm_y + 10*s, 5*s, 4*s, skin)
|
||||
fill_rect(draw, right_arm_x, arm_y - 8*s, 5*s, 10*s, outfit)
|
||||
fill_rect(draw, right_arm_x, arm_y - 10*s, 6*s, 4*s, skin)
|
||||
elif arm_angle > 0:
|
||||
fill_rect(draw, left_arm_x - arm_angle, arm_y, 5*s, 10*s, outfit)
|
||||
fill_rect(draw, left_arm_x - arm_angle - s, arm_y + 10*s, 5*s, 4*s, skin)
|
||||
fill_rect(draw, right_arm_x + arm_angle, arm_y, 5*s, 10*s, outfit)
|
||||
fill_rect(draw, right_arm_x + arm_angle + s, arm_y + 10*s, 5*s, 4*s, skin)
|
||||
else:
|
||||
fill_rect(draw, left_arm_x, arm_y, 5*s, 10*s, outfit)
|
||||
fill_rect(draw, left_arm_x - s, arm_y + 10*s, 5*s, 4*s, skin)
|
||||
fill_rect(draw, right_arm_x, arm_y, 5*s, 10*s, outfit)
|
||||
fill_rect(draw, right_arm_x + s, arm_y + 10*s, 5*s, 4*s, skin)
|
||||
|
||||
# --- Scarf (drawn between body and head) ---
|
||||
draw_extras(draw, cx, cy, s, colors, features, bx, by, expression)
|
||||
|
||||
# --- HEAD ---
|
||||
hx = cx - 10*s + head_tilt
|
||||
hy = cy - 16*s
|
||||
|
||||
# Hair back
|
||||
fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c)
|
||||
# Head shape
|
||||
fill_rect(draw, hx, hy, 20*s, 18*s, skin)
|
||||
|
||||
# Hair style
|
||||
hair_style = features.get("hair_style", "short")
|
||||
if hair_style == "long":
|
||||
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
|
||||
fill_rect(draw, hx + 2*s, hy - 5*s, 16*s, 3*s, hair_c)
|
||||
fill_rect(draw, hx + 4*s, hy - 6*s, 12*s, 2*s, hair_light)
|
||||
fill_rect(draw, hx - 2*s, hy, 3*s, 16*s, hair_c)
|
||||
fill_rect(draw, hx + 19*s, hy, 3*s, 16*s, hair_c)
|
||||
fill_rect(draw, hx - 3*s, hy + 14*s, 4*s, 4*s, hair_c)
|
||||
fill_rect(draw, hx + 19*s, hy + 14*s, 4*s, 4*s, hair_c)
|
||||
elif hair_style == "spiky":
|
||||
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
|
||||
fill_rect(draw, hx + s, hy - 7*s, 4*s, 4*s, hair_c)
|
||||
fill_rect(draw, hx + 6*s, hy - 8*s, 4*s, 5*s, hair_c)
|
||||
fill_rect(draw, hx + 11*s, hy - 7*s, 4*s, 4*s, hair_c)
|
||||
fill_rect(draw, hx + 16*s, hy - 6*s, 3*s, 3*s, hair_c)
|
||||
fill_rect(draw, hx - 2*s, hy, 3*s, 10*s, hair_c)
|
||||
fill_rect(draw, hx + 19*s, hy, 3*s, 10*s, hair_c)
|
||||
elif hair_style == "ponytail":
|
||||
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
|
||||
fill_rect(draw, hx + 2*s, hy - 5*s, 16*s, 3*s, hair_c)
|
||||
fill_rect(draw, hx - 2*s, hy, 3*s, 10*s, hair_c)
|
||||
fill_rect(draw, hx + 19*s, hy, 3*s, 10*s, hair_c)
|
||||
fill_rect(draw, hx + 18*s, hy + 8*s, 3*s, 3*s, hair_c)
|
||||
fill_rect(draw, hx + 19*s, hy + 10*s, 3*s, 8*s, hair_c)
|
||||
fill_rect(draw, hx + 20*s, hy + 16*s, 2*s, 4*s, hair_light)
|
||||
elif hair_style == "bald":
|
||||
fill_rect(draw, hx + 2*s, hy - 2*s, 16*s, 2*s, skin_dark)
|
||||
else: # short (default)
|
||||
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
|
||||
fill_rect(draw, hx + 2*s, hy - 5*s, 16*s, 3*s, hair_c)
|
||||
fill_rect(draw, hx + 4*s, hy - 6*s, 12*s, 2*s, hair_light)
|
||||
fill_rect(draw, hx - 2*s, hy, 3*s, 10*s, hair_c)
|
||||
fill_rect(draw, hx + 19*s, hy, 3*s, 10*s, hair_c)
|
||||
|
||||
# --- FACE ---
|
||||
ey = hy + 8*s
|
||||
|
||||
if expression == "blink":
|
||||
fill_rect(draw, hx + 4*s, ey + s, 4*s, s, eye_c)
|
||||
fill_rect(draw, hx + 12*s, ey + s, 4*s, s, eye_c)
|
||||
elif expression == "happy":
|
||||
fill_rect(draw, hx + 4*s, ey, 4*s, s, eye_c)
|
||||
fill_rect(draw, hx + 3*s, ey + s, s, s, eye_c)
|
||||
fill_rect(draw, hx + 8*s, ey + s, s, s, eye_c)
|
||||
fill_rect(draw, hx + 12*s, ey, 4*s, s, eye_c)
|
||||
fill_rect(draw, hx + 11*s, ey + s, s, s, eye_c)
|
||||
fill_rect(draw, hx + 16*s, ey + s, s, s, eye_c)
|
||||
fill_rect(draw, hx + 7*s, ey + 5*s, 6*s, s, mouth_c)
|
||||
fill_rect(draw, hx + 6*s, ey + 4*s, s, s, mouth_c)
|
||||
fill_rect(draw, hx + 13*s, ey + 4*s, s, s, mouth_c)
|
||||
fill_rect(draw, hx + 2*s, ey + 3*s, 3*s, 2*s, blush_c)
|
||||
fill_rect(draw, hx + 15*s, ey + 3*s, 3*s, 2*s, blush_c)
|
||||
elif expression == "sad":
|
||||
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, eye_w)
|
||||
fill_rect(draw, hx + 5*s, ey + s, 2*s, 2*s, eye_c)
|
||||
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, eye_w)
|
||||
fill_rect(draw, hx + 13*s, ey + s, 2*s, 2*s, eye_c)
|
||||
fill_rect(draw, hx + 3*s, ey - 2*s, 5*s, s, hair_c)
|
||||
fill_rect(draw, hx + 12*s, ey - 2*s, 5*s, s, hair_c)
|
||||
fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c)
|
||||
fill_rect(draw, hx + 7*s, ey + 6*s, s, s, mouth_c)
|
||||
fill_rect(draw, hx + 12*s, ey + 6*s, s, s, mouth_c)
|
||||
elif expression == "surprised":
|
||||
fill_rect(draw, hx + 3*s, ey - s, 5*s, 4*s, eye_w)
|
||||
fill_rect(draw, hx + 4*s, ey, 3*s, 3*s, eye_c)
|
||||
fill_rect(draw, hx + 5*s, ey + s, s, s, (255, 255, 255))
|
||||
fill_rect(draw, hx + 12*s, ey - s, 5*s, 4*s, eye_w)
|
||||
fill_rect(draw, hx + 13*s, ey, 3*s, 3*s, eye_c)
|
||||
fill_rect(draw, hx + 14*s, ey + s, s, s, (255, 255, 255))
|
||||
fill_rect(draw, hx + 8*s, ey + 4*s, 4*s, 3*s, mouth_c)
|
||||
fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80))
|
||||
elif expression == "determined":
|
||||
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, eye_w)
|
||||
fill_rect(draw, hx + 6*s, ey + s, 2*s, 2*s, eye_c)
|
||||
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, eye_w)
|
||||
fill_rect(draw, hx + 14*s, ey + s, 2*s, 2*s, eye_c)
|
||||
fill_rect(draw, hx + 3*s, ey - 2*s, 6*s, s, hair_c)
|
||||
fill_rect(draw, hx + 11*s, ey - 2*s, 6*s, s, hair_c)
|
||||
fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c)
|
||||
else: # normal
|
||||
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, eye_w)
|
||||
fill_rect(draw, hx + 5*s, ey + s, 2*s, 2*s, eye_c)
|
||||
fill_rect(draw, hx + 5*s, ey + s, s, s, (255, 255, 255))
|
||||
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, eye_w)
|
||||
fill_rect(draw, hx + 13*s, ey + s, 2*s, 2*s, eye_c)
|
||||
fill_rect(draw, hx + 13*s, ey + s, s, s, (255, 255, 255))
|
||||
fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c)
|
||||
fill_rect(draw, hx + 7*s, ey + 4*s, s, s, mouth_c)
|
||||
fill_rect(draw, hx + 12*s, ey + 4*s, s, s, mouth_c)
|
||||
|
||||
# --- HEADGEAR ---
|
||||
draw_headgear(draw, hx, hy, s, colors, features, flip)
|
||||
|
||||
|
||||
def gen_row(sheet, row, colors, features, frame_configs):
|
||||
"""Generate one animation row from frame configs."""
|
||||
for col, fc in enumerate(frame_configs):
|
||||
img = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
draw_character(
|
||||
d, CW // 2 + fc.get("dx", 0), CH // 2 + 20,
|
||||
colors, features, scale=3,
|
||||
flip=fc.get("flip", False),
|
||||
arm_angle=fc.get("arm_angle", 0),
|
||||
leg_offset=fc.get("leg_offset", 0),
|
||||
body_tilt=fc.get("body_tilt", 0),
|
||||
head_tilt=fc.get("head_tilt", 0),
|
||||
expression=fc.get("expression", "normal"),
|
||||
arm_wave=fc.get("arm_wave", False),
|
||||
jump_y=fc.get("jump_y", 0),
|
||||
collapsed=fc.get("collapsed", False),
|
||||
)
|
||||
sheet.paste(img, (col * CW, row * CH), img)
|
||||
|
||||
|
||||
def gen_all_animations(sheet, colors, features):
|
||||
"""Generate all 9 animation rows."""
|
||||
|
||||
# Row 0: idle
|
||||
gen_row(sheet, 0, colors, features, [
|
||||
{"jump_y": 0, "expression": "normal"},
|
||||
{"jump_y": -1, "expression": "normal"},
|
||||
{"jump_y": -2, "expression": "blink"},
|
||||
{"jump_y": -1, "expression": "normal"},
|
||||
{"jump_y": 0, "expression": "normal"},
|
||||
{"jump_y": -1, "expression": "normal"},
|
||||
{"jump_y": 0, "expression": "normal"},
|
||||
{"jump_y": 0, "expression": "blink"},
|
||||
])
|
||||
|
||||
# Row 1: running right
|
||||
gen_row(sheet, 1, colors, features, [
|
||||
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
|
||||
{"leg_offset": 4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
|
||||
{"leg_offset": 0, "body_tilt": 2, "head_tilt": 2, "arm_angle": 3, "jump_y": -3, "dx": 8},
|
||||
{"leg_offset": -4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
|
||||
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
|
||||
{"leg_offset": 4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
|
||||
{"leg_offset": 0, "body_tilt": -2, "head_tilt": -2, "arm_angle": -3, "jump_y": -3, "dx": 8},
|
||||
{"leg_offset": -4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
|
||||
])
|
||||
|
||||
# Row 2: running left
|
||||
gen_row(sheet, 2, colors, features, [
|
||||
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "flip": True, "dx": -8},
|
||||
{"leg_offset": 4, "body_tilt": -1, "head_tilt": -1, "arm_angle": 2, "jump_y": -2, "flip": True, "dx": -8},
|
||||
{"leg_offset": 0, "body_tilt": -2, "head_tilt": -2, "arm_angle": 3, "jump_y": -3, "flip": True, "dx": -8},
|
||||
{"leg_offset": -4, "body_tilt": -1, "head_tilt": -1, "arm_angle": 2, "jump_y": -2, "flip": True, "dx": -8},
|
||||
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "flip": True, "dx": -8},
|
||||
{"leg_offset": 4, "body_tilt": 1, "head_tilt": 1, "arm_angle": -2, "jump_y": -2, "flip": True, "dx": -8},
|
||||
{"leg_offset": 0, "body_tilt": 2, "head_tilt": 2, "arm_angle": -3, "jump_y": -3, "flip": True, "dx": -8},
|
||||
{"leg_offset": -4, "body_tilt": 1, "head_tilt": 1, "arm_angle": -2, "jump_y": -2, "flip": True, "dx": -8},
|
||||
])
|
||||
|
||||
# Row 3: waving
|
||||
gen_row(sheet, 3, colors, features, [
|
||||
{"arm_wave": True, "jump_y": 0, "expression": "happy"},
|
||||
{"arm_wave": False, "jump_y": -1, "expression": "happy"},
|
||||
{"arm_wave": True, "jump_y": 0, "expression": "happy"},
|
||||
{"arm_wave": False, "jump_y": -1, "expression": "happy"},
|
||||
{"arm_wave": True, "jump_y": 0, "expression": "happy"},
|
||||
{"arm_wave": False, "jump_y": 0, "expression": "happy"},
|
||||
{"arm_wave": True, "jump_y": 0, "expression": "normal"},
|
||||
{"arm_wave": False, "jump_y": 0, "expression": "normal"},
|
||||
])
|
||||
|
||||
# Row 4: jumping
|
||||
gen_row(sheet, 4, colors, features, [
|
||||
{"jump_y": 0, "arm_angle": 0, "expression": "normal"},
|
||||
{"jump_y": -8, "arm_angle": 3, "expression": "happy"},
|
||||
{"jump_y": -20, "arm_angle": 5, "expression": "happy"},
|
||||
{"jump_y": -30, "arm_angle": 5, "expression": "happy"},
|
||||
{"jump_y": -35, "arm_angle": 5, "expression": "happy"},
|
||||
{"jump_y": -25, "arm_angle": 3, "expression": "happy"},
|
||||
{"jump_y": -10, "arm_angle": 0, "expression": "happy"},
|
||||
{"jump_y": 0, "arm_angle": 0, "expression": "normal"},
|
||||
])
|
||||
|
||||
# Row 5: failed
|
||||
gen_row(sheet, 5, colors, features, [
|
||||
{"body_tilt": 0, "head_tilt": 0, "expression": "sad"},
|
||||
{"body_tilt": -1, "head_tilt": -2, "expression": "sad"},
|
||||
{"body_tilt": -2, "head_tilt": -4, "expression": "sad"},
|
||||
{"body_tilt": -3, "head_tilt": -6, "expression": "sad"},
|
||||
{"body_tilt": -3, "head_tilt": -6, "expression": "sad", "collapsed": True},
|
||||
{"body_tilt": -2, "head_tilt": -4, "expression": "sad", "collapsed": True},
|
||||
{"body_tilt": -1, "head_tilt": -2, "expression": "sad"},
|
||||
{"body_tilt": 0, "head_tilt": 0, "expression": "sad"},
|
||||
])
|
||||
|
||||
# Row 6: waiting
|
||||
gen_row(sheet, 6, colors, features, [
|
||||
{"jump_y": 0, "head_tilt": 0, "expression": "normal"},
|
||||
{"jump_y": -1, "head_tilt": 0, "expression": "normal"},
|
||||
{"jump_y": 0, "head_tilt": 2, "expression": "normal"},
|
||||
{"jump_y": -1, "head_tilt": 0, "expression": "normal"},
|
||||
{"jump_y": 0, "head_tilt": -2, "expression": "blink"},
|
||||
{"jump_y": 0, "head_tilt": 0, "expression": "blink"},
|
||||
{"jump_y": 0, "head_tilt": 0, "expression": "normal"},
|
||||
{"jump_y": 0, "head_tilt": 0, "expression": "normal"},
|
||||
])
|
||||
|
||||
# Row 7: running (generic, same as row 1)
|
||||
gen_row(sheet, 7, colors, features, [
|
||||
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
|
||||
{"leg_offset": 4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
|
||||
{"leg_offset": 0, "body_tilt": 2, "head_tilt": 2, "arm_angle": 3, "jump_y": -3, "dx": 8},
|
||||
{"leg_offset": -4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
|
||||
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
|
||||
{"leg_offset": 4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
|
||||
{"leg_offset": 0, "body_tilt": -2, "head_tilt": -2, "arm_angle": -3, "jump_y": -3, "dx": 8},
|
||||
{"leg_offset": -4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
|
||||
])
|
||||
|
||||
# Row 8: review/thinking
|
||||
gen_row(sheet, 8, colors, features, [
|
||||
{"head_tilt": 0, "arm_angle": 2, "expression": "surprised"},
|
||||
{"head_tilt": 2, "arm_angle": 2, "expression": "surprised", "jump_y": -1},
|
||||
{"head_tilt": 4, "arm_angle": 2, "expression": "surprised", "jump_y": -1},
|
||||
{"head_tilt": 4, "arm_angle": 0, "expression": "normal"},
|
||||
{"head_tilt": 2, "arm_angle": 0, "expression": "happy"},
|
||||
{"head_tilt": 0, "arm_angle": 0, "expression": "happy"},
|
||||
{"head_tilt": 0, "arm_angle": 0, "expression": "normal"},
|
||||
{"head_tilt": 0, "arm_angle": 0, "expression": "normal"},
|
||||
])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate desktop pet spritesheet")
|
||||
parser.add_argument("--output", "-o", required=True, help="Output .webp file path")
|
||||
parser.add_argument("--config", "-c", type=str, help="JSON config string")
|
||||
parser.add_argument("--config-file", "-f", help="Path to JSON config file")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = {}
|
||||
if args.config_file:
|
||||
with open(args.config_file) as f:
|
||||
config = json.load(f)
|
||||
elif args.config:
|
||||
config = json.loads(args.config)
|
||||
|
||||
# Merge colors with defaults (convert all to tuples)
|
||||
colors = {k: tuple_color(v) for k, v in DEFAULT_COLORS.items()}
|
||||
for k, v in config.get("colors", {}).items():
|
||||
colors[k] = tuple_color(v)
|
||||
|
||||
# Auto-derive missing colors
|
||||
if "hair_light" not in config.get("colors", {}):
|
||||
colors["hair_light"] = lighten(colors["hair"], 30)
|
||||
if "mouth" not in config.get("colors", {}):
|
||||
colors["mouth"] = darken(colors["skin"], 80)
|
||||
|
||||
# Merge features with defaults
|
||||
features = dict(DEFAULT_FEATURES)
|
||||
features.update(config.get("features", {}))
|
||||
|
||||
sheet = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
gen_all_animations(sheet, colors, features)
|
||||
|
||||
import os
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
||||
sheet.save(args.output, "WEBP", quality=95, lossless=True)
|
||||
print(f"Saved spritesheet to {args.output} ({sheet.size[0]}x{sheet.size[1]})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,50 +0,0 @@
|
||||
# Dependencies (rebuilt inside container)
|
||||
node_modules/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
*.tgz
|
||||
|
||||
# Electron app output (not needed for server)
|
||||
apps/electron/release/
|
||||
apps/electron/vendor/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
|
||||
# Tests
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
**/tests/
|
||||
**/test/
|
||||
**/__tests__/
|
||||
|
||||
# Top-level docs (not resource docs used by server)
|
||||
/docs/
|
||||
apps/online-docs/
|
||||
README*.md
|
||||
CONTRIBUTING*.md
|
||||
CHANGELOG*.md
|
||||
LICENSE*
|
||||
|
||||
# Docker files (avoid recursive context)
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Smoke test script (not needed inside image)
|
||||
scripts/docker-smoke-test.sh
|
||||
@@ -1,123 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior in OpenWork
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenWork Version
|
||||
description: "Found in Settings or the title bar (e.g. 0.4.6)"
|
||||
placeholder: "0.4.6"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows 11
|
||||
- Windows 10
|
||||
- Linux (Ubuntu/Debian)
|
||||
- Linux (Fedora/RHEL)
|
||||
- Linux (Arch)
|
||||
- Linux (Other)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os_version
|
||||
attributes:
|
||||
label: OS Version
|
||||
description: "e.g. macOS 15.3, Windows 11 24H2, Ubuntu 24.04"
|
||||
placeholder: "macOS 15.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: ai_provider
|
||||
attributes:
|
||||
label: AI Provider
|
||||
description: Which AI provider/connection are you using?
|
||||
options:
|
||||
- Anthropic API (direct)
|
||||
- Anthropic API (custom endpoint)
|
||||
- OpenAI / Codex
|
||||
- Copilot (GitHub)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: "Which model are you using? (e.g. Claude Opus 4.7, GPT-4.1)"
|
||||
placeholder: "Claude Opus 4.7"
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Step-by-step instructions to reproduce the behavior.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / Screen Recordings
|
||||
description: |
|
||||
For **UI issues**, please attach screenshots or screen recordings showing the problem.
|
||||
You can drag and drop images/videos directly into this field.
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Debug Logs
|
||||
description: |
|
||||
For **non-UI issues** (crashes, errors, connection problems), please attach relevant logs from a debug session.
|
||||
|
||||
Launch the app with `-- --debug` and reproduce the issue.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem (MCP sources used, workspace config, etc.)
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Have an idea for OpenWork? We'd love to hear it!
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or Motivation
|
||||
description: What problem does this feature solve, or what workflow does it improve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the feature or change you'd like to see.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Any workarounds or alternative approaches you've tried.
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Screenshots, mockups, links, or any other context.
|
||||
@@ -1,592 +0,0 @@
|
||||
name: Desktop Release
|
||||
|
||||
run-name: Desktop release ${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Desktop app version to release, for example 0.0.2 or v0.0.2"
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
description: "Release title. Defaults to the tag."
|
||||
required: false
|
||||
type: string
|
||||
qwen_code_source:
|
||||
description: "Qwen Code runtime source to vendor into the desktop app."
|
||||
required: true
|
||||
default: pinned_package_version
|
||||
type: choice
|
||||
options:
|
||||
- npm_latest
|
||||
- source_branch
|
||||
- pinned_package_version
|
||||
qwen_code_ref:
|
||||
description: "QwenLM/qwen-code branch, tag, or commit when qwen_code_source is source_branch."
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
qwen_code_version:
|
||||
description: "Optional exact @qwen-code/qwen-code npm version for pinned_package_version. Defaults to package.json qwenCodeRuntime.version."
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Build installers only. Do not create or update a GitHub Release."
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
draft:
|
||||
description: "Create a draft release."
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
prerelease:
|
||||
description: "Mark the release as a prerelease."
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
clobber:
|
||||
description: "Replace same-named assets when uploading to an existing release."
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BUN_VERSION: 1.3.9
|
||||
CRAFT_BRAND: openwork
|
||||
|
||||
jobs:
|
||||
release_metadata:
|
||||
name: Prepare Release Source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
release_branch: ${{ steps.release-branch.outputs.branch }}
|
||||
release_ref: ${{ steps.release-branch.outputs.ref }}
|
||||
tag: ${{ steps.release-version.outputs.tag }}
|
||||
version: ${{ steps.release-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Configure Git user
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Require main for publishing
|
||||
if: ${{ inputs.dry_run == false }}
|
||||
env:
|
||||
SOURCE_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$SOURCE_REF" != "main" ]; then
|
||||
echo "::error::Desktop releases with dry_run=false must be run from main. Current ref: $SOURCE_REF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Bump desktop version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: bun run bump-desktop-version "$INPUT_VERSION"
|
||||
|
||||
- name: Validate release version
|
||||
id: release-version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: bun run check-release-version --version "$INPUT_VERSION"
|
||||
|
||||
- name: Create release branch
|
||||
id: release-branch
|
||||
env:
|
||||
IS_DRY_RUN: ${{ inputs.dry_run }}
|
||||
RELEASE_TAG: ${{ steps.release-version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="release/desktop-${RELEASE_TAG}"
|
||||
git switch -C "$branch"
|
||||
git add package.json apps/electron/package.json packages/shared/package.json
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No desktop version changes to commit."
|
||||
else
|
||||
git commit -m "chore(release): desktop ${RELEASE_TAG}"
|
||||
fi
|
||||
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$IS_DRY_RUN" = "false" ]; then
|
||||
remote_sha="$(git ls-remote --heads origin "$branch" | awk '{print $1}')"
|
||||
if [ -n "$remote_sha" ]; then
|
||||
git push --force-with-lease="refs/heads/$branch:$remote_sha" origin "HEAD:refs/heads/$branch"
|
||||
else
|
||||
git push origin "HEAD:refs/heads/$branch"
|
||||
fi
|
||||
echo "ref=$branch" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Dry run enabled. Skipping release branch push."
|
||||
echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 90
|
||||
needs: release_metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release_metadata.outputs.tag }}
|
||||
RELEASE_VERSION: ${{ needs.release_metadata.outputs.version }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS
|
||||
os: macos-latest
|
||||
command: bun run dist:mac:no-publish
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
command: bun run dist:win:no-publish
|
||||
- name: Linux
|
||||
os: ubuntu-22.04
|
||||
command: bun run dist:linux:no-publish
|
||||
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.release_metadata.outputs.release_ref }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Check out Qwen Code source
|
||||
if: ${{ inputs.qwen_code_source == 'source_branch' }}
|
||||
shell: bash
|
||||
env:
|
||||
QWEN_CODE_REF_INPUT: ${{ inputs.qwen_code_ref }}
|
||||
QWEN_CODE_SOURCE_ROOT: ${{ runner.temp }}/qwen-code-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$QWEN_CODE_REF_INPUT" ]; then
|
||||
echo "::error::qwen_code_ref is required when qwen_code_source is source_branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$QWEN_CODE_SOURCE_ROOT"
|
||||
git init "$QWEN_CODE_SOURCE_ROOT"
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" remote add origin https://github.com/QwenLM/qwen-code.git
|
||||
|
||||
if ! git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "$QWEN_CODE_REF_INPUT"; then
|
||||
if ! git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "refs/heads/$QWEN_CODE_REF_INPUT"; then
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "refs/tags/$QWEN_CODE_REF_INPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" checkout --detach FETCH_HEAD
|
||||
git config --global --add safe.directory "$QWEN_CODE_SOURCE_ROOT"
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install Linux packaging dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Qwen Code source dependencies
|
||||
if: ${{ inputs.qwen_code_source == 'source_branch' }}
|
||||
working-directory: ${{ runner.temp }}/qwen-code-source
|
||||
run: npm ci
|
||||
|
||||
- name: Bump desktop version
|
||||
run: bun run bump-desktop-version "${{ needs.release_metadata.outputs.version }}"
|
||||
|
||||
- name: Confirm release version
|
||||
run: bun run check-release-version --version "${{ needs.release_metadata.outputs.version }}"
|
||||
|
||||
- name: Configure Qwen Code runtime source
|
||||
shell: bash
|
||||
env:
|
||||
QWEN_CODE_REF_INPUT: ${{ inputs.qwen_code_ref }}
|
||||
QWEN_CODE_SOURCE_INPUT: ${{ inputs.qwen_code_source }}
|
||||
QWEN_CODE_SOURCE_ROOT: ${{ runner.temp }}/qwen-code-source
|
||||
QWEN_CODE_VERSION_INPUT: ${{ inputs.qwen_code_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$QWEN_CODE_SOURCE_INPUT" in
|
||||
npm_latest)
|
||||
echo "QWEN_CODE_VERSION=latest" >> "$GITHUB_ENV"
|
||||
echo "Using Qwen Code runtime from npm dist-tag: latest"
|
||||
;;
|
||||
source_branch)
|
||||
if [ -z "$QWEN_CODE_REF_INPUT" ]; then
|
||||
echo "::error::qwen_code_ref is required when qwen_code_source is source_branch."
|
||||
exit 1
|
||||
fi
|
||||
echo "QWEN_CODE_ROOT=$QWEN_CODE_SOURCE_ROOT" >> "$GITHUB_ENV"
|
||||
echo "Using Qwen Code runtime from QwenLM/qwen-code ref: $QWEN_CODE_REF_INPUT"
|
||||
;;
|
||||
pinned_package_version)
|
||||
if [ -n "$QWEN_CODE_VERSION_INPUT" ]; then
|
||||
echo "QWEN_CODE_VERSION=$QWEN_CODE_VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "Using exact Qwen Code npm version: $QWEN_CODE_VERSION_INPUT"
|
||||
else
|
||||
echo "Using Qwen Code runtime from package.json qwenCodeRuntime.version"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown qwen_code_source: $QWEN_CODE_SOURCE_INPUT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Configure optional signing secrets
|
||||
shell: bash
|
||||
env:
|
||||
IS_DRY_RUN: ${{ inputs.dry_run }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD_SECRET: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_ID_SECRET: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID_SECRET: ${{ secrets.APPLE_TEAM_ID }}
|
||||
IS_DRAFT: ${{ inputs.draft }}
|
||||
MAC_CSC_KEY_PASSWORD_SECRET: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
MAC_CSC_LINK_SECRET: ${{ secrets.MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD_SECRET: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK_SECRET: ${{ secrets.CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD_SECRET: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
WIN_CSC_LINK_SECRET: ${{ secrets.WIN_CSC_LINK }}
|
||||
SENTRY_ELECTRON_INGEST_URL_SECRET: ${{ secrets.SENTRY_ELECTRON_INGEST_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
append_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
{
|
||||
echo "$name<<__${name}__"
|
||||
printf '%s\n' "$value"
|
||||
echo "__${name}__"
|
||||
} >> "$GITHUB_ENV"
|
||||
}
|
||||
|
||||
mac_csc_link="${MAC_CSC_LINK_SECRET:-$CSC_LINK_SECRET}"
|
||||
mac_csc_key_password="${MAC_CSC_KEY_PASSWORD_SECRET:-$CSC_KEY_PASSWORD_SECRET}"
|
||||
|
||||
allow_unsigned_artifacts() {
|
||||
if [ "$IS_DRY_RUN" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$IS_DRAFT" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$RUNNER_OS" = "macOS" ]; then
|
||||
if [ -n "$mac_csc_link" ]; then
|
||||
if [ -z "$mac_csc_key_password" ]; then
|
||||
echo "::error::MAC_CSC_LINK/CSC_LINK is configured, but MAC_CSC_KEY_PASSWORD/CSC_KEY_PASSWORD is missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
append_env "CSC_LINK" "$mac_csc_link"
|
||||
append_env "CSC_KEY_PASSWORD" "$mac_csc_key_password"
|
||||
append_env "APPLE_ID" "$APPLE_ID_SECRET"
|
||||
append_env "APPLE_APP_SPECIFIC_PASSWORD" "$APPLE_APP_SPECIFIC_PASSWORD_SECRET"
|
||||
append_env "APPLE_TEAM_ID" "$APPLE_TEAM_ID_SECRET"
|
||||
echo "CSC_IDENTITY_AUTO_DISCOVERY=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
if ! allow_unsigned_artifacts; then
|
||||
echo "::error::Published macOS desktop releases require MAC_CSC_LINK/CSC_LINK and MAC_CSC_KEY_PASSWORD/CSC_KEY_PASSWORD so auto-update signature validation can pass."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$IS_DRY_RUN" = "false" ]; then
|
||||
echo "::warning::Publishing an unsigned macOS draft release for maintainer testing. Auto-update validation is not supported for this artifact."
|
||||
fi
|
||||
|
||||
echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
elif [ "$RUNNER_OS" = "Windows" ]; then
|
||||
if [ -n "$WIN_CSC_LINK_SECRET" ]; then
|
||||
if [ -z "$WIN_CSC_KEY_PASSWORD_SECRET" ]; then
|
||||
echo "::error::WIN_CSC_LINK is configured, but WIN_CSC_KEY_PASSWORD is missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
append_env "WIN_CSC_LINK" "$WIN_CSC_LINK_SECRET"
|
||||
append_env "WIN_CSC_KEY_PASSWORD" "$WIN_CSC_KEY_PASSWORD_SECRET"
|
||||
else
|
||||
if ! allow_unsigned_artifacts; then
|
||||
echo "::error::Published Windows desktop releases require WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$IS_DRY_RUN" = "false" ]; then
|
||||
echo "::warning::Publishing an unsigned Windows draft release for maintainer testing. Windows may show Unknown Publisher / SmartScreen warnings."
|
||||
else
|
||||
echo "Windows signing certificate is not configured; Windows dry-run artifacts will be unsigned."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ "$RUNNER_OS" != "Linux" ] && [ -n "$CSC_LINK_SECRET" ]; then
|
||||
echo "::warning::CSC_LINK is configured but not used on $RUNNER_OS."
|
||||
fi
|
||||
fi
|
||||
|
||||
append_env "SENTRY_ELECTRON_INGEST_URL" "$SENTRY_ELECTRON_INGEST_URL_SECRET"
|
||||
|
||||
- name: Build desktop installer
|
||||
# Build jobs only produce artifacts. The publish job below owns GitHub
|
||||
# Release creation/upload so dry-run, draft, prerelease, and replace
|
||||
# behavior stays centralized.
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Upload installer artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.name }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
path: |
|
||||
apps/electron/release/*.AppImage
|
||||
apps/electron/release/*.blockmap
|
||||
apps/electron/release/*.dmg
|
||||
apps/electron/release/*.exe
|
||||
apps/electron/release/*.yml
|
||||
apps/electron/release/*.zip
|
||||
|
||||
publish:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs:
|
||||
- build
|
||||
- release_metadata
|
||||
if: ${{ inputs.dry_run == false }}
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release_metadata.outputs.tag }}
|
||||
RELEASE_VERSION: ${{ needs.release_metadata.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Download installer artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish release assets
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_DRAFT: ${{ inputs.draft }}
|
||||
RELEASE_NAME: ${{ inputs.release_name }}
|
||||
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
|
||||
RELEASE_TARGET: ${{ needs.release_metadata.outputs.release_ref }}
|
||||
UPLOAD_CLOBBER: ${{ inputs.clobber }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
assets=()
|
||||
while IFS= read -r -d '' file; do
|
||||
assets+=("$file")
|
||||
done < <(find release-assets -type f -print0 | sort -z)
|
||||
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets were downloaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Release assets:\n'
|
||||
printf ' %s\n' "${assets[@]}"
|
||||
|
||||
title="${RELEASE_NAME:-$RELEASE_TAG}"
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
upload_args=("$RELEASE_TAG" "${assets[@]}")
|
||||
if [ "$UPLOAD_CLOBBER" = "true" ]; then
|
||||
upload_args+=(--clobber)
|
||||
fi
|
||||
gh release upload "${upload_args[@]}"
|
||||
else
|
||||
previous_tag="$(
|
||||
gh release list \
|
||||
--repo "$GH_REPO" \
|
||||
--limit 100 \
|
||||
--json tagName,isDraft,isPrerelease \
|
||||
--jq '.[] | select(.isDraft == false and .isPrerelease == false) | .tagName' \
|
||||
| grep -vxF "$RELEASE_TAG" \
|
||||
| head -n 1 \
|
||||
|| true
|
||||
)"
|
||||
|
||||
create_args=(
|
||||
"$RELEASE_TAG"
|
||||
"${assets[@]}"
|
||||
--generate-notes
|
||||
--target "$RELEASE_TARGET"
|
||||
--title "$title"
|
||||
)
|
||||
if [ -n "$previous_tag" ]; then
|
||||
echo "Using $previous_tag as the release notes start tag."
|
||||
create_args+=(--notes-start-tag "$previous_tag")
|
||||
else
|
||||
echo "No previous published stable release found for release notes."
|
||||
fi
|
||||
if [ "$RELEASE_DRAFT" = "true" ]; then
|
||||
create_args+=(--draft)
|
||||
fi
|
||||
if [ "$RELEASE_PRERELEASE" = "true" ]; then
|
||||
create_args+=(--prerelease)
|
||||
fi
|
||||
gh release create "${create_args[@]}"
|
||||
fi
|
||||
|
||||
sync-version:
|
||||
name: Sync Release Version to Main
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- publish
|
||||
- release_metadata
|
||||
if: ${{ inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Create version sync PR
|
||||
id: version-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_BOT_PAT || github.token }}
|
||||
RELEASE_BRANCH: ${{ needs.release_metadata.outputs.release_branch }}
|
||||
RELEASE_TAG: ${{ needs.release_metadata.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pr_url="$(gh pr list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--head "$RELEASE_BRANCH" \
|
||||
--base main \
|
||||
--json url \
|
||||
--jq '.[0].url')"
|
||||
|
||||
if [ -z "$pr_url" ]; then
|
||||
pr_url="$(gh pr create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--base main \
|
||||
--head "$RELEASE_BRANCH" \
|
||||
--title "chore(release): desktop ${RELEASE_TAG}" \
|
||||
--body "Automated desktop release PR for ${RELEASE_TAG}. Syncs desktop package versions on main.")"
|
||||
fi
|
||||
|
||||
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_BOT_PAT || github.token }}
|
||||
PR_URL: ${{ steps.version-pr.outputs.url }}
|
||||
RELEASE_TAG: ${{ needs.release_metadata.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh pr merge "$PR_URL" \
|
||||
--squash \
|
||||
--auto \
|
||||
--delete-branch \
|
||||
--subject "chore(release): desktop ${RELEASE_TAG} [skip ci]"
|
||||
|
||||
dry-run-summary:
|
||||
name: Dry Run Summary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- build
|
||||
- release_metadata
|
||||
if: ${{ inputs.dry_run }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release_metadata.outputs.tag }}
|
||||
RELEASE_VERSION: ${{ needs.release_metadata.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Download installer artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: List release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
assets=()
|
||||
while IFS= read -r -d '' file; do
|
||||
assets+=("$file")
|
||||
done < <(find release-assets -type f -print0 | sort -z)
|
||||
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets were downloaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Desktop release dry run"
|
||||
echo
|
||||
echo "Version: $RELEASE_VERSION"
|
||||
echo "Release tag: $RELEASE_TAG"
|
||||
echo
|
||||
echo "Built ${#assets[@]} asset(s). No GitHub Release was created or updated."
|
||||
echo
|
||||
echo "| Asset | Size |"
|
||||
echo "| --- | ---: |"
|
||||
for file in "${assets[@]}"; do
|
||||
size=$(du -h "$file" | cut -f1)
|
||||
echo "| $(basename "$file") | $size |"
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,75 +0,0 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
.build
|
||||
apps/electron/release
|
||||
apps/electron/electron-builder.generated.yml
|
||||
apps/electron/resources/session-mcp-server/
|
||||
apps/electron/resources/pi-agent-server/
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
.wrangler
|
||||
.mintlify
|
||||
|
||||
# OSS sync temp directory
|
||||
.oss-sync-temp/
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Claude Code local settings (user-specific)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Accidental literal tilde directories (Node.js doesn't expand ~)
|
||||
~/
|
||||
|
||||
# Lock files from other package managers
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Craft Agent local data (sessions, credentials, config)
|
||||
.craft-agent/
|
||||
|
||||
# SSH keys and secrets
|
||||
*.pem
|
||||
*.key
|
||||
sshkey
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
|
||||
# Credentials files
|
||||
credentials.enc
|
||||
*.credentials
|
||||
|
||||
# act (local GitHub Actions runner)
|
||||
.secrets
|
||||
.actrc
|
||||
@@ -1 +0,0 @@
|
||||
22
|
||||
@@ -1,28 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We are committed to providing a welcoming and inclusive environment for everyone who participates in OpenWork.
|
||||
|
||||
## Our Standards
|
||||
|
||||
We expect all participants to:
|
||||
|
||||
- Be respectful and considerate
|
||||
- Welcome newcomers and help them learn
|
||||
- Accept constructive feedback gracefully
|
||||
- Focus on what is best for the community
|
||||
|
||||
## Enforcement
|
||||
|
||||
OpenWork does not currently maintain a dedicated private conduct-reporting inbox.
|
||||
|
||||
If you need to report unacceptable behavior, please contact a repository maintainer through the current GitHub repository channels. If the report includes sensitive personal information, avoid posting those details publicly and ask a maintainer for a private contact path first.
|
||||
|
||||
Reports will be reviewed in good faith by the maintainers who are currently responsible for the project.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
|
||||
For the full text, see: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
|
||||
@@ -1,103 +0,0 @@
|
||||
# Contributing to OpenWork
|
||||
|
||||
Thank you for your interest in contributing to OpenWork. This guide covers the
|
||||
local development workflow for the desktop app and shared packages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) 1.3 or newer
|
||||
- Node.js 22 or newer for the Qwen Code runtime and related tooling
|
||||
- macOS, Linux, or Windows
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/modelstudioai/openwork.git
|
||||
cd openwork
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Start the Electron app in development mode:
|
||||
|
||||
```bash
|
||||
CRAFT_BRAND=modelstudio bun run electron:dev
|
||||
```
|
||||
|
||||
To run against a local Qwen Code checkout instead of a vendored or npm
|
||||
runtime, pass the source root:
|
||||
|
||||
```bash
|
||||
CRAFT_BRAND=modelstudio \
|
||||
QWEN_CODE_ROOT=/path/to/qwen-code \
|
||||
bun run electron:dev
|
||||
```
|
||||
|
||||
## Useful Commands
|
||||
|
||||
Run focused checks whenever possible:
|
||||
|
||||
```bash
|
||||
bun run typecheck:shared
|
||||
bun run typecheck:electron
|
||||
bun run typecheck:all
|
||||
```
|
||||
|
||||
Build the desktop app resources:
|
||||
|
||||
```bash
|
||||
bun run electron:build
|
||||
```
|
||||
|
||||
Package a dev build:
|
||||
|
||||
```bash
|
||||
CRAFT_DEV_RUNTIME=1 bun run electron:dist:dev:mac
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
openwork/
|
||||
├── apps/
|
||||
│ ├── electron/ # Electron desktop app
|
||||
│ ├── cli/ # Command-line entry points
|
||||
│ ├── viewer/ # Shared session viewer
|
||||
│ └── webui/ # Web UI build
|
||||
├── packages/
|
||||
│ ├── shared/ # Shared app logic and protocol types
|
||||
│ ├── server-core/ # Server/session orchestration
|
||||
│ ├── server/ # Server entry point
|
||||
│ ├── ui/ # React UI components
|
||||
│ └── core/ # Shared lower-level utilities
|
||||
└── scripts/ # Build, dev, and packaging scripts
|
||||
```
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
- Keep changes focused and minimal.
|
||||
- Follow existing TypeScript and React patterns.
|
||||
- Prefer existing shared helpers over introducing new abstractions.
|
||||
- Include screenshots or a short screen recording for visible UI changes.
|
||||
- Mention the commands you ran in the PR description.
|
||||
- Do not include generated build artifacts unless the project explicitly tracks
|
||||
them.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Create a branch from the target branch.
|
||||
2. Make the smallest change that solves the problem.
|
||||
3. Run the relevant focused checks.
|
||||
4. Open a pull request with a clear summary, testing notes, and screenshots for
|
||||
UI changes.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions are licensed under the same
|
||||
license as this repository.
|
||||
@@ -1,191 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on
|
||||
behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written
|
||||
communication sent to the Licensor or its representatives, including
|
||||
but not limited to communication on electronic mailing lists, source
|
||||
code control systems, and issue tracking systems that are managed by,
|
||||
or on behalf of, the Licensor for the purpose of discussing and
|
||||
improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by the copyright owner as
|
||||
"Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Craft Docs Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,23 +0,0 @@
|
||||
Craft Agents
|
||||
Copyright 2026 Craft Docs Ltd.
|
||||
|
||||
This product includes software developed by Craft Docs Ltd.
|
||||
https://craft.do
|
||||
|
||||
---
|
||||
|
||||
This project uses the Claude Agent SDK, which is subject to Anthropic's
|
||||
Commercial Terms of Service: https://www.anthropic.com/legal/commercial-terms
|
||||
|
||||
---
|
||||
|
||||
OpenWork
|
||||
Copyright 2026 Model Studio AI
|
||||
|
||||
OpenWork includes modifications and extensions developed by Model Studio AI.
|
||||
https://github.com/modelstudioai/openwork
|
||||
|
||||
---
|
||||
|
||||
Third-party dependencies are listed in package.json files and are subject
|
||||
to their respective licenses.
|
||||
@@ -1,189 +0,0 @@
|
||||
# Qwen Code
|
||||
|
||||
Qwen Code is a desktop and headless agent workspace. It provides multi-session chat, source connections, skills, file previews, automations, and permission modes in a local-first application.
|
||||
|
||||
## Backend
|
||||
|
||||
This fork is Qwen-only:
|
||||
|
||||
- Agent sessions run through Qwen Code over ACP.
|
||||
- The app does not store third-party LLM API keys.
|
||||
- The built-in LLM connection is `qwen-code`.
|
||||
- Legacy multi-provider backends and package/runtime wiring have been removed.
|
||||
|
||||
## Qwen Code CLI Runtime
|
||||
|
||||
The desktop app talks to the Qwen Code CLI over ACP. Treat the CLI as a
|
||||
runtime artifact, not as desktop source code. A packaged app must bundle a
|
||||
known CLI build so users can launch it without installing `qwen` separately.
|
||||
|
||||
Use one of these workflows depending on what you are developing:
|
||||
|
||||
| Workflow | Use it when | Commands |
|
||||
| --------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Default desktop development | You are developing desktop only. | `bun run dev` |
|
||||
| Published npm package | You want a specific published CLI version for dev, CI, or release builds. | `QWEN_CODE_VERSION=0.15.12-acp.0 bun run dev` |
|
||||
| Local npm tarball | You need to verify the exact package contents before publishing. | `QWEN_CODE_TARBALL=/path/to/qwen-code-0.15.12-acp.0.tgz bun run dev` |
|
||||
| Local qwen-code checkout | You are changing ACP or other CLI behavior while testing desktop. | `QWEN_CODE_ROOT=/path/to/qwen-code bun run dev` |
|
||||
| Explicit CLI entry | You need to point at a specific CLI file. | `QWEN_CODE_CLI=/path/to/qwen-code/scripts/dev.js bun run dev` |
|
||||
|
||||
`electron:dev` uses local overrides first. If no override is set and this
|
||||
repository is not inside the qwen-code monorepo, it vendors the default version
|
||||
from `qwenCodeRuntime.version` in `package.json` and points Electron at the
|
||||
vendored CLI automatically.
|
||||
|
||||
If you are preparing a package without publishing it, create the tarball from
|
||||
the Qwen Code repository and point desktop at it:
|
||||
|
||||
```bash
|
||||
cd /path/to/qwen-code
|
||||
npm run build
|
||||
npm run bundle
|
||||
npm run prepare:package
|
||||
npm pack
|
||||
|
||||
cd /path/to/desktop
|
||||
QWEN_CODE_TARBALL=/path/to/qwen-code/qwen-code-0.15.12-acp.0.tgz bun run dist:mac
|
||||
```
|
||||
|
||||
Distribution builds run `electron:vendor:qwen` automatically. Set
|
||||
`QWEN_CODE_VERSION` or `QWEN_CODE_TARBALL` when you want the packaged app to use
|
||||
a published or packed CLI artifact. If neither is set, this monorepo builds
|
||||
from the local checkout; a standalone desktop checkout uses
|
||||
`qwenCodeRuntime.version` from `package.json`.
|
||||
|
||||
Development runtime resolution checks sources in this order:
|
||||
|
||||
```text
|
||||
QWEN_CODE_CLI / QWEN_CODE_ROOT / QWEN_CODE_PATH
|
||||
QWEN_CODE_TARBALL
|
||||
QWEN_CODE_VERSION
|
||||
local monorepo checkout
|
||||
existing vendored CLI
|
||||
qwenCodeRuntime.version from package.json
|
||||
```
|
||||
|
||||
Distribution vendoring checks sources in this order:
|
||||
|
||||
```text
|
||||
QWEN_CODE_TARBALL
|
||||
QWEN_CODE_VERSION
|
||||
QWEN_CODE_ROOT / QWEN_CODE_PATH
|
||||
local monorepo checkout
|
||||
qwenCodeRuntime.version from package.json
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
bun run typecheck:all
|
||||
bun run test:shared
|
||||
bun run dev
|
||||
bun run server:start
|
||||
```
|
||||
|
||||
## Building for Distribution
|
||||
|
||||
All build commands run from `packages/desktop/`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) (see `.bun-version` for exact version)
|
||||
- `bun install` — install all workspace dependencies
|
||||
|
||||
### Developer Build (no code signing)
|
||||
|
||||
Use this for local testing. Produces an ad-hoc signed app.
|
||||
|
||||
```bash
|
||||
# macOS (arm64 + x64)
|
||||
bun run electron:dist:dev:mac
|
||||
|
||||
# Windows
|
||||
bun run electron:dist:dev:win
|
||||
|
||||
# Linux
|
||||
bun run electron:dist:dev:linux
|
||||
```
|
||||
|
||||
### Release Build (with code signing)
|
||||
|
||||
```bash
|
||||
bun run electron:dist:mac
|
||||
bun run electron:dist:win
|
||||
bun run electron:dist:linux
|
||||
```
|
||||
|
||||
Release builds require signing credentials via environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
| ----------------------------- | --------------------------- |
|
||||
| `CSC_LINK` | Path to signing certificate |
|
||||
| `APPLE_ID` | Apple ID for notarization |
|
||||
| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password |
|
||||
| `APPLE_TEAM_ID` | Team ID for notarization |
|
||||
|
||||
### Build Output
|
||||
|
||||
All artifacts are written to `apps/electron/release/`:
|
||||
|
||||
| Platform | Artifact |
|
||||
| -------- | ------------------------------------------------------------------------ |
|
||||
| macOS | `Qwen-Code-Desktop-{arm64,x64}.dmg`, `Qwen-Code-Desktop-{arm64,x64}.zip` |
|
||||
| Windows | `Qwen-Code-Desktop-x64.exe` |
|
||||
| Linux | `Qwen-Code-Desktop-x64.AppImage` |
|
||||
|
||||
### What the Build Does
|
||||
|
||||
Each `electron:dist:*` command runs three stages:
|
||||
|
||||
1. **`electron:vendor:qwen`** — vendors a Qwen Code CLI runtime into `vendor/qwen-code/`. Set `QWEN_CODE_VERSION` to download a published npm version, or `QWEN_CODE_TARBALL` to use a local `npm pack` tarball. If neither is set in this monorepo, it builds from the local checkout.
|
||||
2. **`electron:build`** — compiles the app via esbuild (main + preload), Vite (renderer), and copies resources/assets.
|
||||
3. **`electron-builder`** — downloads the Electron runtime, packages the app, signs it, and produces distributable installers (DMG, NSIS, AppImage).
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
bun run apps/cli/src/index.ts run "Hello from Qwen"
|
||||
bun run apps/cli/src/index.ts run --workspace-dir ./project "Summarize this repo"
|
||||
```
|
||||
|
||||
The `run` command spawns a headless server, creates a temporary session, streams the response, and exits. Provider flags are accepted only for compatibility; the backend remains Qwen Code.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```text
|
||||
apps/
|
||||
electron/ Desktop app
|
||||
cli/ Terminal client
|
||||
webui/ Web adapter
|
||||
packages/
|
||||
shared/ Agent, config, prompts, sessions, sources
|
||||
server-core/ RPC handlers and session manager
|
||||
core/ Shared types
|
||||
ui/ Shared UI components
|
||||
session-tools-core/
|
||||
session-mcp-server/
|
||||
scripts/ Build and packaging helpers
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Multi-session inbox with streaming responses and tool visualization
|
||||
- Qwen Code model discovery through ACP
|
||||
- MCP, REST API, and local filesystem sources
|
||||
- Skills stored per workspace
|
||||
- Permission modes for planning, asking before edits, and autonomous execution
|
||||
- File attachments and in-app previews for images, PDFs, Office files, and diffs
|
||||
- Event-driven automations and messaging integrations
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0. Third-party dependencies are listed in package manifests and are subject to their respective licenses.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security seriously. If you discover a security vulnerability in OpenWork, please report it responsibly.
|
||||
|
||||
### How to Report
|
||||
|
||||
Please do not publish exploit details in public GitHub issues.
|
||||
|
||||
OpenWork does not currently maintain a dedicated security email address. If GitHub private vulnerability reporting is available for this repository, use that channel. Otherwise, open a minimal public issue that requests maintainer contact without including exploit details, secrets, or proof-of-concept code.
|
||||
|
||||
Include the following information:
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
- Potential impact
|
||||
- Any suggested fixes (optional)
|
||||
|
||||
### What to Expect
|
||||
|
||||
Maintainers will review reports on a best-effort basis. Response and resolution timelines depend on maintainer availability and the severity of the issue.
|
||||
|
||||
### Scope
|
||||
|
||||
This policy applies to:
|
||||
|
||||
- The OpenWork desktop application
|
||||
- OpenWork server and shared packages
|
||||
- Official OpenWork repositories
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Third-party dependencies (report to their maintainers)
|
||||
- Social engineering attacks
|
||||
- Denial of service attacks
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| -------- | ------------------ |
|
||||
| Latest | :white_check_mark: |
|
||||
| < Latest | :x: |
|
||||
|
||||
We currently provide security updates for the latest version only. Please keep your installation up to date.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
When using OpenWork:
|
||||
|
||||
1. **Keep credentials secure**: Never commit `.env` files or credentials
|
||||
2. **Use environment variables**: Store secrets in environment variables
|
||||
3. **Review permissions**: Be cautious with "Execute" permission mode
|
||||
4. **Update regularly**: Keep the application updated
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
We appreciate responsible disclosure and will acknowledge security researchers who report valid vulnerabilities (with their permission).
|
||||
@@ -1,103 +0,0 @@
|
||||
# Trademark Policy
|
||||
|
||||
This trademark policy describes how the OpenWork name, Model Studio AI branding, and related project identifiers may be used.
|
||||
|
||||
## Trademarks
|
||||
|
||||
The following names and branding are associated with this project:
|
||||
|
||||
- **OpenWork**
|
||||
- **Model Studio AI**
|
||||
- OpenWork logos, icons, and visual branding
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### Use the Code Freely
|
||||
|
||||
The OpenWork source code is licensed under the Apache License 2.0. You are free to:
|
||||
|
||||
- Use, modify, and distribute the code
|
||||
- Create derivative works
|
||||
- Use the software for any purpose, including commercial use
|
||||
|
||||
The license for the code does not grant permission to use OpenWork or Model Studio AI branding in a way that suggests endorsement or official status.
|
||||
|
||||
### Make Factual Statements
|
||||
|
||||
You may make accurate, factual statements about your relationship to the project:
|
||||
|
||||
- "Based on OpenWork"
|
||||
- "Built with OpenWork technology"
|
||||
- "Compatible with OpenWork"
|
||||
- "Fork of OpenWork"
|
||||
|
||||
You may also make accurate attribution statements about upstream projects, such as Craft Agents OSS and Qwen Code, when describing the technical foundations of OpenWork.
|
||||
|
||||
### Contribute to the Project
|
||||
|
||||
Contributors may use the project name when discussing their contributions to OpenWork.
|
||||
|
||||
## What You Cannot Do
|
||||
|
||||
### Use OpenWork or Model Studio AI Branding for Forks
|
||||
|
||||
If you create a fork or derivative work, you **must**:
|
||||
|
||||
- Choose a different name that does not imply it is the official OpenWork project
|
||||
- Remove or replace OpenWork and Model Studio AI logos and icons
|
||||
- Update bundle identifiers, package metadata, and product names to your own
|
||||
- Avoid using Model Studio AI branding unless you have explicit permission
|
||||
|
||||
### Imply Official Endorsement
|
||||
|
||||
You may not:
|
||||
|
||||
- Use "OpenWork" or "Model Studio AI" as your product name in a confusing way
|
||||
- Use OpenWork or Model Studio AI logos as your application icon
|
||||
- Suggest that your fork is the official version
|
||||
- Imply that Model Studio AI endorses your product
|
||||
|
||||
### Create Confusion
|
||||
|
||||
You may not use the trademarks in any way that:
|
||||
|
||||
- Suggests your product is created by or affiliated with Model Studio AI
|
||||
- Could cause confusion between your product and the official OpenWork project
|
||||
- Misrepresents the relationship between your product, OpenWork, Model Studio AI, Craft Agents OSS, or Qwen Code
|
||||
|
||||
## Branding Locations
|
||||
|
||||
For those creating forks, the following files contain branding that should be updated:
|
||||
|
||||
| File | Contains |
|
||||
| ------------------------------------ | ---------------------------------- |
|
||||
| `apps/electron/electron-builder.yml` | Product name, bundle ID, copyright |
|
||||
| `apps/electron/resources/` | Application icons |
|
||||
| `packages/shared/src/branding.ts` | Product name, bundle ID, URLs |
|
||||
|
||||
## Examples
|
||||
|
||||
### Acceptable
|
||||
|
||||
- "MyAgent - based on OpenWork"
|
||||
- "This project is a fork of OpenWork"
|
||||
- "Compatible with OpenWork"
|
||||
|
||||
### Not Acceptable
|
||||
|
||||
- "OpenWork Pro"
|
||||
- "OpenWork for Linux"
|
||||
- "Better OpenWork"
|
||||
- Using OpenWork or Model Studio AI logos for your fork without permission
|
||||
|
||||
## Questions
|
||||
|
||||
OpenWork does not currently maintain a dedicated trademark or legal contact email. If you have questions about this policy or want to request permission for a specific use, please open an issue in the repository.
|
||||
|
||||
## Changes
|
||||
|
||||
This policy may be updated from time to time. The current version will always be available in this repository.
|
||||
|
||||
---
|
||||
|
||||
_This trademark policy is inspired by similar policies from Mozilla, WordPress, and the Apache Software Foundation._
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@craft-agent/cli",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Terminal client for Qwen Code server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"bin": {
|
||||
"craft-cli": "src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@craft-agent/shared": "workspace:*",
|
||||
"@craft-agent/server-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'bun:test'
|
||||
import { CliRpcClient } from './client.ts'
|
||||
import {
|
||||
serializeEnvelope,
|
||||
deserializeEnvelope,
|
||||
} from '@craft-agent/server-core/transport'
|
||||
import type { MessageEnvelope } from '@craft-agent/shared/protocol'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock WS server helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MockServer {
|
||||
url: string
|
||||
port: number
|
||||
close: () => void
|
||||
lastMessage: () => MessageEnvelope | null
|
||||
sendToAll: (envelope: MessageEnvelope) => void
|
||||
}
|
||||
|
||||
function createMockServer(opts?: {
|
||||
rejectAuth?: boolean
|
||||
noAck?: boolean
|
||||
tls?: { cert: string; key: string }
|
||||
}): MockServer {
|
||||
let lastMsg: MessageEnvelope | null = null
|
||||
const clients = new Set<any>()
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
tls: opts?.tls,
|
||||
fetch(req, server) {
|
||||
if (server.upgrade(req)) return undefined
|
||||
return new Response('Not found', { status: 404 })
|
||||
},
|
||||
websocket: {
|
||||
message(ws, message) {
|
||||
const raw = typeof message === 'string' ? message : new TextDecoder().decode(message)
|
||||
const envelope = deserializeEnvelope(raw)
|
||||
lastMsg = envelope
|
||||
|
||||
if (envelope.type === 'handshake') {
|
||||
if (opts?.rejectAuth) {
|
||||
const error: MessageEnvelope = {
|
||||
id: envelope.id,
|
||||
type: 'error',
|
||||
error: { code: 'AUTH_FAILED', message: 'Invalid token' },
|
||||
}
|
||||
ws.send(serializeEnvelope(error))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (opts?.noAck) return // Simulate timeout
|
||||
|
||||
const ack: MessageEnvelope = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'handshake_ack',
|
||||
clientId: 'test-client-001',
|
||||
protocolVersion: '1.0',
|
||||
}
|
||||
ws.send(serializeEnvelope(ack))
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'request') {
|
||||
// Default: echo args back as result
|
||||
const response: MessageEnvelope = {
|
||||
id: envelope.id,
|
||||
type: 'response',
|
||||
channel: envelope.channel,
|
||||
result: envelope.args,
|
||||
}
|
||||
ws.send(serializeEnvelope(response))
|
||||
}
|
||||
},
|
||||
open(ws) {
|
||||
clients.add(ws)
|
||||
},
|
||||
close(ws) {
|
||||
clients.delete(ws)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const protocol = opts?.tls ? 'wss' : 'ws'
|
||||
const port = server.port!
|
||||
return {
|
||||
url: `${protocol}://127.0.0.1:${port}`,
|
||||
port,
|
||||
close: () => server.stop(true),
|
||||
lastMessage: () => lastMsg,
|
||||
sendToAll: (envelope: MessageEnvelope) => {
|
||||
const data = serializeEnvelope(envelope)
|
||||
for (const ws of clients) ws.send(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createErrorServer(): MockServer {
|
||||
let lastMsg: MessageEnvelope | null = null
|
||||
const clients = new Set<any>()
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req, server) {
|
||||
if (server.upgrade(req)) return undefined
|
||||
return new Response('Not found', { status: 404 })
|
||||
},
|
||||
websocket: {
|
||||
message(ws, message) {
|
||||
const raw = typeof message === 'string' ? message : new TextDecoder().decode(message)
|
||||
const envelope = deserializeEnvelope(raw)
|
||||
lastMsg = envelope
|
||||
|
||||
if (envelope.type === 'handshake') {
|
||||
const ack: MessageEnvelope = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'handshake_ack',
|
||||
clientId: 'test-client-err',
|
||||
protocolVersion: '1.0',
|
||||
}
|
||||
ws.send(serializeEnvelope(ack))
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'request') {
|
||||
// Respond with error
|
||||
const response: MessageEnvelope = {
|
||||
id: envelope.id,
|
||||
type: 'response',
|
||||
channel: envelope.channel,
|
||||
error: { code: 'HANDLER_ERROR', message: 'test error' },
|
||||
}
|
||||
ws.send(serializeEnvelope(response))
|
||||
}
|
||||
},
|
||||
open(ws) {
|
||||
clients.add(ws)
|
||||
},
|
||||
close(ws) {
|
||||
clients.delete(ws)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const port = server.port!
|
||||
return {
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
port,
|
||||
close: () => server.stop(true),
|
||||
lastMessage: () => lastMsg,
|
||||
sendToAll: (envelope: MessageEnvelope) => {
|
||||
const data = serializeEnvelope(envelope)
|
||||
for (const ws of clients) ws.send(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let server: MockServer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
server?.close()
|
||||
server = null
|
||||
})
|
||||
|
||||
describe('CliRpcClient', () => {
|
||||
it('connects and completes handshake', async () => {
|
||||
server = createMockServer()
|
||||
const client = new CliRpcClient(server.url, { token: 'test-token' })
|
||||
const clientId = await client.connect()
|
||||
expect(clientId).toBe('test-client-001')
|
||||
expect(client.isConnected).toBe(true)
|
||||
expect(client.clientId).toBe('test-client-001')
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('sends token in handshake', async () => {
|
||||
server = createMockServer()
|
||||
const client = new CliRpcClient(server.url, { token: 'my-secret' })
|
||||
await client.connect()
|
||||
const hs = server.lastMessage()
|
||||
expect(hs?.type).toBe('handshake')
|
||||
expect(hs?.token).toBe('my-secret')
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('rejects on auth failure', async () => {
|
||||
server = createMockServer({ rejectAuth: true })
|
||||
const client = new CliRpcClient(server.url, { token: 'bad-token' })
|
||||
await expect(client.connect()).rejects.toThrow('Invalid token')
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('rejects on connect timeout', async () => {
|
||||
server = createMockServer({ noAck: true })
|
||||
const client = new CliRpcClient(server.url, { connectTimeout: 200 })
|
||||
await expect(client.connect()).rejects.toThrow('Connection timeout')
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('invoke sends request and receives response', async () => {
|
||||
server = createMockServer()
|
||||
const client = new CliRpcClient(server.url)
|
||||
await client.connect()
|
||||
const result = await client.invoke('system:homeDir')
|
||||
// Mock server echoes args — no args means empty array
|
||||
expect(result).toEqual([])
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('invoke passes args correctly', async () => {
|
||||
server = createMockServer()
|
||||
const client = new CliRpcClient(server.url)
|
||||
await client.connect()
|
||||
const result = await client.invoke('sessions:get', 'workspace-1')
|
||||
expect(result).toEqual(['workspace-1'])
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('invoke rejects on server error', async () => {
|
||||
server = createErrorServer()
|
||||
const client = new CliRpcClient(server.url)
|
||||
await client.connect()
|
||||
await expect(client.invoke('system:versions')).rejects.toThrow('test error')
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('invoke rejects on timeout', async () => {
|
||||
server = createMockServer({ noAck: false })
|
||||
// Create a server that acks handshake but never responds to requests
|
||||
server.close()
|
||||
|
||||
const silentServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req, svr) {
|
||||
if (svr.upgrade(req)) return undefined
|
||||
return new Response('Not found', { status: 404 })
|
||||
},
|
||||
websocket: {
|
||||
message(ws, message) {
|
||||
const raw = typeof message === 'string' ? message : new TextDecoder().decode(message)
|
||||
const envelope = deserializeEnvelope(raw)
|
||||
if (envelope.type === 'handshake') {
|
||||
const ack: MessageEnvelope = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'handshake_ack',
|
||||
clientId: 'silent-client',
|
||||
protocolVersion: '1.0',
|
||||
}
|
||||
ws.send(serializeEnvelope(ack))
|
||||
}
|
||||
// Never respond to requests
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const client = new CliRpcClient(`ws://127.0.0.1:${silentServer.port}`, { requestTimeout: 200 })
|
||||
await client.connect()
|
||||
await expect(client.invoke('system:homeDir')).rejects.toThrow('Request timeout')
|
||||
client.destroy()
|
||||
silentServer.stop(true)
|
||||
})
|
||||
|
||||
it('invoke throws when not connected', async () => {
|
||||
const client = new CliRpcClient('ws://127.0.0.1:1')
|
||||
await expect(client.invoke('system:homeDir')).rejects.toThrow('Not connected')
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('receives push events via on()', async () => {
|
||||
server = createMockServer()
|
||||
const client = new CliRpcClient(server.url)
|
||||
await client.connect()
|
||||
|
||||
const events: unknown[][] = []
|
||||
const unsub = client.on('session:event', (...args) => {
|
||||
events.push(args)
|
||||
})
|
||||
|
||||
// Push an event from server
|
||||
server.sendToAll({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'event',
|
||||
channel: 'session:event',
|
||||
args: [{ type: 'text_delta', sessionId: 's1', delta: 'hello' }],
|
||||
})
|
||||
|
||||
// Give it a tick
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(events.length).toBe(1)
|
||||
expect((events[0][0] as any).delta).toBe('hello')
|
||||
|
||||
// Unsubscribe stops delivery
|
||||
unsub()
|
||||
server.sendToAll({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'event',
|
||||
channel: 'session:event',
|
||||
args: [{ type: 'text_delta', sessionId: 's1', delta: 'world' }],
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(events.length).toBe(1) // Still 1
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('destroy closes connection and rejects pending', async () => {
|
||||
server = createMockServer({ noAck: false })
|
||||
// Use a server that acks but never responds
|
||||
server.close()
|
||||
|
||||
const silentServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req, svr) {
|
||||
if (svr.upgrade(req)) return undefined
|
||||
return new Response('Not found', { status: 404 })
|
||||
},
|
||||
websocket: {
|
||||
message(ws, message) {
|
||||
const raw = typeof message === 'string' ? message : new TextDecoder().decode(message)
|
||||
const envelope = deserializeEnvelope(raw)
|
||||
if (envelope.type === 'handshake') {
|
||||
ws.send(serializeEnvelope({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'handshake_ack',
|
||||
clientId: 'destroy-test',
|
||||
protocolVersion: '1.0',
|
||||
}))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const client = new CliRpcClient(`ws://127.0.0.1:${silentServer.port}`, { requestTimeout: 5000 })
|
||||
await client.connect()
|
||||
|
||||
const pending = client.invoke('system:homeDir')
|
||||
client.destroy()
|
||||
|
||||
await expect(pending).rejects.toThrow('Client destroyed')
|
||||
expect(client.isConnected).toBe(false)
|
||||
silentServer.stop(true)
|
||||
})
|
||||
|
||||
it('throws on invoke after destroy', async () => {
|
||||
server = createMockServer()
|
||||
const client = new CliRpcClient(server.url)
|
||||
await client.connect()
|
||||
client.destroy()
|
||||
await expect(client.invoke('system:homeDir')).rejects.toThrow('Not connected')
|
||||
})
|
||||
|
||||
it('connects over wss:// with TLS', async () => {
|
||||
const tls = generateSelfSignedCert()
|
||||
if (!tls) {
|
||||
// openssl not available — skip TLS test
|
||||
console.log(' (skipped: openssl not available)')
|
||||
return
|
||||
}
|
||||
server = createMockServer({ tls })
|
||||
|
||||
const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
try {
|
||||
const client = new CliRpcClient(server.url)
|
||||
const clientId = await client.connect()
|
||||
expect(clientId).toBe('test-client-001')
|
||||
expect(server.url.startsWith('wss://')).toBe(true)
|
||||
client.destroy()
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
|
||||
} else {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS cert helper — generates a real self-signed cert via openssl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateSelfSignedCert(): { cert: string; key: string } | null {
|
||||
try {
|
||||
const keyResult = Bun.spawnSync({
|
||||
cmd: ['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1',
|
||||
'-keyout', '/dev/stdout', '-out', '/dev/stdout',
|
||||
'-days', '1', '-nodes', '-subj', '/CN=localhost', '-batch'],
|
||||
stderr: 'pipe',
|
||||
})
|
||||
if (keyResult.exitCode !== 0) return null
|
||||
|
||||
const pem = keyResult.stdout.toString()
|
||||
const certMatch = pem.match(/(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)/)
|
||||
const keyMatch = pem.match(/(-----BEGIN (?:EC )?PRIVATE KEY-----[\s\S]+?-----END (?:EC )?PRIVATE KEY-----)/)
|
||||
if (!certMatch || !keyMatch) return null
|
||||
|
||||
return { cert: certMatch[1], key: keyMatch[1] }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* CliRpcClient — Minimal WebSocket RPC client for CLI usage.
|
||||
*
|
||||
* Stripped-down version of WsRpcClient: no auto-reconnect, no capabilities,
|
||||
* no connection state listeners. Connect, work, exit.
|
||||
*/
|
||||
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
type MessageEnvelope,
|
||||
} from '@craft-agent/shared/protocol'
|
||||
import {
|
||||
serializeEnvelope,
|
||||
deserializeEnvelope,
|
||||
} from '@craft-agent/server-core/transport'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export interface CliClientOptions {
|
||||
token?: string
|
||||
workspaceId?: string
|
||||
requestTimeout?: number
|
||||
connectTimeout?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class CliRpcClient {
|
||||
private ws: WebSocket | null = null
|
||||
private pending = new Map<string, PendingRequest>()
|
||||
private listeners = new Map<string, Set<(...args: unknown[]) => void>>()
|
||||
private _clientId: string | null = null
|
||||
private _connected = false
|
||||
private _destroyed = false
|
||||
|
||||
private readonly url: string
|
||||
private readonly token: string | undefined
|
||||
private readonly workspaceId: string | undefined
|
||||
private readonly requestTimeout: number
|
||||
private readonly connectTimeout: number
|
||||
|
||||
constructor(url: string, opts?: CliClientOptions) {
|
||||
this.url = url
|
||||
this.token = opts?.token
|
||||
this.workspaceId = opts?.workspaceId
|
||||
this.requestTimeout = opts?.requestTimeout ?? 10_000
|
||||
this.connectTimeout = opts?.connectTimeout ?? 10_000
|
||||
}
|
||||
|
||||
/** Connect to the server and complete the handshake. Returns the assigned clientId. */
|
||||
async connect(): Promise<string> {
|
||||
if (this._destroyed) throw new Error('Client destroyed')
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Connection timeout (${this.connectTimeout}ms)`))
|
||||
this.ws?.close()
|
||||
}, this.connectTimeout)
|
||||
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
const handshake: MessageEnvelope = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'handshake',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
workspaceId: this.workspaceId,
|
||||
token: this.token,
|
||||
}
|
||||
this.ws!.send(serializeEnvelope(handshake))
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const raw = typeof event.data === 'string' ? event.data : String(event.data)
|
||||
let envelope: MessageEnvelope
|
||||
try {
|
||||
envelope = deserializeEnvelope(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'handshake_ack') {
|
||||
clearTimeout(timer)
|
||||
this._clientId = envelope.clientId ?? null
|
||||
this._connected = true
|
||||
// Switch to normal message handler
|
||||
this.ws!.onmessage = (e) => {
|
||||
this.onMessage(typeof e.data === 'string' ? e.data : String(e.data))
|
||||
}
|
||||
resolve(this._clientId!)
|
||||
} else if (envelope.type === 'error') {
|
||||
clearTimeout(timer)
|
||||
const err = new Error(envelope.error?.message ?? 'Connection rejected')
|
||||
;(err as any).code = envelope.error?.code
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (!this._connected) {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('WebSocket connection error'))
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (!this._connected) {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('WebSocket closed before handshake'))
|
||||
}
|
||||
this._connected = false
|
||||
for (const [, req] of this.pending) {
|
||||
clearTimeout(req.timeout)
|
||||
req.reject(new Error('Disconnected'))
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Send an RPC request and await the response. */
|
||||
async invoke(channel: string, ...args: unknown[]): Promise<unknown> {
|
||||
if (!this._connected || !this.ws) {
|
||||
throw new Error(`Not connected (channel: ${channel})`)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = crypto.randomUUID()
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
reject(new Error(`Request timeout: ${channel} (${this.requestTimeout}ms)`))
|
||||
}, this.requestTimeout)
|
||||
|
||||
this.pending.set(id, { resolve, reject, timeout })
|
||||
|
||||
const envelope: MessageEnvelope = {
|
||||
id,
|
||||
type: 'request',
|
||||
channel,
|
||||
args,
|
||||
}
|
||||
this.ws!.send(serializeEnvelope(envelope))
|
||||
})
|
||||
}
|
||||
|
||||
/** Subscribe to push events on a channel. Returns an unsubscribe function. */
|
||||
on(channel: string, callback: (...args: unknown[]) => void): () => void {
|
||||
let set = this.listeners.get(channel)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.listeners.set(channel, set)
|
||||
}
|
||||
set.add(callback)
|
||||
|
||||
return () => {
|
||||
set!.delete(callback)
|
||||
if (set!.size === 0) this.listeners.delete(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the connection and reject all pending requests. */
|
||||
destroy(): void {
|
||||
this._destroyed = true
|
||||
for (const [, req] of this.pending) {
|
||||
clearTimeout(req.timeout)
|
||||
req.reject(new Error('Client destroyed'))
|
||||
}
|
||||
this.pending.clear()
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
this._connected = false
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get clientId(): string | null {
|
||||
return this._clientId
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal message routing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private onMessage(raw: string): void {
|
||||
let envelope: MessageEnvelope
|
||||
try {
|
||||
envelope = deserializeEnvelope(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
switch (envelope.type) {
|
||||
case 'response': {
|
||||
const req = this.pending.get(envelope.id)
|
||||
if (req) {
|
||||
this.pending.delete(envelope.id)
|
||||
clearTimeout(req.timeout)
|
||||
if (envelope.error) {
|
||||
const err = new Error(envelope.error.message)
|
||||
;(err as any).code = envelope.error.code
|
||||
;(err as any).data = envelope.error.data
|
||||
req.reject(err)
|
||||
} else {
|
||||
req.resolve(envelope.result)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'event': {
|
||||
if (envelope.channel) {
|
||||
const set = this.listeners.get(envelope.channel)
|
||||
if (set) {
|
||||
for (const cb of set) {
|
||||
try {
|
||||
cb(...(envelope.args ?? []))
|
||||
} catch {
|
||||
// Listener errors shouldn't break the client
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { parseArgs, resolveApiKey, shouldSetupLlmConnection } from './index.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arg parsing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('parses --url, --token, --workspace', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--url', 'ws://localhost:3000',
|
||||
'--token', 'secret123',
|
||||
'--workspace', 'ws-1',
|
||||
'ping',
|
||||
])
|
||||
expect(args.url).toBe('ws://localhost:3000')
|
||||
expect(args.token).toBe('secret123')
|
||||
expect(args.workspace).toBe('ws-1')
|
||||
expect(args.command).toBe('ping')
|
||||
})
|
||||
|
||||
it('parses --timeout and --json', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--timeout', '5000',
|
||||
'--json',
|
||||
'workspaces',
|
||||
])
|
||||
expect(args.timeout).toBe(5000)
|
||||
expect(args.json).toBe(true)
|
||||
expect(args.command).toBe('workspaces')
|
||||
})
|
||||
|
||||
it('parses --tls-ca', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--tls-ca', '/path/to/ca.pem',
|
||||
'ping',
|
||||
])
|
||||
expect(args.tlsCa).toBe('/path/to/ca.pem')
|
||||
})
|
||||
|
||||
it('parses --send-timeout', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--send-timeout', '60000',
|
||||
'send', 'session-1', 'hello',
|
||||
])
|
||||
expect(args.sendTimeout).toBe(60000)
|
||||
expect(args.command).toBe('send')
|
||||
expect(args.rest).toEqual(['session-1', 'hello'])
|
||||
})
|
||||
|
||||
it('falls back to env vars for url and token', () => {
|
||||
const prevUrl = process.env.CRAFT_SERVER_URL
|
||||
const prevToken = process.env.CRAFT_SERVER_TOKEN
|
||||
const prevCa = process.env.CRAFT_TLS_CA
|
||||
|
||||
process.env.CRAFT_SERVER_URL = 'ws://env-server:8080'
|
||||
process.env.CRAFT_SERVER_TOKEN = 'env-token'
|
||||
process.env.CRAFT_TLS_CA = '/env/ca.pem'
|
||||
|
||||
try {
|
||||
const args = parseArgs(['bun', 'index.ts', 'ping'])
|
||||
expect(args.url).toBe('ws://env-server:8080')
|
||||
expect(args.token).toBe('env-token')
|
||||
expect(args.tlsCa).toBe('/env/ca.pem')
|
||||
} finally {
|
||||
if (prevUrl === undefined) delete process.env.CRAFT_SERVER_URL
|
||||
else process.env.CRAFT_SERVER_URL = prevUrl
|
||||
if (prevToken === undefined) delete process.env.CRAFT_SERVER_TOKEN
|
||||
else process.env.CRAFT_SERVER_TOKEN = prevToken
|
||||
if (prevCa === undefined) delete process.env.CRAFT_TLS_CA
|
||||
else process.env.CRAFT_TLS_CA = prevCa
|
||||
}
|
||||
})
|
||||
|
||||
it('explicit flags override env vars', () => {
|
||||
const prevUrl = process.env.CRAFT_SERVER_URL
|
||||
process.env.CRAFT_SERVER_URL = 'ws://env-server:8080'
|
||||
|
||||
try {
|
||||
const args = parseArgs(['bun', 'index.ts', '--url', 'ws://flag-server:9090', 'ping'])
|
||||
expect(args.url).toBe('ws://flag-server:9090')
|
||||
} finally {
|
||||
if (prevUrl === undefined) delete process.env.CRAFT_SERVER_URL
|
||||
else process.env.CRAFT_SERVER_URL = prevUrl
|
||||
}
|
||||
})
|
||||
|
||||
it('parses --help as command', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--help'])
|
||||
expect(args.command).toBe('help')
|
||||
})
|
||||
|
||||
it('parses --version as command', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--version'])
|
||||
expect(args.command).toBe('version')
|
||||
})
|
||||
|
||||
it('parses --validate-server as command', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--validate-server'])
|
||||
expect(args.command).toBe('validate')
|
||||
})
|
||||
|
||||
it('parses session subcommand with args', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'session', 'create', '--name', 'test', '--mode', 'safe',
|
||||
])
|
||||
expect(args.command).toBe('session')
|
||||
// --mode is now a global flag, consumed at top level
|
||||
expect(args.rest).toEqual(['create', '--name', 'test'])
|
||||
expect(args.mode).toBe('safe')
|
||||
})
|
||||
|
||||
it('parses send with message text', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'send', 'sess-123', 'What', 'files', 'are', 'here?',
|
||||
])
|
||||
expect(args.command).toBe('send')
|
||||
expect(args.rest).toEqual(['sess-123', 'What', 'files', 'are', 'here?'])
|
||||
})
|
||||
|
||||
it('parses invoke with channel and JSON args', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'invoke', 'sessions:get', '["workspace-1"]',
|
||||
])
|
||||
expect(args.command).toBe('invoke')
|
||||
expect(args.rest).toEqual(['sessions:get', '["workspace-1"]'])
|
||||
})
|
||||
|
||||
it('defaults to empty command (shows help)', () => {
|
||||
const args = parseArgs(['bun', 'index.ts'])
|
||||
expect(args.command).toBe('')
|
||||
})
|
||||
|
||||
it('defaults timeout to 10000', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'ping'])
|
||||
expect(args.timeout).toBe(10000)
|
||||
})
|
||||
|
||||
it('defaults sendTimeout to 300000', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'send', 's1', 'hi'])
|
||||
expect(args.sendTimeout).toBe(300000)
|
||||
})
|
||||
|
||||
it('defaults json to false', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'ping'])
|
||||
expect(args.json).toBe(false)
|
||||
})
|
||||
|
||||
// --- run-specific flags ---
|
||||
|
||||
it('parses run command with positional args', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello', 'world'])
|
||||
expect(args.command).toBe('run')
|
||||
expect(args.rest).toEqual(['hello', 'world'])
|
||||
})
|
||||
|
||||
it('--source accumulates into array', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--source', 'craft-kb',
|
||||
'--source', 'github',
|
||||
'run', 'do stuff',
|
||||
])
|
||||
expect(args.sources).toEqual(['craft-kb', 'github'])
|
||||
})
|
||||
|
||||
it('defaults sources to empty array', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello'])
|
||||
expect(args.sources).toEqual([])
|
||||
})
|
||||
|
||||
it('--mode sets mode', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--mode', 'safe', 'run', 'hello'])
|
||||
expect(args.mode).toBe('safe')
|
||||
})
|
||||
|
||||
it('defaults mode to empty (run defaults to allow-all)', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello'])
|
||||
expect(args.mode).toBe('')
|
||||
})
|
||||
|
||||
it('--output-format sets outputFormat', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--output-format', 'stream-json', 'run', 'hello'])
|
||||
expect(args.outputFormat).toBe('stream-json')
|
||||
})
|
||||
|
||||
it('defaults outputFormat to text', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello'])
|
||||
expect(args.outputFormat).toBe('text')
|
||||
})
|
||||
|
||||
it('--no-cleanup sets noCleanup', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--no-cleanup', 'run', 'hello'])
|
||||
expect(args.noCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults noCleanup to false', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello'])
|
||||
expect(args.noCleanup).toBe(false)
|
||||
})
|
||||
|
||||
it('--server-entry sets serverEntry', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--server-entry', '/path/to/server.ts', 'run', 'hello'])
|
||||
expect(args.serverEntry).toBe('/path/to/server.ts')
|
||||
})
|
||||
|
||||
it('defaults serverEntry to undefined', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello'])
|
||||
expect(args.serverEntry).toBeUndefined()
|
||||
})
|
||||
|
||||
it('--workspace-dir sets workspaceDir', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--workspace-dir', '/tmp/ws', 'run', 'hello'])
|
||||
expect(args.workspaceDir).toBe('/tmp/ws')
|
||||
})
|
||||
|
||||
it('defaults workspaceDir to undefined', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello'])
|
||||
expect(args.workspaceDir).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses --provider deepseek for run', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', '--provider', 'deepseek', 'run', 'hello'])
|
||||
expect(args.provider).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider credential resolution tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveApiKey', () => {
|
||||
it('keeps explicit key passthrough for script compatibility', () => {
|
||||
expect(resolveApiKey('qwen', 'unused-key')).toBe('unused-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSetupLlmConnection', () => {
|
||||
it('sets up Qwen when no connection exists', () => {
|
||||
expect(shouldSetupLlmConnection(0, { provider: 'qwen', baseUrl: '' })).toBe(true)
|
||||
})
|
||||
|
||||
it('skips setup when a connection already exists', () => {
|
||||
expect(shouldSetupLlmConnection(2, { provider: 'qwen', baseUrl: '' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate steps structure tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { getValidateSteps } from './index.ts'
|
||||
|
||||
describe('getValidateSteps', () => {
|
||||
it('returns a non-empty array of steps', () => {
|
||||
const steps = getValidateSteps()
|
||||
expect(steps.length).toBeGreaterThan(0)
|
||||
// Sanity: at least the known lifecycle groups exist
|
||||
expect(steps.length).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('first step is handshake', () => {
|
||||
const steps = getValidateSteps()
|
||||
expect(steps[0].name).toBe('Connect + handshake')
|
||||
})
|
||||
|
||||
it('last step is disconnect', () => {
|
||||
const steps = getValidateSteps()
|
||||
expect(steps[steps.length - 1].name).toBe('Disconnect')
|
||||
})
|
||||
|
||||
it('has no duplicate step names', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
})
|
||||
|
||||
it('includes session lifecycle steps (create, read, delete)', () => {
|
||||
const steps = getValidateSteps()
|
||||
const names = steps.map((s) => s.name)
|
||||
expect(names).toContain('sessions:create')
|
||||
expect(names).toContain('sessions:getMessages')
|
||||
expect(names).toContain('sessions:delete')
|
||||
})
|
||||
|
||||
it('includes send message + stream step', () => {
|
||||
const steps = getValidateSteps()
|
||||
const names = steps.map((s) => s.name)
|
||||
expect(names).toContain('send message + stream')
|
||||
})
|
||||
|
||||
it('includes send message + tool use step', () => {
|
||||
const steps = getValidateSteps()
|
||||
const names = steps.map((s) => s.name)
|
||||
expect(names).toContain('send message + tool use')
|
||||
})
|
||||
|
||||
it('includes source lifecycle steps (create, mention, delete)', () => {
|
||||
const steps = getValidateSteps()
|
||||
const names = steps.map((s) => s.name)
|
||||
expect(names).toContain('sources:create')
|
||||
expect(names).toContain('send + source mention')
|
||||
expect(names).toContain('sources:delete')
|
||||
})
|
||||
|
||||
it('includes skill lifecycle steps (create, mention, delete)', () => {
|
||||
const steps = getValidateSteps()
|
||||
const names = steps.map((s) => s.name)
|
||||
expect(names).toContain('send + skill create')
|
||||
expect(names).toContain('send + skill mention')
|
||||
expect(names).toContain('skills:delete')
|
||||
})
|
||||
|
||||
it('includes automation lifecycle steps', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
expect(names).toContain('automation:create')
|
||||
expect(names).toContain('automation:trigger (status change)')
|
||||
expect(names).toContain('automation:verify session')
|
||||
expect(names).toContain('automation:verify labels')
|
||||
expect(names).toContain('automations:getLastExecuted')
|
||||
expect(names).toContain('automation:cleanup')
|
||||
})
|
||||
|
||||
it('includes session tool validation steps', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
expect(names).toContain('session-tools:set_session_labels')
|
||||
expect(names).toContain('session-tools:get_session_info')
|
||||
expect(names).toContain('session-tools:list_sessions')
|
||||
})
|
||||
|
||||
it('session tool steps come after tool use and before branching', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
const toolUse = names.indexOf('send message + tool use')
|
||||
const labels = names.indexOf('session-tools:set_session_labels')
|
||||
const branch = names.indexOf('sessions:branch')
|
||||
expect(labels).toBeGreaterThan(toolUse)
|
||||
expect(branch).toBeGreaterThan(labels)
|
||||
})
|
||||
|
||||
it('includes session branching steps', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
expect(names).toContain('sessions:branch')
|
||||
expect(names).toContain('sessions:branch verify')
|
||||
expect(names).toContain('sessions:branch send')
|
||||
})
|
||||
|
||||
it('includes webhook validation steps', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
expect(names).toContain('webhook:test (RPC)')
|
||||
expect(names).toContain('webhook:verify failure')
|
||||
})
|
||||
|
||||
it('creates session with allow-all permission mode', () => {
|
||||
const steps = getValidateSteps()
|
||||
const createStep = steps.find((s) => s.name === 'sessions:create')
|
||||
expect(createStep).toBeDefined()
|
||||
})
|
||||
|
||||
it('cleanup steps come after send steps', () => {
|
||||
const steps = getValidateSteps()
|
||||
const names = steps.map((s) => s.name)
|
||||
const skillDelete = names.indexOf('skills:delete')
|
||||
const sourceDelete = names.indexOf('sources:delete')
|
||||
const sessionDelete = names.indexOf('sessions:delete')
|
||||
const skillMention = names.indexOf('send + skill mention')
|
||||
expect(skillDelete).toBeGreaterThan(skillMention)
|
||||
expect(sourceDelete).toBeGreaterThan(skillDelete)
|
||||
expect(sessionDelete).toBeGreaterThan(sourceDelete)
|
||||
})
|
||||
|
||||
it('branching steps come after send message + tool use', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
const toolUse = names.indexOf('send message + tool use')
|
||||
const branch = names.indexOf('sessions:branch')
|
||||
expect(branch).toBeGreaterThan(toolUse)
|
||||
})
|
||||
|
||||
it('automation cleanup comes before sources:delete', () => {
|
||||
const names = getValidateSteps().map((s) => s.name)
|
||||
const cleanup = names.indexOf('automation:cleanup')
|
||||
const srcDelete = names.indexOf('sources:delete')
|
||||
expect(cleanup).toBeGreaterThan(-1)
|
||||
expect(cleanup).toBeLessThan(srcDelete)
|
||||
})
|
||||
})
|
||||
@@ -1,427 +0,0 @@
|
||||
import { describe, it, expect, afterEach, mock, beforeEach } from 'bun:test'
|
||||
import {
|
||||
serializeEnvelope,
|
||||
deserializeEnvelope,
|
||||
} from '@craft-agent/server-core/transport'
|
||||
import type { SpawnedServer } from './server-spawner.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock WS server for run command tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MockServerOptions {
|
||||
/** What LLM_Connection:list returns */
|
||||
connections?: unknown[]
|
||||
}
|
||||
|
||||
interface MockServer {
|
||||
url: string
|
||||
token: string
|
||||
close: () => void
|
||||
/** Channels invoked by the client, in order */
|
||||
invokedChannels: string[]
|
||||
/** Arguments passed to sessions:create */
|
||||
createSessionArgs?: unknown[]
|
||||
/** All invocation args, keyed by channel */
|
||||
invokeArgs: Record<string, unknown[][]>
|
||||
}
|
||||
|
||||
function pushSessionEvents(
|
||||
ws: any,
|
||||
sessionId: string,
|
||||
events: Array<Record<string, unknown>>,
|
||||
): void {
|
||||
setTimeout(() => {
|
||||
for (const ev of events) {
|
||||
ws.send(serializeEnvelope({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'event',
|
||||
channel: 'session:event',
|
||||
args: [{ sessionId, ...ev }],
|
||||
}))
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
function createMockServer(opts?: MockServerOptions): MockServer {
|
||||
const token = 'test-token'
|
||||
const invokedChannels: string[] = []
|
||||
const invokeArgs: Record<string, unknown[][]> = {}
|
||||
let createSessionArgs: unknown[] | undefined
|
||||
const connections = opts?.connections ?? []
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req, svr) {
|
||||
if (svr.upgrade(req)) return undefined
|
||||
return new Response('Not found', { status: 404 })
|
||||
},
|
||||
websocket: {
|
||||
message(ws, message) {
|
||||
const raw = typeof message === 'string' ? message : new TextDecoder().decode(message)
|
||||
const envelope = deserializeEnvelope(raw)
|
||||
|
||||
if (envelope.type === 'handshake') {
|
||||
ws.send(serializeEnvelope({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'handshake_ack',
|
||||
clientId: 'run-test-client',
|
||||
protocolVersion: '1.0',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'request') {
|
||||
const ch = envelope.channel!
|
||||
invokedChannels.push(ch)
|
||||
if (!invokeArgs[ch]) invokeArgs[ch] = []
|
||||
invokeArgs[ch].push(envelope.args ?? [])
|
||||
|
||||
let result: unknown
|
||||
switch (ch) {
|
||||
case 'workspaces:get':
|
||||
result = [{ id: 'ws-1', name: 'Test Workspace' }]
|
||||
break
|
||||
case 'workspaces:create':
|
||||
result = { id: 'ws-1', name: 'ci-workspace' }
|
||||
break
|
||||
case 'window:switchWorkspace':
|
||||
result = { ok: true }
|
||||
break
|
||||
case 'LLM_Connection:list':
|
||||
result = connections
|
||||
break
|
||||
case 'LLM_Connection:save':
|
||||
result = { ok: true }
|
||||
break
|
||||
case 'settings:setupLlmConnection':
|
||||
result = { ok: true }
|
||||
break
|
||||
case 'LLM_Connection:setDefault':
|
||||
result = { ok: true }
|
||||
break
|
||||
case 'sessions:create':
|
||||
createSessionArgs = envelope.args
|
||||
result = { id: 'run-session-1', name: 'run-test' }
|
||||
break
|
||||
case 'sessions:sendMessage': {
|
||||
ws.send(serializeEnvelope({
|
||||
id: envelope.id,
|
||||
type: 'response',
|
||||
channel: ch,
|
||||
result: { started: true },
|
||||
}))
|
||||
pushSessionEvents(ws, 'run-session-1', [
|
||||
{ type: 'text_delta', delta: 'Hello ' },
|
||||
{ type: 'text_delta', delta: 'World' },
|
||||
{ type: 'complete' },
|
||||
])
|
||||
return // already sent response
|
||||
}
|
||||
case 'sessions:delete':
|
||||
result = { deleted: true }
|
||||
break
|
||||
case 'sessions:cancel':
|
||||
result = { cancelled: true }
|
||||
break
|
||||
default:
|
||||
result = null
|
||||
}
|
||||
|
||||
ws.send(serializeEnvelope({
|
||||
id: envelope.id,
|
||||
type: 'response',
|
||||
channel: ch,
|
||||
result,
|
||||
}))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
url: `ws://localhost:${server.port}`,
|
||||
token,
|
||||
close: () => server.stop(),
|
||||
invokedChannels,
|
||||
invokeArgs,
|
||||
get createSessionArgs() { return createSessionArgs },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock spawnServer so cmdRun doesn't actually launch a child process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mockWsServer: MockServer | null = null
|
||||
|
||||
mock.module('./server-spawner.ts', () => ({
|
||||
spawnServer: async (): Promise<SpawnedServer> => {
|
||||
if (!mockWsServer) throw new Error('mockWsServer not initialized')
|
||||
return {
|
||||
url: mockWsServer.url,
|
||||
token: mockWsServer.token,
|
||||
stop: async () => {},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Import main AFTER mocking
|
||||
const { parseArgs } = await import('./index.ts')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('run command', () => {
|
||||
beforeEach(() => {
|
||||
mockWsServer = createMockServer()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockWsServer?.close()
|
||||
mockWsServer = null
|
||||
})
|
||||
|
||||
it('parseArgs: run with --source accumulates sources', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--source', 'craft-kb',
|
||||
'--source', 'github',
|
||||
'run', 'do', 'stuff',
|
||||
])
|
||||
expect(args.command).toBe('run')
|
||||
expect(args.sources).toEqual(['craft-kb', 'github'])
|
||||
expect(args.rest).toEqual(['do', 'stuff'])
|
||||
})
|
||||
|
||||
it('parseArgs: --output-format stream-json', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--output-format', 'stream-json',
|
||||
'run', 'test',
|
||||
])
|
||||
expect(args.outputFormat).toBe('stream-json')
|
||||
})
|
||||
|
||||
it('parseArgs: --no-cleanup flag', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--no-cleanup',
|
||||
'run', 'test',
|
||||
])
|
||||
expect(args.noCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('creates session with correct workspace and options', async () => {
|
||||
// We can't easily call cmdRun directly since it calls process.exit.
|
||||
// Instead, test the mock server interaction via CliRpcClient to verify
|
||||
// the channels and args that cmdRun would invoke.
|
||||
const { CliRpcClient } = await import('./client.ts')
|
||||
|
||||
const client = new CliRpcClient(mockWsServer!.url, {
|
||||
token: mockWsServer!.token,
|
||||
requestTimeout: 5_000,
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
// Simulate what cmdRun does: resolve workspace, create session
|
||||
const workspaces = await client.invoke('workspaces:get') as any[]
|
||||
expect(workspaces).toHaveLength(1)
|
||||
|
||||
await client.invoke('window:switchWorkspace', workspaces[0].id)
|
||||
|
||||
const session = await client.invoke('sessions:create', 'ws-1', {
|
||||
permissionMode: 'allow-all',
|
||||
enabledSourceSlugs: ['craft-kb'],
|
||||
}) as { id: string }
|
||||
expect(session.id).toBe('run-session-1')
|
||||
|
||||
// Verify the create args
|
||||
expect(mockWsServer!.createSessionArgs).toEqual([
|
||||
'ws-1',
|
||||
{ permissionMode: 'allow-all', enabledSourceSlugs: ['craft-kb'] },
|
||||
])
|
||||
|
||||
// Verify channel order
|
||||
expect(mockWsServer!.invokedChannels).toEqual([
|
||||
'workspaces:get',
|
||||
'window:switchWorkspace',
|
||||
'sessions:create',
|
||||
])
|
||||
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('streams text events from session', async () => {
|
||||
const { CliRpcClient } = await import('./client.ts')
|
||||
|
||||
const client = new CliRpcClient(mockWsServer!.url, {
|
||||
token: mockWsServer!.token,
|
||||
requestTimeout: 5_000,
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
// Subscribe and collect text deltas
|
||||
const deltas: string[] = []
|
||||
let completed = false
|
||||
|
||||
const unsub = client.on('session:event', (event: unknown) => {
|
||||
const ev = event as { type: string; sessionId: string; delta?: string }
|
||||
if (ev.sessionId !== 'run-session-1') return
|
||||
if (ev.type === 'text_delta') deltas.push(ev.delta!)
|
||||
if (ev.type === 'complete') completed = true
|
||||
})
|
||||
|
||||
await client.invoke('sessions:sendMessage', 'run-session-1', 'test')
|
||||
|
||||
// Wait for events
|
||||
const deadline = Date.now() + 5_000
|
||||
while (!completed && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
}
|
||||
|
||||
unsub()
|
||||
expect(completed).toBe(true)
|
||||
expect(deltas).toEqual(['Hello ', 'World'])
|
||||
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('session delete is called in lifecycle', async () => {
|
||||
const { CliRpcClient } = await import('./client.ts')
|
||||
|
||||
const client = new CliRpcClient(mockWsServer!.url, {
|
||||
token: mockWsServer!.token,
|
||||
requestTimeout: 5_000,
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
await client.invoke('sessions:create', 'ws-1', { permissionMode: 'allow-all' })
|
||||
await client.invoke('sessions:delete', 'run-session-1')
|
||||
|
||||
expect(mockWsServer!.invokedChannels).toContain('sessions:create')
|
||||
expect(mockWsServer!.invokedChannels).toContain('sessions:delete')
|
||||
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('spawnServer mock returns expected url and token', async () => {
|
||||
const { spawnServer } = await import('./server-spawner.ts')
|
||||
const server = await spawnServer()
|
||||
|
||||
expect(server.url).toBe(mockWsServer!.url)
|
||||
expect(server.token).toBe(mockWsServer!.token)
|
||||
expect(typeof server.stop).toBe('function')
|
||||
})
|
||||
|
||||
it('parseArgs: --workspace-dir sets workspaceDir', () => {
|
||||
const args = parseArgs([
|
||||
'bun', 'index.ts',
|
||||
'--workspace-dir', '/tmp/my-workspace',
|
||||
'run', 'hello',
|
||||
])
|
||||
expect(args.workspaceDir).toBe('/tmp/my-workspace')
|
||||
expect(args.command).toBe('run')
|
||||
})
|
||||
|
||||
it('parseArgs: workspaceDir defaults to undefined', () => {
|
||||
const args = parseArgs(['bun', 'index.ts', 'run', 'hello'])
|
||||
expect(args.workspaceDir).toBeUndefined()
|
||||
})
|
||||
|
||||
it('workspace:create returns ID used directly (no workspaces:get needed)', async () => {
|
||||
const { CliRpcClient } = await import('./client.ts')
|
||||
|
||||
const client = new CliRpcClient(mockWsServer!.url, {
|
||||
token: mockWsServer!.token,
|
||||
requestTimeout: 5_000,
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
// Simulate the workspace bootstrap path from cmdRun:
|
||||
// workspaces:create returns { id }, which is used directly
|
||||
const ws = (await client.invoke('workspaces:create', '/tmp/ws', 'ci-workspace')) as { id: string }
|
||||
expect(ws.id).toBe('ws-1')
|
||||
|
||||
// Then switchWorkspace is called with the returned ID
|
||||
await client.invoke('window:switchWorkspace', ws.id)
|
||||
|
||||
// Session is created with the bootstrapped workspace ID
|
||||
await client.invoke('sessions:create', ws.id, {
|
||||
permissionMode: 'allow-all',
|
||||
enabledSourceSlugs: ['craft-public'],
|
||||
})
|
||||
|
||||
expect(mockWsServer!.invokedChannels).toEqual([
|
||||
'workspaces:create',
|
||||
'window:switchWorkspace',
|
||||
'sessions:create',
|
||||
])
|
||||
expect(mockWsServer!.invokeArgs['workspaces:create']![0]).toEqual(['/tmp/ws', 'ci-workspace'])
|
||||
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('LLM bootstrap calls save, setup, and setDefault when no connections exist', async () => {
|
||||
// Server returns empty connections list
|
||||
mockWsServer?.close()
|
||||
mockWsServer = createMockServer({ connections: [] })
|
||||
|
||||
const { CliRpcClient } = await import('./client.ts')
|
||||
const client = new CliRpcClient(mockWsServer!.url, {
|
||||
token: mockWsServer!.token,
|
||||
requestTimeout: 5_000,
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
// Simulate the LLM bootstrap path from cmdRun
|
||||
const connections = (await client.invoke('LLM_Connection:list')) as any[]
|
||||
expect(connections).toEqual([])
|
||||
|
||||
await client.invoke('LLM_Connection:save', {
|
||||
slug: 'qwen-code',
|
||||
name: 'Qwen Code',
|
||||
providerType: 'qwen',
|
||||
authType: 'none',
|
||||
createdAt: 123,
|
||||
})
|
||||
await client.invoke('settings:setupLlmConnection', {
|
||||
slug: 'qwen-code',
|
||||
})
|
||||
await client.invoke('LLM_Connection:setDefault', 'qwen-code')
|
||||
|
||||
expect(mockWsServer!.invokedChannels).toEqual([
|
||||
'LLM_Connection:list',
|
||||
'LLM_Connection:save',
|
||||
'settings:setupLlmConnection',
|
||||
'LLM_Connection:setDefault',
|
||||
])
|
||||
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
it('LLM bootstrap is skipped when connections already exist', async () => {
|
||||
// Server returns existing connection
|
||||
mockWsServer?.close()
|
||||
mockWsServer = createMockServer({
|
||||
connections: [{ slug: 'existing', name: 'Existing' }],
|
||||
})
|
||||
|
||||
const { CliRpcClient } = await import('./client.ts')
|
||||
const client = new CliRpcClient(mockWsServer!.url, {
|
||||
token: mockWsServer!.token,
|
||||
requestTimeout: 5_000,
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
// Simulate: check connections — they exist, so skip bootstrap
|
||||
const connections = (await client.invoke('LLM_Connection:list')) as any[]
|
||||
expect(connections).toHaveLength(1)
|
||||
|
||||
// No further LLM calls should be needed
|
||||
expect(mockWsServer!.invokedChannels).toEqual(['LLM_Connection:list'])
|
||||
|
||||
client.destroy()
|
||||
})
|
||||
})
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Server spawner — start a headless Qwen Code server as a child process.
|
||||
*
|
||||
* Spawns `bun run <serverEntry>`, reads stdout for the `CRAFT_SERVER_URL=`
|
||||
* and `CRAFT_SERVER_TOKEN=` lines, and returns a handle to stop the server.
|
||||
*/
|
||||
|
||||
import { resolve, join } from 'node:path'
|
||||
import type { Subprocess } from 'bun'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SpawnedServer {
|
||||
url: string
|
||||
token: string
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface SpawnServerOptions {
|
||||
/** Path to the server entry file. Auto-detected from monorepo root if omitted. */
|
||||
serverEntry?: string
|
||||
/** Extra env vars to pass to the server process. */
|
||||
env?: Record<string, string>
|
||||
/** How long to wait for the server to print its URL (ms). Default: 30000. */
|
||||
startupTimeout?: number
|
||||
/** Suppress server stderr output (useful for validation where only test output matters). */
|
||||
quiet?: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-detect server entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findServerEntry(): string {
|
||||
// Walk up from this file's directory to find the monorepo root.
|
||||
// Expected layout: apps/cli/src/server-spawner.ts → root/packages/server/src/index.ts
|
||||
let dir = import.meta.dir
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = join(dir, 'packages', 'server', 'src', 'index.ts')
|
||||
if (Bun.file(candidate).size > 0) return candidate
|
||||
dir = resolve(dir, '..')
|
||||
}
|
||||
throw new Error(
|
||||
'Could not auto-detect server entry. ' +
|
||||
'Pass --server-entry or ensure the monorepo layout includes packages/server/src/index.ts',
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function spawnServer(opts?: SpawnServerOptions): Promise<SpawnedServer> {
|
||||
const serverEntry = opts?.serverEntry ?? findServerEntry()
|
||||
const startupTimeout = opts?.startupTimeout ?? 30_000
|
||||
const token = crypto.randomUUID()
|
||||
|
||||
const proc: Subprocess = Bun.spawn(['bun', 'run', serverEntry], {
|
||||
env: {
|
||||
...process.env,
|
||||
...opts?.env,
|
||||
CRAFT_SERVER_TOKEN: token,
|
||||
CRAFT_RPC_PORT: '0',
|
||||
CRAFT_RPC_HOST: '127.0.0.1',
|
||||
},
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
// Pipe server stderr to our stderr so --debug logs are visible (unless quiet)
|
||||
if (proc.stderr && !opts?.quiet) {
|
||||
;(async () => {
|
||||
// @ts-expect-error — Bun Subprocess types don't narrow stderr to ReadableStream when stderr: 'pipe'
|
||||
const reader = proc.stderr.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
process.stderr.write(value)
|
||||
}
|
||||
} catch {
|
||||
// Server exited — normal
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Read stdout line by line looking for CRAFT_SERVER_URL=
|
||||
return new Promise<SpawnedServer>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Server did not start within ${startupTimeout}ms`))
|
||||
}, startupTimeout)
|
||||
|
||||
let url = ''
|
||||
let buffer = ''
|
||||
|
||||
const processLines = () => {
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? '' // keep incomplete last line in buffer
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('CRAFT_SERVER_URL=')) {
|
||||
url = line.slice('CRAFT_SERVER_URL='.length).trim()
|
||||
}
|
||||
if (line.startsWith('CRAFT_SERVER_TOKEN=')) {
|
||||
// Server echoes the token — we already have it but this confirms ready
|
||||
}
|
||||
// Once we have the URL, the server is ready
|
||||
if (url) {
|
||||
clearTimeout(timer)
|
||||
resolve({
|
||||
url,
|
||||
token,
|
||||
stop: async () => {
|
||||
proc.kill('SIGTERM')
|
||||
await proc.exited
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
// @ts-expect-error — Bun Subprocess types don't narrow stdout to ReadableStream when stdout: 'pipe'
|
||||
const reader = proc.stdout.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
processLines()
|
||||
}
|
||||
} catch {
|
||||
// Stream closed
|
||||
}
|
||||
// If we get here without resolving, the process exited before printing the URL
|
||||
clearTimeout(timer)
|
||||
if (!url) {
|
||||
reject(new Error('Server process exited before printing CRAFT_SERVER_URL'))
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "bun"],
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@craft-agent/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@craft-agent/shared/*": ["../../packages/shared/src/*"],
|
||||
"@craft-agent/server-core": ["../../packages/server-core/src/index.ts"],
|
||||
"@craft-agent/server-core/*": ["../../packages/server-core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "../../packages/shared/src/**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# Build artifacts for DMG packaging
|
||||
vendor/
|
||||
packages/
|
||||
release/
|
||||
node_modules/@anthropic-ai/
|
||||
|
||||
# Platform-specific uv binaries (downloaded during build)
|
||||
resources/bin/darwin-arm64/
|
||||
resources/bin/darwin-x64/
|
||||
resources/bin/win32-x64/
|
||||
resources/bin/linux-x64/
|
||||
@@ -1,28 +0,0 @@
|
||||
# Qwen Code Electron App
|
||||
|
||||
Electron + React desktop interface for Qwen Code.
|
||||
|
||||
The desktop app provides:
|
||||
|
||||
- Qwen-backed multi-session chat
|
||||
- Workspace and source management
|
||||
- Onboarding for local Qwen Code setup
|
||||
- Permission modes and plan approval flow
|
||||
- File previews, diffs, browser panes, and automations
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run electron:start
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
src/main/ Electron main process
|
||||
src/preload/ Context bridge
|
||||
src/renderer/ React UI
|
||||
src/transport/ RPC client/server transport
|
||||
resources/ Built-in docs and release assets
|
||||
```
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- https://github.com/electron/electron-notarize#prerequisites -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- https://github.com/electron-userland/electron-builder/issues/3940 -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Voice dictation: microphone access under the hardened runtime. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/renderer/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
appId: com.alibaba.qwen-code
|
||||
productName: Qwen Code Desktop
|
||||
copyright: Copyright © 2026 Alibaba Group.
|
||||
|
||||
electronVersion: "39.2.7"
|
||||
|
||||
# Hook to compile macOS 26+ Liquid Glass icon after packaging
|
||||
afterPack: scripts/afterPack.cjs
|
||||
|
||||
directories:
|
||||
output: release
|
||||
buildResources: resources
|
||||
|
||||
files:
|
||||
- dist/**/*
|
||||
- "!dist/renderer/src/**"
|
||||
- "!**/*.map"
|
||||
- package.json
|
||||
# Include bundled MCP servers (bridge + session) for agent sessions
|
||||
- resources/bridge-mcp-server/**/*
|
||||
- resources/session-mcp-server/**/*
|
||||
# Note: Bundled assets (docs, themes, permissions, tool-icons) are in resources/
|
||||
# and copied to dist/resources/ by build:copy. They're included via dist/**/* above.
|
||||
- packages/shared/src/interceptor-common.ts
|
||||
- packages/shared/src/feature-flags.ts
|
||||
- packages/shared/src/interceptor-request-utils.ts
|
||||
# Include CLI tool Python scripts (platform-independent)
|
||||
- resources/scripts/**/*
|
||||
# Include CLI tool shell wrappers (platform-independent, both Unix and Windows)
|
||||
- resources/bin/markitdown
|
||||
- resources/bin/markitdown.cmd
|
||||
- resources/bin/pdf-tool
|
||||
- resources/bin/pdf-tool.cmd
|
||||
- resources/bin/xlsx-tool
|
||||
- resources/bin/xlsx-tool.cmd
|
||||
- resources/bin/doc-diff
|
||||
- resources/bin/doc-diff.cmd
|
||||
- resources/bin/img-tool
|
||||
- resources/bin/img-tool.cmd
|
||||
- resources/bin/docx-tool
|
||||
- resources/bin/docx-tool.cmd
|
||||
- resources/bin/pptx-tool
|
||||
- resources/bin/pptx-tool.cmd
|
||||
- resources/bin/ical-tool
|
||||
- resources/bin/ical-tool.cmd
|
||||
# Include bundled uv binary (platform-specific, only the relevant one ships per build)
|
||||
- resources/bin/darwin-arm64/**/*
|
||||
- resources/bin/darwin-x64/**/*
|
||||
- resources/bin/win32-x64/**/*
|
||||
- resources/bin/linux-x64/**/*
|
||||
# Include bundled Bun runtime (platform-specific, set by build script)
|
||||
- vendor/bun/**/*
|
||||
# Include bundled Codex binary (platform-specific, downloaded by build script)
|
||||
- vendor/codex/**/*
|
||||
# Include bundled Qwen Code CLI package (downloaded by build script)
|
||||
- vendor/qwen-code/**/*
|
||||
# Exclude everything from node_modules.
|
||||
- "!node_modules/**/*"
|
||||
|
||||
extraMetadata:
|
||||
main: dist/main.cjs
|
||||
|
||||
# Auto-update publish metadata is generated from the active brand config.
|
||||
|
||||
# Disable ASAR to avoid decompression overhead and click delays
|
||||
asar: false
|
||||
|
||||
mac:
|
||||
category: public.app-category.productivity
|
||||
icon: resources/brands/qwen-code/icon.icns
|
||||
# macOS 26+ Liquid Glass: Tell macOS to look for icon in Assets.car
|
||||
# The value must match --app-icon used in actool (see afterPack.js)
|
||||
extendInfo:
|
||||
CFBundleIconName: AppIcon
|
||||
# Voice dictation: shown in the macOS microphone permission prompt.
|
||||
NSMicrophoneUsageDescription: Qwen Code uses the microphone for voice dictation in the prompt composer.
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
entitlements: build/entitlements.mac.plist
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extraResources:
|
||||
# WhatsApp worker subprocess (self-contained; Baileys bundled in).
|
||||
- from: ../../packages/messaging-whatsapp-worker/dist/worker.cjs
|
||||
to: messaging-whatsapp-worker/worker.cjs
|
||||
# Exclude binaries for other platforms
|
||||
files:
|
||||
- "!**/vendor/codex/linux-*/**"
|
||||
- "!**/vendor/codex/win32-*/**"
|
||||
- "!**/resources/bin/win32-*/**"
|
||||
- "!**/resources/bin/linux-*/**"
|
||||
# Use predictable naming for macOS packages (applies to zip; dmg uses its own config below)
|
||||
artifactName: "Qwen-Code-Desktop-${arch}.${ext}"
|
||||
# Code signing & notarization (disabled by default for local builds)
|
||||
# To enable: set CSC_LINK, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID
|
||||
# notarize:
|
||||
# teamId: ${APPLE_TEAM_ID}
|
||||
|
||||
dmg:
|
||||
# Use predictable naming: Qwen-Code-Desktop-arm64.dmg, Qwen-Code-Desktop-x64.dmg
|
||||
artifactName: "Qwen-Code-Desktop-${arch}.dmg"
|
||||
# Custom background (multi-resolution TIFF with 1x+2x for retina support)
|
||||
background: resources/dmg-background.tiff
|
||||
# Use app icon as the mounted volume icon in Finder
|
||||
icon: resources/brands/qwen-code/icon.icns
|
||||
iconSize: 80
|
||||
title: "Qwen Code Desktop"
|
||||
contents:
|
||||
- x: 130
|
||||
y: 200
|
||||
- x: 410
|
||||
y: 200
|
||||
type: link
|
||||
path: /Applications
|
||||
window:
|
||||
width: 540
|
||||
height: 380
|
||||
|
||||
win:
|
||||
icon: resources/brands/qwen-code/icon.ico
|
||||
# Windows releases are currently unsigned, so keep this update channel unsigned
|
||||
# from the first release. Re-enable only with a planned signed-channel migration.
|
||||
verifyUpdateCodeSignature: false
|
||||
target:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
# Use predictable naming: Qwen-Code-Desktop-x64.exe
|
||||
artifactName: "Qwen-Code-Desktop-${arch}.${ext}"
|
||||
files:
|
||||
# Exclude binaries for other platforms
|
||||
- "!**/vendor/codex/darwin-*/**"
|
||||
- "!**/vendor/codex/linux-*/**"
|
||||
- "!**/resources/bin/darwin-*/**"
|
||||
- "!**/resources/bin/linux-*/**"
|
||||
# WORKAROUND: Exclude bun, codex, uv, and Qwen Code from regular files on Windows.
|
||||
# electron-builder's npm node module collector causes EBUSY errors when copying
|
||||
# files because it scans/locks files while simultaneously trying to copy them.
|
||||
# Moving them to extraResources avoids this by copying before the collector runs.
|
||||
# See: https://github.com/electron-userland/electron-builder/issues/8250
|
||||
- "!vendor/bun/**/*"
|
||||
- "!vendor/codex/**/*"
|
||||
- "!vendor/qwen-code/**/*"
|
||||
- "!**/resources/bin/win32-x64/**"
|
||||
# Copy executables as extraResources to avoid EBUSY file locking (see comment above)
|
||||
extraResources:
|
||||
- from: vendor/bun/bun.exe
|
||||
to: vendor/bun/bun.exe
|
||||
- from: vendor/codex/win32-x64
|
||||
to: app/vendor/codex/win32-x64
|
||||
- from: vendor/qwen-code
|
||||
to: app/vendor/qwen-code
|
||||
- from: resources/bin/win32-x64
|
||||
to: app/resources/bin/win32-x64
|
||||
# WhatsApp worker subprocess (self-contained; Baileys bundled in).
|
||||
- from: ../../packages/messaging-whatsapp-worker/dist/worker.cjs
|
||||
to: messaging-whatsapp-worker/worker.cjs
|
||||
|
||||
nsis:
|
||||
oneClick: true
|
||||
# Per-user install to %LOCALAPPDATA%\Programs\ (not Program Files).
|
||||
# Bun subprocess cannot read/write files in Program Files due to Windows permissions.
|
||||
perMachine: false
|
||||
deleteAppDataOnUninstall: true
|
||||
|
||||
linux:
|
||||
icon: resources/brands/qwen-code/icon.png
|
||||
category: Utility
|
||||
maintainer: "Alibaba Group"
|
||||
target:
|
||||
- target: AppImage
|
||||
arch:
|
||||
- x64
|
||||
artifactName: "Qwen-Code-Desktop-${arch}.${ext}"
|
||||
extraResources:
|
||||
# WhatsApp worker subprocess (self-contained; Baileys bundled in).
|
||||
- from: ../../packages/messaging-whatsapp-worker/dist/worker.cjs
|
||||
to: messaging-whatsapp-worker/worker.cjs
|
||||
# Exclude binaries for other platforms
|
||||
files:
|
||||
- "!**/vendor/codex/darwin-*/**"
|
||||
- "!**/vendor/codex/win32-*/**"
|
||||
- "!**/resources/bin/darwin-*/**"
|
||||
- "!**/resources/bin/win32-*/**"
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { Linter } from 'eslint'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const rule = require('../no-hardcoded-z-index.cjs')
|
||||
|
||||
function runRule(code: string) {
|
||||
const linter = new Linter({ configType: 'eslintrc' })
|
||||
linter.defineRule('craft-styles/no-hardcoded-z-index', rule)
|
||||
|
||||
return linter.verify(code, {
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'craft-styles/no-hardcoded-z-index': 'error',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('no-hardcoded-z-index (electron)', () => {
|
||||
it('flags hardcoded default in destructured props', () => {
|
||||
const messages = runRule('function Comp({ zIndex = 50 }) { return zIndex }')
|
||||
expect(messages.length).toBe(1)
|
||||
expect(messages[0]?.message).toContain('Avoid hardcoded zIndex values')
|
||||
})
|
||||
|
||||
it('flags hardcoded default in object destructuring', () => {
|
||||
const messages = runRule('const { zIndex = 50 } = props')
|
||||
expect(messages.length).toBe(1)
|
||||
})
|
||||
|
||||
it('allows token-based zIndex default values', () => {
|
||||
const messages = runRule("const { zIndex = 'var(--z-floating-menu, 400)' } = props")
|
||||
expect(messages.length).toBe(0)
|
||||
})
|
||||
|
||||
it('continues to flag hardcoded style zIndex literals', () => {
|
||||
const messages = runRule('const style = { zIndex: 400 }')
|
||||
expect(messages.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-direct-file-open
|
||||
*
|
||||
* Prevents calling window.electronAPI.openFile() directly in renderer code.
|
||||
* All file-open calls should go through the link interceptor (via AppShellContext
|
||||
* or PlatformContext's onOpenFile) so the app can show in-app previews for
|
||||
* supported file types instead of always opening in the default external app.
|
||||
*
|
||||
* Allowed in:
|
||||
* - App.tsx (link interceptor implementation)
|
||||
* - useLinkInterceptor.ts (link interceptor fallback)
|
||||
*
|
||||
* Bad:
|
||||
* window.electronAPI.openFile(path)
|
||||
*
|
||||
* Good:
|
||||
* const { onOpenFile } = useAppShellContext()
|
||||
* onOpenFile(path)
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow direct window.electronAPI.openFile() calls. Use onOpenFile from context instead.',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
noDirectFileOpen:
|
||||
'Use onOpenFile from AppShellContext or PlatformContext instead of calling window.electronAPI.openFile() directly. This ensures the link interceptor can show in-app previews for supported file types.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
// Allow direct calls in the interceptor implementation files
|
||||
const filename = context.filename || context.getFilename()
|
||||
const basename = filename.split('/').pop() || ''
|
||||
if (basename === 'App.tsx' || basename === 'useLinkInterceptor.ts') {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
// Match: window.electronAPI.openFile(...)
|
||||
CallExpression(node) {
|
||||
const callee = node.callee
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'openFile' &&
|
||||
callee.object.type === 'MemberExpression' &&
|
||||
callee.object.property.type === 'Identifier' &&
|
||||
callee.object.property.name === 'electronAPI'
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noDirectFileOpen',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-direct-navigation-state
|
||||
*
|
||||
* Prevents direct calls to navigation state setters outside of the
|
||||
* SIDEBAR_NAVIGATE_EVENT listener in AppShell.tsx. All navigation should
|
||||
* go through navigate(routes.xxx()) to ensure:
|
||||
*
|
||||
* 1. URL/deep link consistency
|
||||
* 2. History tracking for back/forward
|
||||
* 3. Auto-selection of first item in new views
|
||||
*
|
||||
* Bad (in click handlers):
|
||||
* setSidebarMode({ type: 'sources' })
|
||||
*
|
||||
* Good:
|
||||
* navigate(routes.view.sources())
|
||||
* navigate(routes.view.agent(agentId))
|
||||
*
|
||||
* Note: This rule only checks AppShell.tsx where the navigation state is defined.
|
||||
* The setSidebarMode function is not exported, so other files can't use it anyway.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get ancestor nodes for a given node in ESLint 9+
|
||||
* ESLint 9 removed context.getAncestors(), so we use sourceCode.getAncestors()
|
||||
*/
|
||||
function getAncestors(context, node) {
|
||||
const sourceCode = context.sourceCode || context.getSourceCode()
|
||||
if (sourceCode.getAncestors) {
|
||||
return sourceCode.getAncestors(node)
|
||||
}
|
||||
// Fallback for older versions
|
||||
if (context.getAncestors) {
|
||||
return context.getAncestors()
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow direct calls to setSidebarMode outside the navigation event handler.',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
noDirectCall:
|
||||
"Do not call 'setSidebarMode()' directly. Use navigate(routes.xxx()) instead to ensure URL consistency, history tracking, and auto-selection. Only the handleSidebarNavigate event listener should call setSidebarMode. See the comment at line ~595 in AppShell.tsx.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
// Get the filename - only check AppShell.tsx
|
||||
const filename = context.filename || context.getFilename()
|
||||
const isAppShell = filename.includes('AppShell.tsx')
|
||||
|
||||
// Only apply this rule to AppShell.tsx
|
||||
if (!isAppShell) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
// Only check setSidebarMode calls
|
||||
if (
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'setSidebarMode'
|
||||
) {
|
||||
const ancestors = getAncestors(context, node)
|
||||
|
||||
// Allow if inside handleSidebarNavigate
|
||||
for (const ancestor of ancestors) {
|
||||
// Check for: const handleSidebarNavigate = useCallback(...)
|
||||
if (
|
||||
ancestor.type === 'VariableDeclarator' &&
|
||||
ancestor.id.type === 'Identifier' &&
|
||||
ancestor.id.name === 'handleSidebarNavigate'
|
||||
) {
|
||||
return // Allowed - inside the event handler
|
||||
}
|
||||
// Check for: function handleSidebarNavigate(...)
|
||||
if (
|
||||
ancestor.type === 'FunctionDeclaration' &&
|
||||
ancestor.id &&
|
||||
ancestor.id.name === 'handleSidebarNavigate'
|
||||
) {
|
||||
return // Allowed
|
||||
}
|
||||
}
|
||||
|
||||
// Not inside allowed context - report error
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noDirectCall',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-direct-platform-check
|
||||
*
|
||||
* Prevents direct access to navigator.platform. Use the platform utilities instead:
|
||||
*
|
||||
* import { isMac, isWindows, isLinux, PATH_SEP } from '@/lib/platform'
|
||||
*
|
||||
* This ensures consistent platform detection across the codebase and proper
|
||||
* handling of path separators on different operating systems.
|
||||
*
|
||||
* Bad:
|
||||
* navigator.platform.toLowerCase().includes('mac')
|
||||
* navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
*
|
||||
* Good:
|
||||
* import { isMac } from '@/lib/platform'
|
||||
* if (isMac) { ... }
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow direct access to navigator.platform',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
noDirectAccess:
|
||||
"Don't access 'navigator.platform' directly. Import from '@/lib/platform' instead: { isMac, isWindows, isLinux, PATH_SEP }",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
if (
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === 'navigator' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'platform'
|
||||
) {
|
||||
// Allow in platform.ts itself (the source of truth)
|
||||
const filename = context.filename || context.getFilename()
|
||||
if (filename.includes('platform.ts')) {
|
||||
return
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noDirectAccess',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-hardcoded-path-separator
|
||||
*
|
||||
* Prevents hardcoded path separators in path comparison operations.
|
||||
* This catches patterns like `path.startsWith(dir + '/')` which fail on Windows.
|
||||
*
|
||||
* Bad:
|
||||
* filePath.startsWith(dir + '/')
|
||||
* path.startsWith(prefix + '/')
|
||||
*
|
||||
* Good:
|
||||
* import { pathStartsWith } from '@craft-agent/core/utils'
|
||||
* pathStartsWith(filePath, dir)
|
||||
*
|
||||
* Or in Node.js main process:
|
||||
* import { sep } from 'path'
|
||||
* filePath.startsWith(dir + sep)
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow hardcoded path separators in path operations',
|
||||
category: 'Cross-Platform',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
hardcodedSeparator:
|
||||
"Avoid hardcoded '/' or '\\\\' in path operations - this breaks on Windows/Unix. " +
|
||||
"Use pathStartsWith() from @craft-agent/core/utils, or path.sep in Node.js code.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
BinaryExpression(node) {
|
||||
// Detect: someVar + '/' or someVar + '\\'
|
||||
if (
|
||||
node.operator === '+' &&
|
||||
node.right.type === 'Literal' &&
|
||||
(node.right.value === '/' || node.right.value === '\\')
|
||||
) {
|
||||
// Check if parent is a path operation (startsWith, endsWith, includes)
|
||||
const parent = node.parent
|
||||
if (parent?.type === 'CallExpression') {
|
||||
const callee = parent.callee
|
||||
if (
|
||||
callee?.type === 'MemberExpression' &&
|
||||
callee.property?.type === 'Identifier' &&
|
||||
['startsWith', 'endsWith', 'includes'].includes(callee.property.name)
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'hardcodedSeparator',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-hardcoded-z-index
|
||||
*
|
||||
* Enforces the centralized z-index token system by blocking hardcoded
|
||||
* literal values in JS/TS style objects and style assignments.
|
||||
*
|
||||
* Allowed examples:
|
||||
* style={{ zIndex: 'var(--z-floating-menu, 400)' }}
|
||||
* style={{ zIndex: 'calc(var(--z-floating-menu, 400) + 1)' }}
|
||||
* style={{ zIndex: Z_FULLSCREEN }}
|
||||
* style={{ zIndex: index + 1 }}
|
||||
*
|
||||
* Disallowed examples:
|
||||
* style={{ zIndex: 400 }}
|
||||
* style={{ zIndex: '400' }}
|
||||
* el.style.zIndex = 9999
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Disallow hardcoded zIndex literals. Use centralized z-index tokens/constants.',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
noHardcodedZIndex:
|
||||
'Avoid hardcoded zIndex values. Use z-index tokens (for example var(--z-floating-menu, 400)), Tailwind z-* utilities, or a named constant.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
function isZIndexPropertyName(node) {
|
||||
if (!node) return false
|
||||
if (node.type === 'Identifier') return node.name === 'zIndex'
|
||||
if (node.type === 'Literal') return node.value === 'zIndex'
|
||||
return false
|
||||
}
|
||||
|
||||
function getStaticTemplateValue(node) {
|
||||
if (node.type !== 'TemplateLiteral') return null
|
||||
if (node.expressions.length > 0) return null
|
||||
return node.quasis.map((q) => q.value.cooked ?? '').join('')
|
||||
}
|
||||
|
||||
function isAllowedZIndexString(value) {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
// CSS variable/token usage from centralized z-scale
|
||||
if (normalized.includes('var(--z-')) return true
|
||||
|
||||
// Common CSS keywords
|
||||
if (
|
||||
normalized === 'auto' ||
|
||||
normalized === 'inherit' ||
|
||||
normalized === 'initial' ||
|
||||
normalized === 'unset' ||
|
||||
normalized === 'revert' ||
|
||||
normalized === 'revert-layer'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isHardcodedLiteralValue(node) {
|
||||
if (!node) return false
|
||||
|
||||
if (node.type === 'Literal') {
|
||||
if (typeof node.value === 'number') return true
|
||||
if (typeof node.value === 'string') return !isAllowedZIndexString(node.value)
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.type === 'TemplateLiteral') {
|
||||
const staticValue = getStaticTemplateValue(node)
|
||||
if (staticValue == null) return false
|
||||
return !isAllowedZIndexString(staticValue)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isStyleZIndexMemberExpression(node) {
|
||||
// Matches: something.style.zIndex
|
||||
return (
|
||||
node &&
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'zIndex' &&
|
||||
node.object &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property &&
|
||||
node.object.property.type === 'Identifier' &&
|
||||
node.object.property.name === 'style'
|
||||
)
|
||||
}
|
||||
|
||||
function isZIndexIdentifier(node) {
|
||||
return node && node.type === 'Identifier' && node.name === 'zIndex'
|
||||
}
|
||||
|
||||
return {
|
||||
Property(node) {
|
||||
if (!isZIndexPropertyName(node.key)) return
|
||||
if (isHardcodedLiteralValue(node.value)) {
|
||||
context.report({ node: node.value, messageId: 'noHardcodedZIndex' })
|
||||
}
|
||||
},
|
||||
|
||||
AssignmentPattern(node) {
|
||||
if (!isZIndexIdentifier(node.left)) return
|
||||
if (isHardcodedLiteralValue(node.right)) {
|
||||
context.report({ node: node.right, messageId: 'noHardcodedZIndex' })
|
||||
}
|
||||
},
|
||||
|
||||
AssignmentExpression(node) {
|
||||
if (!isStyleZIndexMemberExpression(node.left)) return
|
||||
if (isHardcodedLiteralValue(node.right)) {
|
||||
context.report({ node: node.right, messageId: 'noHardcodedZIndex' })
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-inline-source-auth-check
|
||||
*
|
||||
* Prevents inline checks of source.config.isAuthenticated.
|
||||
* Use the centralized isSourceUsable() helper instead.
|
||||
*
|
||||
* The isSourceUsable() helper correctly handles:
|
||||
* - Sources with authType: 'none' (no auth required)
|
||||
* - Sources with undefined authType (no auth required)
|
||||
* - Sources with OAuth/Bearer auth (requires isAuthenticated)
|
||||
*
|
||||
* Inline checks often miss the authType: 'none' case, causing bugs where
|
||||
* no-auth sources are incorrectly filtered out.
|
||||
*
|
||||
* Allowed in:
|
||||
* - storage.ts (where isSourceUsable is defined)
|
||||
* - credential-manager.ts (state-setting operations, inverse check)
|
||||
* - server-builder.ts (documented exceptions for OAuth providers)
|
||||
*
|
||||
* Bad:
|
||||
* source.config.isAuthenticated
|
||||
* s.config.enabled && s.config.isAuthenticated
|
||||
*
|
||||
* Good:
|
||||
* isSourceUsable(source)
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow inline source.config.isAuthenticated checks. Use isSourceUsable() from storage.ts instead.',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
useIsSourceUsable:
|
||||
'Do not check source.config.isAuthenticated directly. Use isSourceUsable() from sources/storage.ts instead. ' +
|
||||
'Direct checks often miss sources with authType: "none" which should be considered authenticated.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
// Files where direct isAuthenticated access is allowed
|
||||
const allowedFiles = [
|
||||
'storage.ts', // isSourceUsable is defined here
|
||||
'credential-manager.ts', // State-setting and inverse check
|
||||
'server-builder.ts', // OAuth provider checks (documented)
|
||||
]
|
||||
|
||||
const filename = context.filename || context.getFilename()
|
||||
const basename = filename.split('/').pop() || ''
|
||||
|
||||
// Allow in specific files
|
||||
if (allowedFiles.includes(basename)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
// Match: .config.isAuthenticated access
|
||||
MemberExpression(node) {
|
||||
// Check if property is 'isAuthenticated'
|
||||
if (
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'isAuthenticated'
|
||||
) {
|
||||
// Check if accessed via .config.isAuthenticated pattern
|
||||
if (
|
||||
node.object.type === 'MemberExpression' &&
|
||||
node.object.property.type === 'Identifier' &&
|
||||
node.object.property.name === 'config'
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'useIsSourceUsable',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-localstorage
|
||||
*
|
||||
* Warns against using localStorage in Qwen Code codebase.
|
||||
* All persistent user settings should be stored in file-based configs
|
||||
* (preferences.json, workspace configs) for consistency with Qwen Code
|
||||
* architecture principles.
|
||||
*
|
||||
* Bad:
|
||||
* localStorage.getItem('key')
|
||||
* localStorage.setItem('key', 'value')
|
||||
* window.localStorage.getItem('key')
|
||||
*
|
||||
* Good:
|
||||
* // Use IPC to read/write preferences
|
||||
* window.electronAPI.readPreferences()
|
||||
* window.electronAPI.writePreferences(content)
|
||||
*
|
||||
* Why: File-based configs are:
|
||||
* - Portable (sync via cloud services)
|
||||
* - Editable (users can manually modify)
|
||||
* - Consistent (all settings in one place)
|
||||
* - Inspectable (easy debugging)
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Disallow localStorage usage. Use file-based preferences instead.',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
noLocalStorage:
|
||||
"Avoid localStorage in Qwen Code. Store settings in ~/.craft-agent/preferences.json using window.electronAPI.readPreferences/writePreferences. See packages/shared/src/config/preferences.ts for the preferences API.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
/**
|
||||
* Check if a node is a localStorage access (localStorage or window.localStorage)
|
||||
*/
|
||||
function isLocalStorageAccess(node) {
|
||||
// Direct: localStorage.getItem
|
||||
if (node.type === 'Identifier' && node.name === 'localStorage') {
|
||||
return true
|
||||
}
|
||||
|
||||
// window.localStorage
|
||||
if (
|
||||
node.type === 'MemberExpression' &&
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === 'window' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'localStorage'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
// Catch localStorage.getItem(), localStorage.setItem(), etc.
|
||||
MemberExpression(node) {
|
||||
if (isLocalStorageAccess(node.object)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noLocalStorage',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Catch direct localStorage references (e.g., passing it as argument)
|
||||
Identifier(node) {
|
||||
if (node.name === 'localStorage') {
|
||||
// Only report if it's being used (not just referenced in a type)
|
||||
const parent = node.parent
|
||||
if (
|
||||
parent &&
|
||||
parent.type === 'MemberExpression' &&
|
||||
parent.object === node
|
||||
) {
|
||||
// Already handled by MemberExpression rule
|
||||
return
|
||||
}
|
||||
// Report standalone localStorage references
|
||||
if (parent && parent.type !== 'TSTypeReference') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noLocalStorage',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* ESLint Rule: no-nonstandard-shadows
|
||||
*
|
||||
* Enforces approved shadow usage:
|
||||
* - Allows only specific shadow-* utility classes
|
||||
* - Disallows arbitrary shadow classes (shadow-[...]) unless explicitly allowlisted
|
||||
* - Disallows inline style boxShadow values
|
||||
* - Disallows direct style assignments (el.style.boxShadow = ...)
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Allow only approved shadow utilities and block inline boxShadow usage.',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
allowedClasses: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
allowInlineNone: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
disallowedClass:
|
||||
'Disallowed shadow class "{{className}}". Use approved shadow classes only: {{allowed}}.',
|
||||
disallowedInline:
|
||||
'Avoid inline boxShadow usage. Use approved shadow utility classes (for example shadow-minimal/shadow-modal-small).',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const allowedClasses = new Set(
|
||||
options.allowedClasses || [
|
||||
'shadow-none',
|
||||
'shadow-minimal',
|
||||
'shadow-tinted',
|
||||
'shadow-thin',
|
||||
'shadow-middle',
|
||||
'shadow-strong',
|
||||
'shadow-panel-focused',
|
||||
'shadow-modal-small',
|
||||
'shadow-bottom-border',
|
||||
'shadow-bottom-border-thin',
|
||||
]
|
||||
)
|
||||
|
||||
const allowInlineNone = options.allowInlineNone !== false
|
||||
const allowedSummary = Array.from(allowedClasses).sort().join(', ')
|
||||
|
||||
function reportDisallowedClass(node, className) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'disallowedClass',
|
||||
data: {
|
||||
className,
|
||||
allowed: allowedSummary,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function checkStringForShadowTokens(node, text) {
|
||||
if (!text || !text.includes('shadow-')) return
|
||||
|
||||
const regex = /shadow-[^\s'"`]+/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const token = match[0]
|
||||
const start = match.index
|
||||
const prev = start > 0 ? text[start - 1] : ''
|
||||
|
||||
// Ignore CSS custom property names like --shadow-color, --shadow-minimal-flat
|
||||
if (prev === '-') continue
|
||||
|
||||
if (token.startsWith('shadow-[') && !allowedClasses.has(token)) {
|
||||
reportDisallowedClass(node, token)
|
||||
continue
|
||||
}
|
||||
if (!allowedClasses.has(token)) {
|
||||
reportDisallowedClass(node, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBoxShadowPropertyKey(node) {
|
||||
if (!node) return false
|
||||
if (node.type === 'Identifier') return node.name === 'boxShadow'
|
||||
if (node.type === 'Literal') return node.value === 'boxShadow'
|
||||
return false
|
||||
}
|
||||
|
||||
function isStyleBoxShadowAssignment(node) {
|
||||
return (
|
||||
node &&
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'boxShadow' &&
|
||||
node.object &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property &&
|
||||
node.object.property.type === 'Identifier' &&
|
||||
node.object.property.name === 'style'
|
||||
)
|
||||
}
|
||||
|
||||
function isNoneLiteral(node) {
|
||||
return (
|
||||
node &&
|
||||
node.type === 'Literal' &&
|
||||
typeof node.value === 'string' &&
|
||||
node.value.trim().toLowerCase() === 'none'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string') return
|
||||
checkStringForShadowTokens(node, node.value)
|
||||
},
|
||||
|
||||
TemplateLiteral(node) {
|
||||
if (node.expressions.length > 0) return
|
||||
const text = node.quasis.map((q) => q.value.cooked ?? '').join('')
|
||||
checkStringForShadowTokens(node, text)
|
||||
},
|
||||
|
||||
Property(node) {
|
||||
if (!isBoxShadowPropertyKey(node.key)) return
|
||||
if (allowInlineNone && isNoneLiteral(node.value)) return
|
||||
context.report({ node: node.value, messageId: 'disallowedInline' })
|
||||
},
|
||||
|
||||
AssignmentExpression(node) {
|
||||
if (!isStyleBoxShadowAssignment(node.left)) return
|
||||
if (allowInlineNone && isNoneLiteral(node.right)) return
|
||||
context.report({ node: node.right, messageId: 'disallowedInline' })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* ESLint Configuration for Electron App
|
||||
*
|
||||
* Uses flat config format (ESLint 9+).
|
||||
* Includes custom navigation rule to enforce navigate() usage.
|
||||
*/
|
||||
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import noDirectNavigationState from './eslint-rules/no-direct-navigation-state.cjs'
|
||||
import noLocalStorage from './eslint-rules/no-localstorage.cjs'
|
||||
import noDirectPlatformCheck from './eslint-rules/no-direct-platform-check.cjs'
|
||||
import noHardcodedPathSeparator from './eslint-rules/no-hardcoded-path-separator.cjs'
|
||||
import noDirectFileOpen from './eslint-rules/no-direct-file-open.cjs'
|
||||
import noInlineSourceAuthCheck from './eslint-rules/no-inline-source-auth-check.cjs'
|
||||
import noHardcodedZIndex from './eslint-rules/no-hardcoded-z-index.cjs'
|
||||
import noNonstandardShadows from './eslint-rules/no-nonstandard-shadows.cjs'
|
||||
|
||||
export default [
|
||||
// Ignore patterns
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'release/**',
|
||||
'*.cjs',
|
||||
'eslint-rules/**',
|
||||
],
|
||||
},
|
||||
|
||||
// TypeScript/React files
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
// Custom plugin for Qwen Code rules
|
||||
'craft-agent': {
|
||||
rules: {
|
||||
'no-direct-navigation-state': noDirectNavigationState,
|
||||
'no-localstorage': noLocalStorage,
|
||||
},
|
||||
},
|
||||
// Custom plugin for platform detection rules
|
||||
'craft-platform': {
|
||||
rules: {
|
||||
'no-direct-platform-check': noDirectPlatformCheck,
|
||||
},
|
||||
},
|
||||
// Custom plugin for cross-platform path rules
|
||||
'craft-paths': {
|
||||
rules: {
|
||||
'no-hardcoded-path-separator': noHardcodedPathSeparator,
|
||||
},
|
||||
},
|
||||
// Custom plugin for link interceptor enforcement
|
||||
'craft-links': {
|
||||
rules: {
|
||||
'no-direct-file-open': noDirectFileOpen,
|
||||
},
|
||||
},
|
||||
// Custom plugin for source auth checks (shared with packages/shared)
|
||||
'craft-sources': {
|
||||
rules: {
|
||||
'no-inline-source-auth-check': noInlineSourceAuthCheck,
|
||||
},
|
||||
},
|
||||
// Custom style rules
|
||||
'craft-styles': {
|
||||
rules: {
|
||||
'no-hardcoded-z-index': noHardcodedZIndex,
|
||||
'no-nonstandard-shadows': noNonstandardShadows,
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// React Hooks rules
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
// Custom Qwen Code rules
|
||||
'craft-agent/no-direct-navigation-state': 'error',
|
||||
'craft-agent/no-localstorage': 'warn',
|
||||
|
||||
// Custom platform detection rule
|
||||
'craft-platform/no-direct-platform-check': 'error',
|
||||
|
||||
// Custom cross-platform path rule
|
||||
'craft-paths/no-hardcoded-path-separator': 'warn',
|
||||
|
||||
// Custom link interceptor rule — prevents bypassing in-app file preview
|
||||
'craft-links/no-direct-file-open': 'error',
|
||||
|
||||
// Custom source auth check rule — use isSourceUsable() instead of inline checks
|
||||
'craft-sources/no-inline-source-auth-check': 'error',
|
||||
|
||||
// Custom style rule — use z-index token scale instead of hardcoded literals
|
||||
'craft-styles/no-hardcoded-z-index': 'error',
|
||||
|
||||
// Custom style rule — enforce approved shadow classes/tokens only
|
||||
'craft-styles/no-nonstandard-shadows': ['error', {
|
||||
allowedClasses: [
|
||||
'shadow-none',
|
||||
'shadow-xs',
|
||||
'shadow-minimal',
|
||||
'shadow-tinted',
|
||||
'shadow-thin',
|
||||
'shadow-middle',
|
||||
'shadow-strong',
|
||||
'shadow-panel-focused',
|
||||
'shadow-modal-small',
|
||||
'shadow-bottom-border',
|
||||
'shadow-bottom-border-thin',
|
||||
],
|
||||
allowInlineNone: true,
|
||||
}],
|
||||
|
||||
// Enforce centralized action registry for keyboard shortcuts
|
||||
'no-restricted-imports': ['error', {
|
||||
paths: [
|
||||
{
|
||||
name: 'react-hotkeys-hook',
|
||||
message: 'Use useAction from @/actions instead. See actions/index.ts'
|
||||
}
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
// Temporary exceptions for unresolved shadow migrations.
|
||||
{
|
||||
files: [
|
||||
'src/renderer/components/ui/sortable-list.tsx',
|
||||
'src/main/browser-pane-manager.ts',
|
||||
'src/shared/browser-live-fx.ts',
|
||||
'src/renderer/components/KeyboardShortcutsDialog.tsx',
|
||||
'src/renderer/playground/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'craft-styles/no-nonstandard-shadows': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Enforce backend abstraction boundary in Electron main process.
|
||||
{
|
||||
files: ['src/main/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
paths: [
|
||||
{ name: '@craft-agent/shared/agent/qwen-agent', message: 'Use backend factory APIs from @craft-agent/shared/agent/backend instead.' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
// Keep main model fetchers provider-agnostic (delegate to shared backend APIs only).
|
||||
{
|
||||
files: ['src/main/model-fetchers/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-syntax': ['error',
|
||||
{
|
||||
selector: "CallExpression[callee.name='fetch']",
|
||||
message: 'Do not call provider APIs directly in Electron model fetchers. Delegate to fetchBackendModels() from @craft-agent/shared/agent/backend.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"name": "@craft-agent/electron",
|
||||
"version": "0.0.5",
|
||||
"description": "Electron desktop app for Qwen Code",
|
||||
"main": "dist/main.cjs",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "Craft Docs Ltd.",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
"homepage": "https://agents.craft.do",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build:main": "source ../../.env 2>/dev/null || true; esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --alias:node-fetch=./src/main/shims/node-fetch.cjs --alias:abort-controller=./src/main/shims/abort-controller.cjs --define:process.env.GOOGLE_OAUTH_CLIENT_ID=\\\"${GOOGLE_OAUTH_CLIENT_ID:-}\\\" --define:process.env.GOOGLE_OAUTH_CLIENT_SECRET=\\\"${GOOGLE_OAUTH_CLIENT_SECRET:-}\\\" --define:process.env.SLACK_OAUTH_CLIENT_ID=\\\"${SLACK_OAUTH_CLIENT_ID:-}\\\" --define:process.env.SLACK_OAUTH_CLIENT_SECRET=\\\"${SLACK_OAUTH_CLIENT_SECRET:-}\\\" --define:process.env.MICROSOFT_OAUTH_CLIENT_ID=\\\"${MICROSOFT_OAUTH_CLIENT_ID:-}\\\"",
|
||||
"build:main:win": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --alias:node-fetch=./src/main/shims/node-fetch.cjs --alias:abort-controller=./src/main/shims/abort-controller.cjs",
|
||||
"build:preload": "esbuild src/preload/bootstrap.ts --bundle --platform=node --format=cjs --outfile=dist/bootstrap-preload.cjs --external:electron",
|
||||
"build:preload-toolbar": "esbuild src/preload/browser-toolbar.ts --bundle --platform=node --format=cjs --outfile=dist/browser-toolbar-preload.cjs --external:electron",
|
||||
"build:renderer": "vite build",
|
||||
"build:copy": "bun scripts/copy-assets.ts",
|
||||
"build:validate": "bun scripts/validate-assets.ts",
|
||||
"build": "bun run lint && bun run build:main && bun run build:preload && bun run build:preload-toolbar && bun run build:renderer && bun run build:copy && bun run build:validate",
|
||||
"build:win": "bun run build:main:win && bun run build:preload && bun run build:preload-toolbar && bun run build:renderer && bun run build:copy && bun run build:validate",
|
||||
"start": "bun run build && electron .",
|
||||
"start:win": "bun run build:win && electron .",
|
||||
"dev": "vite dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dist:mac": "bash scripts/build-dmg.sh arm64",
|
||||
"dist:mac:x64": "bash scripts/build-dmg.sh x64",
|
||||
"dist:win": "powershell -ExecutionPolicy Bypass -File scripts/build-win.ps1",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@craft-agent/core": "workspace:*",
|
||||
"@craft-agent/messaging-gateway": "workspace:*",
|
||||
"@craft-agent/server-core": "workspace:*",
|
||||
"@craft-agent/shared": "workspace:*",
|
||||
"@craft-agent/ui": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@paper-design/shaders-react": "^0.0.69",
|
||||
"@pierre/diffs": "^1.0.4",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"chrono-node": "^2.9.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.8.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jotai-family": "^1.0.1",
|
||||
"motion": "^12.23.26",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"remark": "^15.0.1",
|
||||
"sharp": "0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
"strip-markdown": "^6.0.0",
|
||||
"undici": "^6.22.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.18.1"
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
# Bundled Resources
|
||||
|
||||
This folder contains assets that are bundled with the Electron app and synced to the user's `~/.craft-agent/` directory on every launch.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Build time**: `scripts/copy-assets.ts` copies this folder to `dist/resources/`
|
||||
2. **Package time**: electron-builder includes `dist/resources/` in the app bundle
|
||||
3. **Runtime**: `getBundledAssetsDir()` resolves paths to these bundled assets
|
||||
4. **Launch**: Each asset type syncs to the user's home directory
|
||||
|
||||
## Asset Types
|
||||
|
||||
| Folder/File | Synced To | Sync Behavior |
|
||||
|-------------|-----------|---------------|
|
||||
| `docs/` | `~/.craft-agent/docs/` | Always overwrite on launch |
|
||||
| `themes/` | `~/.craft-agent/themes/` | Always overwrite on launch |
|
||||
| `permissions/` | `~/.craft-agent/permissions/` | Always overwrite on launch |
|
||||
| `tool-icons/` | `~/.craft-agent/tool-icons/` | Always overwrite on launch |
|
||||
| `config-defaults.json` | `~/.craft-agent/config-defaults.json` | Always overwrite on launch |
|
||||
|
||||
## Why Sync on Every Launch?
|
||||
|
||||
- Ensures users always have the latest defaults/docs when the app updates
|
||||
- Consistent behavior between debug and release builds
|
||||
- No stale configuration causing confusion
|
||||
|
||||
## Other Files (Not Synced)
|
||||
|
||||
These files are used by electron-builder or the app directly, not synced to user home:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `icon.*` | App icons (icns, ico, png, svg) |
|
||||
| `Assets.car` | Optional macOS compiled asset catalog |
|
||||
| `dmg-background.*` | DMG installer background |
|
||||
| `craft-logos/` | Branding assets |
|
||||
| `source.png` | Default source icon |
|
||||
| `generate-icons.sh` | Icon generation script |
|
||||
| `bridge-mcp-server/` | Bundled MCP server for API source bridge |
|
||||
| `session-mcp-server/` | Bundled MCP server for session tools |
|
||||
|
||||
## Single Source of Truth
|
||||
|
||||
The files in this folder are the **source of truth** for bundled defaults:
|
||||
- Edit `config-defaults.json` here to change default settings
|
||||
- Edit files in `docs/` to update documentation
|
||||
- Edit files in `themes/` to update bundled themes
|
||||
|
||||
There is no TypeScript fallback - if the bundled JSON file is missing, the app will fail with a clear error.
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
ENTRY="${CRAFT_COMMANDS_ENTRY:-$CRAFT_CLI_ENTRY}"
|
||||
export CRAFT_CLI_JSON_ONLY="${CRAFT_CLI_JSON_ONLY:-1}"
|
||||
exec "${CRAFT_BUN:-bun}" run "$ENTRY" "$@"
|
||||
@@ -1,7 +0,0 @@
|
||||
@echo off
|
||||
set "CRAFT_BUN_BIN=%CRAFT_BUN%"
|
||||
if "%CRAFT_BUN_BIN%"=="" set "CRAFT_BUN_BIN=bun"
|
||||
set "CRAFT_COMMANDS_BIN=%CRAFT_COMMANDS_ENTRY%"
|
||||
if "%CRAFT_COMMANDS_BIN%"=="" set "CRAFT_COMMANDS_BIN=%CRAFT_CLI_ENTRY%"
|
||||
if "%CRAFT_CLI_JSON_ONLY%"=="" set "CRAFT_CLI_JSON_ONLY=1"
|
||||
"%CRAFT_BUN_BIN%" run "%CRAFT_COMMANDS_BIN%" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/doc_diff.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\doc_diff.py" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/docx_tool.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\docx_tool.py" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/ical_tool.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\ical_tool.py" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/img_tool.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\img_tool.py" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/markitdown_cli.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\markitdown_cli.py" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/pdf_tool.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\pdf_tool.py" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/pptx_tool.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\pptx_tool.py" %*
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec "$CRAFT_UV" run --python 3.12 "$CRAFT_SCRIPTS/xlsx_tool.py" "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
@echo off
|
||||
"%CRAFT_UV%" run --python 3.12 "%CRAFT_SCRIPTS%\xlsx_tool.py" %*
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 439 B |
|
Before Width: | Height: | Size: 805 B |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 805 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB |