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.
This commit is contained in:
易良 authored and GitHub committed 2026-08-25 05:26:11 +00:00
1 parent 9416d6a6ad
commit ce72ddbe6c
1723 files changed
+139 -383835

No files matched your search

-3
View File
@@ -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
-4
View File
@@ -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'
-363
View File
@@ -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 }}'
-148
View File
@@ -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"
-222
View File
@@ -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"'
-1
View File
@@ -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.
-102
View File
@@ -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.
-2
View File
@@ -88,5 +88,3 @@ ignore:
- 'vendor/'
- 'node_modules/'
- 'integration-tests/terminal-bench/'
- 'packages/desktop/.github/'
- 'packages/desktop/apps/electron/electron-builder.yml'
+17 -17
View File
@@ -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
-1
View File
@@ -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
+1 -1
View File
@@ -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
-1
View File
@@ -21,7 +21,6 @@
"packages/channels/gitlab",
"packages/channels/plugin-example",
"integrations/external-context",
"!packages/desktop",
"!packages/desktop-shell"
],
"dependencies": {
-3
View File
@@ -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],
+2 -4
View File
@@ -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()
-50
View File
@@ -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
-123
View File
@@ -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.
-592
View File
@@ -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"
-75
View File
@@ -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
View File
@@ -1 +0,0 @@
22
-28
View File
@@ -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/
-103
View File
@@ -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.
-191
View File
@@ -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.
-23
View File
@@ -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.
-189
View File
@@ -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.
-58
View File
@@ -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).
-103
View File
@@ -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._
-25
View File
@@ -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
}
}
-239
View File
@@ -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)
})
})
File diff suppressed because it is too large. Load diff
-427
View File
@@ -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'))
}
})()
})
}
-30
View File
@@ -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"]
}
-11
View File
@@ -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/
-28
View File
@@ -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" %*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Loaded 100 of 1723 files, more files were not shown because too many files have changed in this diff. Show more