From 2ca9f92d4e0901abc85a1e18068e547fae9e3640 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 6 May 2026 08:59:48 +0700 Subject: [PATCH 1/2] xx --- src/components/content/SkillsHub.vue | 110 --- src/composables/useGithubSkillsSync.ts | 246 ------ src/composables/useUiLanguage.ts | 18 - src/server/codexAppServerBridge.ts | 3 +- src/server/skillsRoutes.ts | 990 +------------------------ src/style.css | 33 - tests.md | 293 +------- 7 files changed, 30 insertions(+), 1663 deletions(-) delete mode 100644 src/composables/useGithubSkillsSync.ts diff --git a/src/components/content/SkillsHub.vue b/src/components/content/SkillsHub.vue index 4c55c353b..bb5a89578 100644 --- a/src/components/content/SkillsHub.vue +++ b/src/components/content/SkillsHub.vue @@ -4,50 +4,6 @@

{{ t('Skills Hub') }}

{{ t('Manage installed skills on this machine') }}

- -
-
- {{ t('Skills Sync (GitHub)') }} - - {{ t('Connected') }}: {{ syncStatus.repoOwner }}/{{ syncStatus.repoName }} - - {{ t('Logged in as') }} {{ syncStatus.githubUsername }} - {{ t('Not connected') }} -
-
- {{ t('Startup') }}: {{ syncStatus.startup.mode }} - {{ t('Branch') }}: {{ syncStatus.startup.branch }} - {{ t('Action') }}: {{ syncStatus.startup.lastAction }} -
-
- {{ syncStatus.startup.lastError }} -
-
- {{ t('Manual sync') }}: {{ syncActionStatus }} -
-
- {{ syncActionError }} -
-
- {{ t('Open') }} {{ t('GitHub device login') }} {{ t('and enter code:') }} - {{ deviceLogin.user_code }} -
-
- - - - - - -
-
-
{{ toast.text }}
@@ -141,7 +97,6 @@ import { computed, onMounted, ref } from 'vue' import IconTablerChevronRight from '../icons/IconTablerChevronRight.vue' import SkillCard from './SkillCard.vue' import SkillDetailModal, { type HubSkill } from './SkillDetailModal.vue' -import { useGithubSkillsSync } from '../../composables/useGithubSkillsSync' import { useUiLanguage } from '../../composables/useUiLanguage' const EMPTY_SKILL: HubSkill = { name: '', owner: '', description: '', url: '', installed: false } @@ -183,13 +138,6 @@ const isDetailInstalling = computed(() => const isDetailUninstalling = computed(() => isUninstallActionInFlight.value && actionSkillKey.value === currentDetailSkillKey.value, ) -const githubRepoUrl = computed(() => { - if (!syncStatus.value.configured) return '' - const owner = syncStatus.value.repoOwner.trim() - const repo = syncStatus.value.repoName.trim() - if (!owner || !repo) return '' - return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` -}) const filteredInstalled = computed(() => installedSkills.value) function showToast(text: string, type: 'success' | 'error' = 'success'): void { @@ -332,7 +280,6 @@ async function handleToggleEnabled(skill: HubSkill, enabled: boolean): Promise { - await fetchSkills() - emit('skills-changed') - }, -}) - onMounted(() => { void fetchSkills() - void loadSyncStatus() }) @@ -409,38 +331,6 @@ onMounted(() => { @apply shrink-0 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs font-medium text-zinc-600 transition hover:bg-zinc-50 hover:border-zinc-300 cursor-pointer; } -.skills-sync-panel { - @apply rounded-xl border border-zinc-200 bg-zinc-50 p-3 flex flex-col gap-2; -} - -.skills-sync-header { - @apply flex flex-wrap items-center gap-2 text-sm text-zinc-700; -} - -.skills-sync-badge { - @apply text-xs rounded-md border border-zinc-300 bg-white px-2 py-0.5; -} - -.skills-sync-badge-link { - @apply text-zinc-700 hover:text-zinc-900 hover:border-zinc-400; -} - -.skills-sync-device { - @apply text-xs text-zinc-600 flex items-center gap-2 flex-wrap; -} - -.skills-sync-meta { - @apply text-xs text-zinc-600 flex items-center gap-3 flex-wrap; -} - -.skills-sync-error { - @apply text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md px-2 py-1; -} - -.skills-sync-actions { - @apply flex flex-wrap gap-2; -} - .skills-search-panel { @apply rounded-xl border border-zinc-200 bg-white p-3 flex flex-col gap-2; } diff --git a/src/composables/useGithubSkillsSync.ts b/src/composables/useGithubSkillsSync.ts deleted file mode 100644 index 64d3946df..000000000 --- a/src/composables/useGithubSkillsSync.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { computed, ref } from 'vue' - -type ToastType = 'success' | 'error' - -type SyncStartupStatus = { - inProgress: boolean - mode: string - branch: string - lastAction: string - lastRunAtIso: string - lastSuccessAtIso: string - lastError: string -} - -export type SkillsSyncStatus = { - loggedIn: boolean - githubUsername: string - repoOwner: string - repoName: string - configured: boolean - startup: SyncStartupStatus -} - -type UseGithubSkillsSyncOptions = { - showToast: (text: string, type?: ToastType) => void - onPulled: () => Promise -} - -const firebaseConfig = { - apiKey: 'AIzaSyAf0CIHBZ-wEQJ8CCUUWo1Wl9P7typ_ZPI', - authDomain: 'gptcall-416910.firebaseapp.com', - projectId: 'gptcall-416910', - storageBucket: 'gptcall-416910.appspot.com', - messagingSenderId: '99275526699', - appId: '1:99275526699:web:3b623e1e2996108b52106e', -} - -let firebaseGithubAuthLoader: - Promise<[typeof import('firebase/app'), typeof import('firebase/auth')]> | null = null - -function loadFirebaseGithubAuth() { - if (!firebaseGithubAuthLoader) { - firebaseGithubAuthLoader = Promise.all([ - import('firebase/app'), - import('firebase/auth'), - ]) - } - return firebaseGithubAuthLoader -} - -export function useGithubSkillsSync(options: UseGithubSkillsSyncOptions) { - const deviceLogin = ref<{ device_code: string; user_code: string; verification_uri: string } | null>(null) - const syncActionStatus = ref('') - const syncActionError = ref('') - const syncActionInFlight = ref<'pull' | 'push' | 'startup-sync' | ''>('') - const syncStatus = ref({ - loggedIn: false, - githubUsername: '', - repoOwner: '', - repoName: '', - configured: false, - startup: { - inProgress: false, - mode: 'idle', - branch: 'main', - lastAction: 'not-started', - lastRunAtIso: '', - lastSuccessAtIso: '', - lastError: '', - }, - }) - - const isPullInFlight = computed(() => syncActionInFlight.value === 'pull') - const isPushInFlight = computed(() => syncActionInFlight.value === 'push') - const isStartupSyncInFlight = computed(() => syncActionInFlight.value === 'startup-sync') - const isSyncActionInFlight = computed(() => syncActionInFlight.value !== '') - - async function loadSyncStatus(): Promise { - try { - const resp = await fetch('/codex-api/skills-sync/status') - if (!resp.ok) return - const payload = (await resp.json()) as { data?: SkillsSyncStatus } - if (payload.data) syncStatus.value = payload.data - } catch { - // best effort - } - } - - async function startGithubLogin(): Promise { - try { - const startResp = await fetch('/codex-api/skills-sync/github/start-login', { method: 'POST' }) - const startData = (await startResp.json()) as { data?: { device_code: string; user_code: string; verification_uri: string; interval?: number } } - if (!startResp.ok || !startData.data) throw new Error('Failed to start GitHub login') - deviceLogin.value = startData.data - const maxAttempts = 30 - const waitMs = Math.max((startData.data.interval ?? 5) * 1000, 3000) - let loggedIn = false - for (let i = 0; i < maxAttempts; i++) { - await new Promise((resolve) => setTimeout(resolve, waitMs)) - const completeResp = await fetch('/codex-api/skills-sync/github/complete-login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deviceCode: startData.data.device_code }), - }) - const completeData = (await completeResp.json()) as { ok?: boolean; pending?: boolean; error?: string } - if (!completeResp.ok) throw new Error(completeData.error || 'Failed to complete GitHub login') - if (completeData.ok) { - loggedIn = true - break - } - if (!completeData.pending) throw new Error(completeData.error || 'Failed to complete GitHub login') - } - if (!loggedIn) throw new Error('GitHub login timed out. Please retry.') - deviceLogin.value = null - await loadSyncStatus() - options.showToast('GitHub login successful') - } catch (e) { - options.showToast(e instanceof Error ? e.message : 'Failed GitHub login', 'error') - } - } - - async function startGithubFirebaseLogin(): Promise { - try { - const [firebaseApp, firebaseAuth] = await loadFirebaseGithubAuth() - const { getApp, getApps, initializeApp } = firebaseApp - const { getAuth, GithubAuthProvider, signInWithPopup } = firebaseAuth - const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig) - const auth = getAuth(app) - const provider = new GithubAuthProvider() - provider.addScope('repo') - const result = await signInWithPopup(auth, provider) - const credential = GithubAuthProvider.credentialFromResult(result) - const token = credential?.accessToken ?? '' - if (!token) { - throw new Error('GitHub access token missing from Firebase login') - } - const resp = await fetch('/codex-api/skills-sync/github/token-login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }) - const data = (await resp.json()) as { ok?: boolean; error?: string } - if (!resp.ok || !data.ok) { - throw new Error(data.error || 'Failed to login with GitHub token') - } - await loadSyncStatus() - options.showToast('GitHub login successful') - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed Firebase GitHub login' - options.showToast(message, 'error') - } - } - - async function pullSkillsSync(): Promise { - syncActionError.value = '' - syncActionStatus.value = 'pull-started' - syncActionInFlight.value = 'pull' - try { - const resp = await fetch('/codex-api/skills-sync/pull', { method: 'POST' }) - const data = (await resp.json()) as { ok?: boolean; error?: string } - if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to pull synced skills') - await options.onPulled() - syncActionStatus.value = 'pull-success' - options.showToast(syncStatus.value.loggedIn ? 'Pulled skills from private sync repo' : 'Pulled skills from upstream repo') - } catch (e) { - const message = e instanceof Error ? e.message : 'Failed to pull sync' - syncActionError.value = message - syncActionStatus.value = 'pull-failed' - options.showToast(message, 'error') - } finally { - syncActionInFlight.value = '' - } - } - - async function pushSkillsSync(): Promise { - syncActionError.value = '' - syncActionStatus.value = 'push-started' - syncActionInFlight.value = 'push' - try { - const resp = await fetch('/codex-api/skills-sync/push', { method: 'POST' }) - const data = (await resp.json()) as { ok?: boolean; error?: string } - if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to push synced skills') - syncActionStatus.value = 'push-success' - options.showToast('Pushed skills to private sync repo') - } catch (e) { - const message = e instanceof Error ? e.message : 'Failed to push sync' - syncActionError.value = message - syncActionStatus.value = 'push-failed' - options.showToast(message, 'error') - } finally { - syncActionInFlight.value = '' - } - } - - async function startupSkillsSync(): Promise { - syncActionError.value = '' - syncActionStatus.value = 'startup-sync-started' - syncActionInFlight.value = 'startup-sync' - try { - const resp = await fetch('/codex-api/skills-sync/startup-sync', { method: 'POST' }) - const data = (await resp.json()) as { ok?: boolean; error?: string } - if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to run startup sync') - await options.onPulled() - await loadSyncStatus() - syncActionStatus.value = 'startup-sync-success' - options.showToast('Startup sync completed') - } catch (e) { - const message = e instanceof Error ? e.message : 'Failed startup sync' - syncActionError.value = message - syncActionStatus.value = 'startup-sync-failed' - options.showToast(message, 'error') - } finally { - syncActionInFlight.value = '' - } - } - - async function logoutGithub(): Promise { - try { - const resp = await fetch('/codex-api/skills-sync/github/logout', { method: 'POST' }) - const data = (await resp.json()) as { ok?: boolean; error?: string } - if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to logout GitHub') - await loadSyncStatus() - options.showToast('Logged out from GitHub') - } catch (e) { - options.showToast(e instanceof Error ? e.message : 'Failed to logout GitHub', 'error') - } - } - - return { - deviceLogin, - isPullInFlight, - isPushInFlight, - isStartupSyncInFlight, - isSyncActionInFlight, - loadSyncStatus, - logoutGithub, - pullSkillsSync, - pushSkillsSync, - startupSkillsSync, - startGithubFirebaseLogin, - startGithubLogin, - syncActionError, - syncActionStatus, - syncStatus, - } -} diff --git a/src/composables/useUiLanguage.ts b/src/composables/useUiLanguage.ts index 606ab8c5e..921491b19 100644 --- a/src/composables/useUiLanguage.ts +++ b/src/composables/useUiLanguage.ts @@ -253,24 +253,6 @@ const zhCN: Record = { 'Hide all': '隐藏全部', 'No types yet': '还没有类型', 'Manage installed skills on this machine': '管理此机器上已安装的技能', - 'Skills Sync (GitHub)': '技能同步(GitHub)', - 'Connected': '已连接', - 'Logged in as': '已登录为', - 'Not connected': '未连接', - 'Startup': '启动', - 'Action': '动作', - 'Manual sync': '手动同步', - 'GitHub device login': 'GitHub 设备登录', - 'and enter code:': '并输入代码:', - 'Login with GitHub': '使用 GitHub 登录', - 'Device Login': '设备登录', - 'Logout GitHub': '退出 GitHub', - 'Syncing...': '同步中...', - 'Startup Sync': '启动同步', - 'Pulling...': '拉取中...', - 'Pull': '拉取', - 'Pushing...': '推送中...', - 'Push': '推送', 'Installed ({count})': '已安装({count})', 'Find skills': '查找技能', 'Search the Skills registry with npx skills find.': '使用 npx skills find 搜索 Skills 注册表。', diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 1304d271a..c59264cc8 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -13,7 +13,7 @@ import { writeFile } from 'node:fs/promises' import { handleAccountRoutes } from './accountRoutes.js' import { buildAppServerArgs } from './appServerRuntimeConfig.js' import { handleReviewRoutes } from './reviewGit.js' -import { handleSkillsRoutes, initializeSkillsSyncOnStartup } from './skillsRoutes.js' +import { handleSkillsRoutes } from './skillsRoutes.js' import { TelegramThreadBridge } from './telegramThreadBridge.js' import { getRandomFreeKey, @@ -4839,7 +4839,6 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } return threadSearchIndexPromise } - void initializeSkillsSyncOnStartup(appServer) void readTelegramBridgeConfig() .then((config) => { if (!config.botToken) return diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index 6e9dc476b..adc74dfd7 100644 --- a/src/server/skillsRoutes.ts +++ b/src/server/skillsRoutes.ts @@ -1,10 +1,8 @@ import { spawn } from 'node:child_process' -import { mkdtemp, readFile, readdir, rm, mkdir, stat, lstat, readlink, symlink } from 'node:fs/promises' -import { existsSync } from 'node:fs' +import { readFile, readdir, rm, stat } from 'node:fs/promises' import type { IncomingMessage, ServerResponse } from 'node:http' -import { homedir, tmpdir } from 'node:os' +import { homedir } from 'node:os' import { join } from 'node:path' -import { writeFile } from 'node:fs/promises' import { resolvePythonCommand, resolveSkillInstallerScriptPath } from '../commandResolution.js' import { getSpawnInvocation } from '../utils/commandInvocation.js' @@ -210,23 +208,6 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }) } -async function detectUserSkillsDir(appServer: AppServerLike): Promise { - try { - const result = (await appServer.rpc('skills/list', {})) as { - data?: Array<{ skills?: Array<{ scope?: string; path?: string }> }> - } - for (const entry of result.data ?? []) { - for (const skill of entry.skills ?? []) { - if (skill.scope !== 'user' || !skill.path) continue - const skillInfo = deriveSkillPathInfo(skill.path) - if (!skillInfo) continue - return skillInfo.installDir - } - } - } catch {} - return getSkillsInstallDir() -} - async function ensureInstalledSkillIsValid(appServer: AppServerLike, skillPath: string): Promise { const result = (await appServer.rpc('skills/list', { forceReload: true })) as { data?: Array<{ errors?: Array<{ path?: string; message?: string }> }> @@ -256,20 +237,6 @@ type SkillHubEntry = { installCountLabel?: string } -async function runGitFetchWithRefLockRetry(repoDir: string, args: string[] = ['fetch', 'origin']): Promise { - try { - await runCommand('git', args, { cwd: repoDir }) - } catch (error) { - const message = getErrorMessage(error, '') - if (!message.includes("cannot lock ref 'refs/remotes/origin/")) throw error - const branchMatch = message.match(/refs\/remotes\/origin\/([^\s':]+)/) - if (!branchMatch?.[1]) throw error - const refPath = join(repoDir, '.git', 'refs', 'remotes', 'origin', branchMatch[1]) - try { await rm(refPath, { force: true }) } catch {} - await runCommand('git', args, { cwd: repoDir }) - } -} - async function buildLocalHubEntry(info: InstalledSkillInfo): Promise { let description = '' if (info.path) { @@ -514,60 +481,6 @@ function groupRpcSkillRecords(skills: T[]): T[] { } type InstalledSkillInfo = { name: string; path: string; enabled: boolean } -type SyncedSkill = { owner?: string; name: string; enabled: boolean } - -type SkillsSyncState = { - githubToken?: string - githubUsername?: string - repoOwner?: string - repoName?: string - installedOwners?: Record - lastPullCommitSha?: string - lastPushCommitSha?: string - lastSyncAttemptCount?: number - lastSyncError?: string - lastSyncAtIso?: string -} - -type GithubDeviceCodeResponse = { - device_code: string - user_code: string - verification_uri: string - expires_in: number - interval: number -} - -type GithubTokenResponse = { access_token?: string; error?: string } - -const GITHUB_DEVICE_CLIENT_ID = 'Iv1.b507a08c87ecfe98' -const DEFAULT_SKILLS_SYNC_REPO_NAME = 'codexskills' -const SKILLS_SYNC_MANIFEST_PATH = 'installed-skills.json' -const SYNC_UPSTREAM_SKILLS_OWNER = 'OpenClawAndroid' -const SYNC_UPSTREAM_SKILLS_REPO = 'skills' -const PRIVATE_SYNC_BRANCH = 'main' -const PUBLIC_UPSTREAM_BRANCH_ANDROID = 'android' -const PUBLIC_UPSTREAM_BRANCH_DEFAULT = 'main' -let startupSkillsSyncInitialized = false - -type StartupSyncStatus = { - inProgress: boolean - mode: 'unauthenticated-bootstrap' | 'authenticated-fork-sync' | 'idle' - branch: string - lastAction: string - lastRunAtIso: string - lastSuccessAtIso: string - lastError: string -} - -const startupSyncStatus: StartupSyncStatus = { - inProgress: false, - mode: 'idle', - branch: PRIVATE_SYNC_BRANCH, - lastAction: 'not-started', - lastRunAtIso: '', - lastSuccessAtIso: '', - lastError: '', -} async function scanInstalledSkillsFromDisk(): Promise> { const map = new Map() @@ -638,713 +551,6 @@ function extractSkillDescriptionFromMarkdown(markdown: string): string { return '' } -function getSkillsSyncStatePath(): string { - return join(getCodexHomeDir(), 'skills-sync.json') -} - -async function readSkillsSyncState(): Promise { - try { - const raw = await readFile(getSkillsSyncStatePath(), 'utf8') - const parsed = JSON.parse(raw) as SkillsSyncState - return parsed && typeof parsed === 'object' ? parsed : {} - } catch { - return {} - } -} - -async function writeSkillsSyncState(state: SkillsSyncState): Promise { - await writeFile(getSkillsSyncStatePath(), JSON.stringify(state), 'utf8') -} - -async function getGithubJson(url: string, token: string, method = 'GET', body?: unknown): Promise { - const resp = await fetch(url, { - method, - headers: { - Accept: 'application/vnd.github+json', - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'codex-web-local', - }, - body: body ? JSON.stringify(body) : undefined, - }) - if (!resp.ok) { - const text = await resp.text() - throw new Error(`GitHub API ${method} ${url} failed (${resp.status}): ${text}`) - } - return await resp.json() as T -} - -async function startGithubDeviceLogin(): Promise { - const resp = await fetch('https://github.com/login/device/code', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'codex-web-local', - }, - body: new URLSearchParams({ - client_id: GITHUB_DEVICE_CLIENT_ID, - scope: 'repo read:user', - }), - }) - if (!resp.ok) { - throw new Error(`GitHub device flow init failed (${resp.status})`) - } - return await resp.json() as GithubDeviceCodeResponse -} - -async function completeGithubDeviceLogin(deviceCode: string): Promise<{ token: string | null; error: string | null }> { - const resp = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'codex-web-local', - }, - body: new URLSearchParams({ - client_id: GITHUB_DEVICE_CLIENT_ID, - device_code: deviceCode, - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - }), - }) - if (!resp.ok) { - throw new Error(`GitHub token exchange failed (${resp.status})`) - } - const payload = await resp.json() as GithubTokenResponse - if (!payload.access_token) return { token: null, error: payload.error || 'unknown_error' } - return { token: payload.access_token, error: null } -} - -function isAndroidLikeRuntime(): boolean { - if (process.platform === 'android') return true - if (existsSync('/data/data/com.termux')) return true - if (process.env.TERMUX_VERSION) return true - const prefix = process.env.PREFIX?.toLowerCase() ?? '' - if (prefix.includes('/com.termux/')) return true - const proot = process.env.PROOT_TMP_DIR?.toLowerCase() ?? '' - return proot.length > 0 -} - -function getPreferredPublicUpstreamBranch(): string { - return isAndroidLikeRuntime() ? PUBLIC_UPSTREAM_BRANCH_ANDROID : PUBLIC_UPSTREAM_BRANCH_DEFAULT -} - -function isUpstreamSkillsRepo(repoOwner: string, repoName: string): boolean { - return repoOwner.toLowerCase() === SYNC_UPSTREAM_SKILLS_OWNER.toLowerCase() - && repoName.toLowerCase() === SYNC_UPSTREAM_SKILLS_REPO.toLowerCase() -} - -async function resolveGithubUsername(token: string): Promise { - const user = await getGithubJson<{ login: string }>('https://api.github.com/user', token) - return user.login -} - -async function ensurePrivateForkFromUpstream(token: string, username: string, repoName: string): Promise { - const repoUrl = `https://api.github.com/repos/${username}/${repoName}` - let created = false - const existing = await fetch(repoUrl, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'codex-web-local', - }, - }) - if (existing.ok) { - const details = await existing.json() as { private?: boolean } - if (details.private === true) return - await getGithubJson(repoUrl, token, 'PATCH', { private: true }) - return - } - if (existing.status !== 404) { - throw new Error(`Failed to check personal repo existence (${existing.status})`) - } - - await getGithubJson( - 'https://api.github.com/user/repos', - token, - 'POST', - { name: repoName, private: true, auto_init: false, description: 'Codex skills private mirror sync' }, - ) - created = true - - let ready = false - for (let i = 0; i < 20; i++) { - const check = await fetch(repoUrl, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'codex-web-local', - }, - }) - if (check.ok) { - ready = true - break - } - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - if (!ready) throw new Error('Private mirror repo was created but is not available yet') - if (!created) return - - const tmp = await mkdtemp(join(tmpdir(), 'codex-skills-seed-')) - try { - const upstreamUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git` - const branch = PRIVATE_SYNC_BRANCH - try { - await runCommand('git', ['clone', '--depth', '1', '--single-branch', '--branch', branch, upstreamUrl, tmp]) - } catch { - await runCommand('git', ['clone', '--depth', '1', upstreamUrl, tmp]) - } - const privateRemote = toGitHubTokenRemote(username, repoName, token) - await runCommand('git', ['remote', 'set-url', 'origin', privateRemote], { cwd: tmp }) - try { await runCommand('git', ['checkout', '-B', branch], { cwd: tmp }) } catch {} - await runCommand('git', ['push', '-u', 'origin', `HEAD:${branch}`], { cwd: tmp }) - } finally { - await rm(tmp, { recursive: true, force: true }) - } -} - -async function readRemoteSkillsManifest(token: string, repoOwner: string, repoName: string): Promise { - const url = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${SKILLS_SYNC_MANIFEST_PATH}` - const resp = await fetch(url, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'codex-web-local', - }, - }) - if (resp.status === 404) return [] - if (!resp.ok) throw new Error(`Failed to read remote manifest (${resp.status})`) - const payload = await resp.json() as { content?: string } - const content = payload.content ? Buffer.from(payload.content.replace(/\n/g, ''), 'base64').toString('utf8') : '[]' - const parsed = JSON.parse(content) as unknown - if (!Array.isArray(parsed)) return [] - const skills: SyncedSkill[] = [] - for (const row of parsed) { - const item = asRecord(row) - const owner = typeof item?.owner === 'string' ? item.owner : '' - const name = typeof item?.name === 'string' ? item.name : '' - if (!name) continue - skills.push({ ...(owner ? { owner } : {}), name, enabled: item?.enabled !== false }) - } - return skills -} - -async function writeRemoteSkillsManifest(token: string, repoOwner: string, repoName: string, skills: SyncedSkill[]): Promise { - const url = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${SKILLS_SYNC_MANIFEST_PATH}` - let sha = '' - const nextContent = JSON.stringify(skills, null, 2) - const existing = await fetch(url, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'codex-web-local', - }, - }) - if (existing.ok) { - const payload = await existing.json() as { sha?: string; content?: string } - sha = payload.sha ?? '' - const currentContent = payload.content ? Buffer.from(payload.content.replace(/\n/g, ''), 'base64').toString('utf8') : '' - if (currentContent === nextContent) return false - } - const content = Buffer.from(nextContent, 'utf8').toString('base64') - await getGithubJson(url, token, 'PUT', { - message: 'Update synced skills manifest', - content, - ...(sha ? { sha } : {}), - }) - return true -} - -function toGitHubTokenRemote(repoOwner: string, repoName: string, token: string): string { - return `https://x-access-token:${encodeURIComponent(token)}@github.com/${repoOwner}/${repoName}.git` -} - -async function ensureSkillsWorkingTreeRepo(repoUrl: string, branch: string): Promise { - const localDir = getSkillsInstallDir() - await mkdir(localDir, { recursive: true }) - const gitDir = join(localDir, '.git') - let hasGitDir = false - try { hasGitDir = (await stat(gitDir)).isDirectory() } catch { hasGitDir = false } - - if (!hasGitDir) { - await runCommand('git', ['init'], { cwd: localDir }) - await runCommand('git', ['config', 'user.email', 'skills-sync@local'], { cwd: localDir }) - await runCommand('git', ['config', 'user.name', 'Skills Sync'], { cwd: localDir }) - await runCommand('git', ['add', '-A'], { cwd: localDir }) - try { await runCommand('git', ['commit', '-m', 'Local skills snapshot before sync'], { cwd: localDir }) } catch {} - await runCommand('git', ['branch', '-M', branch], { cwd: localDir }) - try { await runCommand('git', ['remote', 'add', 'origin', repoUrl], { cwd: localDir }) } catch { - await runCommand('git', ['remote', 'set-url', 'origin', repoUrl], { cwd: localDir }) - } - await runGitFetchWithRefLockRetry(localDir) - try { - await runCommand('git', ['merge', '--allow-unrelated-histories', '--no-edit', `origin/${branch}`], { cwd: localDir }) - } catch {} - return localDir - } - - await runCommand('git', ['remote', 'set-url', 'origin', repoUrl], { cwd: localDir }) - await runGitFetchWithRefLockRetry(localDir) - const hasLocalChangesBeforeSync = await hasLocalUncommittedChanges(localDir) - const localMtimesBeforeSync = hasLocalChangesBeforeSync ? await snapshotFileMtimes(localDir) : new Map() - await resolveMergeConflictsByNewerCommit(localDir, branch, localMtimesBeforeSync) - try { - await runCommand('git', ['checkout', branch], { cwd: localDir }) - } catch { - await resolveMergeConflictsByNewerCommit(localDir, branch, localMtimesBeforeSync) - await runCommand('git', ['checkout', '-B', branch], { cwd: localDir }) - } - await resolveMergeConflictsByNewerCommit(localDir, branch, localMtimesBeforeSync) - const hasLocalChangesBeforePull = await hasLocalUncommittedChanges(localDir) - const localMtimesBeforePull = hasLocalChangesBeforePull ? await snapshotFileMtimes(localDir) : new Map() - let createdAutostash = false - try { - const stashOutput = await runCommandWithOutput('git', ['stash', 'push', '--include-untracked', '-m', 'codex-skills-autostash'], { cwd: localDir }) - createdAutostash = !stashOutput.includes('No local changes to save') - } catch {} - let pulledMtimes = new Map() - await runGitFetchWithRefLockRetry(localDir, ['fetch', 'origin', branch]) - await runCommand('git', ['reset', '--hard', `origin/${branch}`], { cwd: localDir }) - pulledMtimes = await snapshotFileMtimes(localDir) - if (createdAutostash) { - try { - await runCommand('git', ['stash', 'pop'], { cwd: localDir }) - } catch { - await resolveStashPopConflictsByFileTime(localDir, localMtimesBeforePull, pulledMtimes) - } - } - return localDir -} - -async function resolveMergeConflictsByNewerCommit( - repoDir: string, - branch: string, - localMtimesBeforeSync: Map = new Map(), -): Promise { - // Keep resolving until merge/rebase no longer reports unmerged paths. - for (let i = 0; i < 20; i++) { - const unmerged = (await runCommandWithOutput('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: repoDir })) - .split(/\r?\n/) - .map((row) => row.trim()) - .filter(Boolean) - if (unmerged.length === 0) return - for (const path of unmerged) { - const localMtimeMs = localMtimesBeforeSync.get(path) ?? 0 - const localMtimeSec = Math.floor(localMtimeMs / 1000) - const remoteCommitTime = await getCommitTime(repoDir, `origin/${branch}`, path) - if (remoteCommitTime > localMtimeSec) { - await checkoutConflictSideWithFallback(repoDir, path, '--theirs') - } else { - await checkoutConflictSideWithFallback(repoDir, path, '--ours') - } - await runCommand('git', ['add', '--', path], { cwd: repoDir }) - } - const rebaseHead = await readOptionalGitRef(repoDir, 'REBASE_HEAD') - if (rebaseHead) { - try { - await runCommand('git', ['rebase', '--continue'], { cwd: repoDir }) - continue - } catch { - // Continue loop and resolve next rebase-conflict batch. - continue - } - } - const mergeHead = await readOptionalGitRef(repoDir, 'MERGE_HEAD') - if (mergeHead) { - await runCommand('git', ['commit', '-m', 'Auto-resolve skills merge by mtime policy'], { cwd: repoDir }) - continue - } - } - throw new Error('Auto-resolve exceeded retry limit while reconciling sync conflicts') -} - -async function readOptionalGitRef(repoDir: string, ref: string): Promise { - try { - return (await runCommandWithOutput('git', ['rev-parse', '-q', '--verify', ref], { cwd: repoDir })).trim() - } catch { - return '' - } -} - -async function listUnmergedStages(repoDir: string, path: string): Promise> { - const raw = (await runCommandWithOutput('git', ['ls-files', '-u', '--', path], { cwd: repoDir })).trim() - const stages = new Set() - if (!raw) return stages - for (const line of raw.split(/\r?\n/)) { - const parts = line.trim().split(/\s+/) - const stage = Number.parseInt(parts[2] ?? '', 10) - if (Number.isInteger(stage)) stages.add(stage) - } - return stages -} - -async function checkoutConflictSideWithFallback( - repoDir: string, - path: string, - preferredSide: '--ours' | '--theirs', -): Promise { - const stages = await listUnmergedStages(repoDir, path) - const hasOurs = stages.has(2) - const hasTheirs = stages.has(3) - if (!hasOurs && !hasTheirs) return - if (preferredSide === '--ours') { - if (hasOurs) { - await runCommand('git', ['checkout', '--ours', '--', path], { cwd: repoDir }) - return - } - await runCommand('git', ['checkout', '--theirs', '--', path], { cwd: repoDir }) - return - } - if (hasTheirs) { - await runCommand('git', ['checkout', '--theirs', '--', path], { cwd: repoDir }) - return - } - await runCommand('git', ['checkout', '--ours', '--', path], { cwd: repoDir }) -} - -async function getCommitTime(repoDir: string, ref: string, path: string): Promise { - try { - const output = (await runCommandWithOutput('git', ['log', '-1', '--format=%ct', ref, '--', path], { cwd: repoDir })).trim() - return output ? Number.parseInt(output, 10) : 0 - } catch { - return 0 - } -} - -async function resolveStashPopConflictsByFileTime( - repoDir: string, - localMtimesBeforePull: Map, - pulledMtimes: Map, -): Promise { - const unmerged = (await runCommandWithOutput('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: repoDir })) - .split(/\r?\n/) - .map((row) => row.trim()) - .filter(Boolean) - if (unmerged.length === 0) return - for (const path of unmerged) { - const localMtime = localMtimesBeforePull.get(path) ?? 0 - const pulledMtime = pulledMtimes.get(path) ?? 0 - const side = localMtime >= pulledMtime ? '--theirs' : '--ours' - await checkoutConflictSideWithFallback(repoDir, path, side) - await runCommand('git', ['add', '--', path], { cwd: repoDir }) - } - const mergeHead = await readOptionalGitRef(repoDir, 'MERGE_HEAD') - if (mergeHead) { - await runCommand('git', ['commit', '-m', 'Auto-resolve stash-pop conflicts by file time'], { cwd: repoDir }) - } -} - -async function snapshotFileMtimes(dir: string): Promise> { - const mtimes = new Map() - await walkFileMtimes(dir, dir, mtimes) - return mtimes -} - -async function hasLocalUncommittedChanges(repoDir: string): Promise { - const status = (await runCommandWithOutput('git', ['status', '--porcelain'], { cwd: repoDir })).trim() - return status.length > 0 -} - -async function hasCommittableWorkingTreeChanges(repoDir: string): Promise { - try { - await runCommand('git', ['diff', '--quiet', '--exit-code', '--ignore-submodules=dirty'], { cwd: repoDir }) - await runCommand('git', ['diff', '--cached', '--quiet', '--exit-code', '--ignore-submodules=dirty'], { cwd: repoDir }) - } catch { - return true - } - const untracked = (await runCommandWithOutput('git', ['ls-files', '--others', '--exclude-standard'], { cwd: repoDir })).trim() - return untracked.length > 0 -} - -async function walkFileMtimes(rootDir: string, currentDir: string, out: Map): Promise { - let entries: Array<{ name: string | Buffer; isDirectory: () => boolean; isFile: () => boolean }> - try { - entries = (await readdir(currentDir, { withFileTypes: true })) as Array<{ name: string | Buffer; isDirectory: () => boolean; isFile: () => boolean }> - } catch { - return - } - for (const entry of entries) { - const entryName = String(entry.name) - if (entryName === '.git') continue - const absolutePath = join(currentDir, entryName) - const relativePath = absolutePath.slice(rootDir.length + 1) - if (entry.isDirectory()) { - await walkFileMtimes(rootDir, absolutePath, out) - continue - } - if (!entry.isFile()) continue - try { - const info = await stat(absolutePath) - out.set(relativePath, info.mtimeMs) - } catch {} - } -} - -async function syncInstalledSkillsFolderToRepo( - token: string, - repoOwner: string, - repoName: string, - _installedMap: Map, -): Promise { - async function hasTrackedLocalFileChanges(repoDir: string, filePath: string): Promise { - const diffHead = (await runCommandWithOutput('git', ['diff', '--name-only', 'HEAD', '--', filePath], { cwd: repoDir })).trim() - if (diffHead.length > 0) return true - const diffCached = (await runCommandWithOutput('git', ['diff', '--cached', '--name-only', '--', filePath], { cwd: repoDir })).trim() - return diffCached.length > 0 - } - - async function restoreProtectedFilesFromOrigin(repoDir: string, branch: string): Promise { - const protectedFiles = ['AGENTS.md'] - for (const filePath of protectedFiles) { - const hasLocalEdits = await hasTrackedLocalFileChanges(repoDir, filePath) - if (hasLocalEdits) continue - try { - await runCommand('git', ['cat-file', '-e', `origin/${branch}:${filePath}`], { cwd: repoDir }) - } catch { - continue - } - await runCommand('git', ['checkout', `origin/${branch}`, '--', filePath], { cwd: repoDir }) - } - try { - await runCommand('git', ['cat-file', '-e', `origin/${branch}:shared_skills`], { cwd: repoDir }) - await runCommand('git', ['checkout', `origin/${branch}`, '--', 'shared_skills'], { cwd: repoDir }) - } catch { - // Ignore when the branch does not track the nested shared_skills gitlink. - } - } - - function isNonFastForwardPushError(error: unknown): boolean { - const text = getErrorMessage(error, '').toLowerCase() - return text.includes('non-fast-forward') - || text.includes('fetch first') - || (text.includes('rejected') && text.includes('push')) - } - - async function pushWithNonFastForwardRetry(repoDir: string, branch: string): Promise { - const maxAttempts = 3 - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const hasLocalChangesBeforeReconcile = await hasLocalUncommittedChanges(repoDir) - const localMtimesBeforeReconcile = hasLocalChangesBeforeReconcile ? await snapshotFileMtimes(repoDir) : new Map() - await runGitFetchWithRefLockRetry(repoDir) - try { - await runCommand('git', ['rebase', `origin/${branch}`], { cwd: repoDir }) - } catch { - try { await runCommand('git', ['rebase', '--abort'], { cwd: repoDir }) } catch {} - try { - await runCommand('git', ['pull', '--rebase', '--autostash', 'origin', branch], { cwd: repoDir }) - } catch { - await resolveMergeConflictsByNewerCommit(repoDir, branch, localMtimesBeforeReconcile) - await runCommand('git', ['pull', '--rebase', '--autostash', 'origin', branch], { cwd: repoDir }) - } - } - try { - await runCommand('git', ['push', '--no-recurse-submodules', 'origin', `HEAD:${branch}`], { cwd: repoDir }) - const state = await readSkillsSyncState() - const pushedHead = await runCommandWithOutput('git', ['rev-parse', 'HEAD'], { cwd: repoDir }) - await writeSkillsSyncState({ - ...state, - lastPushCommitSha: pushedHead.trim(), - lastSyncAttemptCount: attempt, - lastSyncError: '', - lastSyncAtIso: new Date().toISOString(), - }) - return - } catch (error) { - if (!isNonFastForwardPushError(error) || attempt >= maxAttempts) { - const state = await readSkillsSyncState() - await writeSkillsSyncState({ - ...state, - lastSyncAttemptCount: attempt, - lastSyncError: getErrorMessage(error, 'push failed'), - lastSyncAtIso: new Date().toISOString(), - }) - throw error - } - } - } - throw new Error('Failed to push after non-fast-forward retries') - } - - const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token) - const branch = PRIVATE_SYNC_BRANCH - const repoDir = await ensureSkillsWorkingTreeRepo(remoteUrl, branch) - void _installedMap - await runCommand('git', ['config', 'user.email', 'skills-sync@local'], { cwd: repoDir }) - await runCommand('git', ['config', 'user.name', 'Skills Sync'], { cwd: repoDir }) - await restoreProtectedFilesFromOrigin(repoDir, branch) - await runCommand('git', ['add', '.'], { cwd: repoDir }) - try { - await runCommand('git', ['diff', '--cached', '--quiet', '--exit-code'], { cwd: repoDir }) - return - } catch {} - await runCommand('git', ['commit', '-m', 'Sync installed skills folder and manifest'], { cwd: repoDir }) - await pushWithNonFastForwardRetry(repoDir, branch) -} - -async function pullInstalledSkillsFolderFromRepo(token: string, repoOwner: string, repoName: string): Promise { - const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token) - const branch = PRIVATE_SYNC_BRANCH - await ensureSkillsWorkingTreeRepo(remoteUrl, branch) -} - -async function bootstrapSkillsFromUpstreamIntoLocal(): Promise { - const repoUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git` - const branch = getPreferredPublicUpstreamBranch() - await ensureSkillsWorkingTreeRepo(repoUrl, branch) -} - -async function collectLocalSyncedSkills(appServer: AppServerLike): Promise { - const state = await readSkillsSyncState() - const owners = { ...(state.installedOwners ?? {}) } - const skills = (await appServer.rpc('skills/list', {})) as { - data?: Array<{ skills?: Array<{ name?: string; enabled?: boolean; path?: string; scope?: string }> }> - } - const seen = new Set() - const synced: SyncedSkill[] = [] - let ownersChanged = false - for (const entry of skills.data ?? []) { - for (const skill of groupRpcSkillRecords(entry.skills ?? [])) { - const name = typeof skill.name === 'string' ? skill.name : '' - if (!name || skill.scope !== 'user' || seen.has(name)) continue - seen.add(name) - const owner = owners[name] ?? '' - synced.push({ ...(owner ? { owner } : {}), name, enabled: skill.enabled !== false }) - } - } - if (ownersChanged) { - await writeSkillsSyncState({ ...state, installedOwners: owners }) - } - synced.sort((a, b) => `${a.owner ?? ''}/${a.name}`.localeCompare(`${b.owner ?? ''}/${b.name}`)) - return synced -} - -async function autoPushSyncedSkills(appServer: AppServerLike): Promise { - const state = await readSkillsSyncState() - if (!state.githubToken || !state.repoOwner || !state.repoName) return - if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) { - throw new Error('Refusing to push to upstream skills repository') - } - const repoDir = getSkillsInstallDir() - await runCommand('git', ['fetch', 'origin', PRIVATE_SYNC_BRANCH], { cwd: repoDir }) - const head = (await runCommandWithOutput('git', ['rev-parse', 'HEAD'], { cwd: repoDir })).trim() - const originHead = (await runCommandWithOutput('git', ['rev-parse', `origin/${PRIVATE_SYNC_BRANCH}`], { cwd: repoDir })).trim() - const hasCommittableChanges = await hasCommittableWorkingTreeChanges(repoDir) - // After a successful pull, if local tree is already clean and equal to remote, - // skip push entirely to avoid rewriting/deleting remote-only updates. - if (!hasCommittableChanges && head === originHead) return - const local = await collectLocalSyncedSkills(appServer) - const installedMap = await scanInstalledSkillsFromDisk() - await writeRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName, local) - await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap) -} - -async function ensureCodexAgentsSymlinkToSkillsAgents(): Promise { - const codexHomeDir = getCodexHomeDir() - const skillsAgentsPath = join(codexHomeDir, 'skills', 'AGENTS.md') - const codexAgentsPath = join(codexHomeDir, 'AGENTS.md') - await mkdir(join(codexHomeDir, 'skills'), { recursive: true }) - let copiedFromCodex = false - try { - const codexAgentsStat = await lstat(codexAgentsPath) - if (codexAgentsStat.isFile() || codexAgentsStat.isSymbolicLink()) { - const content = await readFile(codexAgentsPath, 'utf8') - await writeFile(skillsAgentsPath, content, 'utf8') - copiedFromCodex = true - } else { - await rm(codexAgentsPath, { force: true, recursive: true }) - } - } catch {} - if (!copiedFromCodex) { - try { - const skillsAgentsStat = await stat(skillsAgentsPath) - if (!skillsAgentsStat.isFile()) { - await rm(skillsAgentsPath, { force: true, recursive: true }) - await writeFile(skillsAgentsPath, '', 'utf8') - } - } catch { - await writeFile(skillsAgentsPath, '', 'utf8') - } - } - const relativeTarget = join('skills', 'AGENTS.md') - try { - const current = await lstat(codexAgentsPath) - if (current.isSymbolicLink()) { - const existingTarget = await readlink(codexAgentsPath) - if (existingTarget === relativeTarget) return - } - await rm(codexAgentsPath, { force: true, recursive: true }) - } catch {} - await symlink(relativeTarget, codexAgentsPath) -} - -async function runSkillsSyncStartup(appServer: AppServerLike): Promise { - if (startupSyncStatus.inProgress) return - startupSyncStatus.inProgress = true - startupSyncStatus.lastRunAtIso = new Date().toISOString() - startupSyncStatus.lastError = '' - startupSyncStatus.branch = PRIVATE_SYNC_BRANCH - try { - const state = await readSkillsSyncState() - if (!state.githubToken) { - await ensureCodexAgentsSymlinkToSkillsAgents() - if (!isAndroidLikeRuntime()) { - startupSyncStatus.mode = 'idle' - startupSyncStatus.lastAction = 'skip-upstream-non-android' - startupSyncStatus.lastSuccessAtIso = new Date().toISOString() - return - } - startupSyncStatus.mode = 'unauthenticated-bootstrap' - startupSyncStatus.branch = getPreferredPublicUpstreamBranch() - startupSyncStatus.lastAction = 'pull-upstream' - await bootstrapSkillsFromUpstreamIntoLocal() - try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} - startupSyncStatus.lastSuccessAtIso = new Date().toISOString() - startupSyncStatus.lastAction = 'pull-upstream-complete' - return - } - startupSyncStatus.mode = 'authenticated-fork-sync' - startupSyncStatus.branch = PRIVATE_SYNC_BRANCH - startupSyncStatus.lastAction = 'ensure-private-fork' - const username = state.githubUsername || await resolveGithubUsername(state.githubToken) - const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME - await ensurePrivateForkFromUpstream(state.githubToken, username, repoName) - await writeSkillsSyncState({ ...state, githubUsername: username, repoOwner: username, repoName }) - startupSyncStatus.lastAction = 'pull-private-fork' - await pullInstalledSkillsFolderFromRepo(state.githubToken, username, repoName) - try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} - startupSyncStatus.lastAction = 'push-private-fork' - await autoPushSyncedSkills(appServer) - startupSyncStatus.lastSuccessAtIso = new Date().toISOString() - startupSyncStatus.lastAction = 'startup-sync-complete' - } catch (error) { - startupSyncStatus.lastError = getErrorMessage(error, 'startup-sync-failed') - startupSyncStatus.lastAction = 'startup-sync-failed' - } finally { - startupSyncStatus.inProgress = false - } -} - -export async function initializeSkillsSyncOnStartup(appServer: AppServerLike): Promise { - if (startupSkillsSyncInitialized) return - startupSkillsSyncInitialized = true - await runSkillsSyncStartup(appServer) -} - -async function finalizeGithubLoginAndSync(token: string, username: string, appServer: AppServerLike): Promise { - const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME - await ensurePrivateForkFromUpstream(token, username, repoName) - const current = await readSkillsSyncState() - await writeSkillsSyncState({ ...current, githubToken: token, githubUsername: username, repoOwner: username, repoName }) - await pullInstalledSkillsFolderFromRepo(token, username, repoName) - try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} - await autoPushSyncedSkills(appServer) -} - export async function handleSkillsRoutes( req: IncomingMessage, res: ServerResponse, @@ -1381,190 +587,6 @@ export async function handleSkillsRoutes( return true } - if (req.method === 'GET' && url.pathname === '/codex-api/skills-sync/status') { - const state = await readSkillsSyncState() - setJson(res, 200, { - data: { - loggedIn: Boolean(state.githubToken), - githubUsername: state.githubUsername ?? '', - repoOwner: state.repoOwner ?? '', - repoName: state.repoName ?? '', - configured: Boolean(state.githubToken && state.repoOwner && state.repoName), - telemetry: { - lastPullCommitSha: state.lastPullCommitSha ?? '', - lastPushCommitSha: state.lastPushCommitSha ?? '', - lastSyncAttemptCount: state.lastSyncAttemptCount ?? 0, - lastSyncError: state.lastSyncError ?? '', - lastSyncAtIso: state.lastSyncAtIso ?? '', - }, - startup: { - inProgress: startupSyncStatus.inProgress, - mode: startupSyncStatus.mode, - branch: startupSyncStatus.branch, - lastAction: startupSyncStatus.lastAction, - lastRunAtIso: startupSyncStatus.lastRunAtIso, - lastSuccessAtIso: startupSyncStatus.lastSuccessAtIso, - lastError: startupSyncStatus.lastError, - }, - }, - }) - return true - } - - if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/start-login') { - try { - const started = await startGithubDeviceLogin() - setJson(res, 200, { data: started }) - } catch (error) { - setJson(res, 502, { error: getErrorMessage(error, 'Failed to start GitHub login') }) - } - return true - } - - if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/token-login') { - try { - const payload = asRecord(await readJsonBody(req)) - const token = typeof payload?.token === 'string' ? payload.token.trim() : '' - if (!token) { - setJson(res, 400, { error: 'Missing GitHub token' }) - return true - } - const username = await resolveGithubUsername(token) - await finalizeGithubLoginAndSync(token, username, appServer) - setJson(res, 200, { ok: true, data: { githubUsername: username } }) - } catch (error) { - setJson(res, 502, { error: getErrorMessage(error, 'Failed to login with GitHub token') }) - } - return true - } - - if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/logout') { - try { - const state = await readSkillsSyncState() - await writeSkillsSyncState({ - ...state, - githubToken: undefined, - githubUsername: undefined, - repoOwner: undefined, - repoName: undefined, - }) - setJson(res, 200, { ok: true }) - } catch (error) { - setJson(res, 500, { error: getErrorMessage(error, 'Failed to logout GitHub') }) - } - return true - } - - if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/complete-login') { - try { - const payload = asRecord(await readJsonBody(req)) - const deviceCode = typeof payload?.deviceCode === 'string' ? payload.deviceCode : '' - if (!deviceCode) { - setJson(res, 400, { error: 'Missing deviceCode' }) - return true - } - const result = await completeGithubDeviceLogin(deviceCode) - if (!result.token) { - setJson(res, 200, { ok: false, pending: result.error === 'authorization_pending', error: result.error || 'login_failed' }) - return true - } - const token = result.token - const username = await resolveGithubUsername(token) - await finalizeGithubLoginAndSync(token, username, appServer) - setJson(res, 200, { ok: true, data: { githubUsername: username } }) - } catch (error) { - setJson(res, 502, { error: getErrorMessage(error, 'Failed to complete GitHub login') }) - } - return true - } - - if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/push') { - try { - const state = await readSkillsSyncState() - if (!state.githubToken || !state.repoOwner || !state.repoName) { - setJson(res, 400, { error: 'Skills sync is not configured yet' }) - return true - } - if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) { - setJson(res, 400, { error: 'Refusing to push to upstream repository' }) - return true - } - const local = await collectLocalSyncedSkills(appServer) - const installedMap = await collectInstalledSkillsMap(appServer) - await writeRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName, local) - await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap) - setJson(res, 200, { ok: true, data: { synced: local.length } }) - } catch (error) { - setJson(res, 502, { error: getErrorMessage(error, 'Failed to push synced skills') }) - } - return true - } - - if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/startup-sync') { - try { - await runSkillsSyncStartup(appServer) - setJson(res, 200, { ok: true }) - } catch (error) { - setJson(res, 502, { error: getErrorMessage(error, 'Failed to run startup sync') }) - } - return true - } - - if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/pull') { - try { - const state = await readSkillsSyncState() - if (!state.githubToken || !state.repoOwner || !state.repoName) { - await bootstrapSkillsFromUpstreamIntoLocal() - try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} - setJson(res, 200, { ok: true, data: { synced: 0, source: 'upstream' } }) - return true - } - const remote = await readRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName) - const localDir = await detectUserSkillsDir(appServer) - await pullInstalledSkillsFolderFromRepo(state.githubToken, state.repoOwner, state.repoName) - const localSkills = await scanInstalledSkillsFromDisk() - const missingAfterPull: string[] = [] - for (const skill of remote) { - const owner = skill.owner || '' - if (!owner) continue - if (!localSkills.has(skill.name)) { - missingAfterPull.push(`${owner}/${skill.name}`) - continue - } - const skillPath = join(localDir, skill.name) - await appServer.rpc('skills/config/write', { path: skillPath, enabled: skill.enabled }) - } - if (missingAfterPull.length > 0) { - throw new Error(`Missing skill folders after pull: ${missingAfterPull.join(', ')}`) - } - const remoteNames = new Set(remote.map((row) => row.name)) - for (const [name, localInfo] of localSkills.entries()) { - if (!remoteNames.has(name)) { - await rm(localInfo.path.replace(/\/SKILL\.md$/, ''), { recursive: true, force: true }) - } - } - const nextOwners: Record = {} - for (const item of remote) { - const owner = item.owner || '' - if (owner) nextOwners[item.name] = owner - } - const pulledHead = await runCommandWithOutput('git', ['rev-parse', 'HEAD'], { cwd: getSkillsInstallDir() }).catch(() => '') - await writeSkillsSyncState({ - ...state, - installedOwners: nextOwners, - lastPullCommitSha: pulledHead.trim(), - lastSyncAttemptCount: 1, - lastSyncError: '', - lastSyncAtIso: new Date().toISOString(), - }) - try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} - setJson(res, 200, { ok: true, data: { synced: remote.length } }) - } catch (error) { - setJson(res, 502, { error: getErrorMessage(error, 'Failed to pull synced skills') }) - } - return true - } - if (req.method === 'GET' && url.pathname === '/codex-api/skills-hub/readme') { try { const owner = url.searchParams.get('owner') || '' @@ -1613,7 +635,6 @@ export async function handleSkillsRoutes( throw new Error(`Skill install completed but ${installSource} was not found in local installed skills`) } await ensureInstalledSkillIsValid(appServer, installed.path) - autoPushSyncedSkills(appServer).catch(() => {}) setJson(res, 200, { ok: true, path: installed.path }) } catch (error) { setJson(res, 502, { error: getErrorMessage(error, 'Failed to install skill') }) @@ -1633,13 +654,6 @@ export async function handleSkillsRoutes( return true } await rm(target, { recursive: true, force: true }) - if (name) { - const syncState = await readSkillsSyncState() - const nextOwners = { ...(syncState.installedOwners ?? {}) } - delete nextOwners[name] - await writeSkillsSyncState({ ...syncState, installedOwners: nextOwners }) - } - autoPushSyncedSkills(appServer).catch(() => {}) try { await withTimeout(appServer.rpc('skills/list', { forceReload: true }), 10_000, 'skills/list reload') } catch {} setJson(res, 200, { ok: true, deletedPath: target }) } catch (error) { diff --git a/src/style.css b/src/style.css index 86e958aa3..133bdfae4 100644 --- a/src/style.css +++ b/src/style.css @@ -1353,39 +1353,6 @@ @apply text-zinc-500; } -:root.dark .skills-sync-panel { - @apply border-zinc-700 bg-zinc-800; -} - -:root.dark .skills-sync-header { - @apply text-zinc-300; -} - -:root.dark .skills-sync-badge { - @apply border-zinc-600 bg-zinc-700 text-zinc-200; -} - -:root.dark .skills-sync-badge-link { - @apply text-zinc-200 hover:text-white hover:border-zinc-500; -} - -:root.dark .skills-sync-device, -:root.dark .skills-sync-meta { - @apply text-zinc-400; -} - -:root.dark .skills-sync-device a { - @apply text-blue-400 hover:text-blue-300; -} - -:root.dark .skills-sync-device code { - @apply border border-zinc-600 bg-zinc-700 px-1.5 py-0.5 rounded text-zinc-200; -} - -:root.dark .skills-sync-error { - @apply border-rose-800 bg-rose-950/40 text-rose-200; -} - :root.dark .skills-search-panel { @apply border-zinc-700 bg-zinc-900; } diff --git a/tests.md b/tests.md index 8dbc45337..8a119e80a 100644 --- a/tests.md +++ b/tests.md @@ -342,40 +342,6 @@ The accidental `npx run dev` command starts the repository dev wrapper instead o #### Rollback/Cleanup - Stop any dev server process started for validation. ---- - -### Skills sync idempotent commits and nested shared skills handling - -#### Feature/Change Name -Skills Sync skips unchanged manifest writes and does not fail parent commits when only nested `shared_skills` content is dirty. - -#### Prerequisites/Setup -1. Dev server running (`pnpm run dev --host 127.0.0.1 --port 5173`) -2. GitHub Skills Sync is connected to a private skills sync repo -3. `/Users/igor/.codex/skills/shared_skills` exists as a nested Git repository -4. Light theme and dark theme are available from the appearance switcher - -#### Steps -1. In light theme, open `#/skills`. -2. Click `Startup Sync` when no installed skills manifest content has changed. -3. Confirm the sync completes without adding a new `Update synced skills manifest` commit to the GitHub repo. -4. Modify a file inside `/Users/igor/.codex/skills/shared_skills` without committing it inside that nested repository. -5. Click `Push` or `Startup Sync` again. -6. Confirm the sync does not show `Command failed (git commit -m Sync installed skills folder and manifest)` for the parent `/Users/igor/.codex/skills` repository. -7. Confirm the startup auto-push path skips when the only local status is dirty nested `shared_skills` content and local `HEAD` equals `origin/main`. -8. Switch to dark theme and repeat steps 1, 2, and 5. - -#### Expected Results -- Unchanged `installed-skills.json` content is not written back to GitHub, so repeated empty-looking manifest commits are not created. -- A dirty nested `shared_skills` repository does not make the parent skills sync fail with `no changes added to commit`. -- Dirty nested `shared_skills` content alone does not keep triggering no-op startup push work. -- Skills Sync status, errors, and action buttons remain readable in light theme and dark theme. - -#### Rollback/Cleanup -- Revert or commit the intentional test edit inside `/Users/igor/.codex/skills/shared_skills`. - ---- - ### Header Git branch dropdown with commit reset #### Feature/Change Name @@ -1335,238 +1301,6 @@ Model, skill, thinking, and plan controls remain usable while a thread turn is i #### Rollback/Cleanup - No cleanup required. -### Feature: Skills sync pull live-reloads installed skills list - -#### Prerequisites -- App running from this repository with Skills Hub available. -- GitHub skills sync configured and connected. -- At least one skill update available in the sync source (new or edited skill metadata). - -#### Steps -1. Open the app and note the currently visible installed skills for the active thread cwd. -2. In Skills Hub, trigger `Pull` from GitHub sync. -3. Wait for the pull success toast. -4. Without restarting the app/server, navigate to thread composer skill picker and verify the installed skills list. -5. Switch to another thread and back to force a normal UI refresh path. - -#### Expected Results -- Pull completes successfully. -- Installed skills list reflects pulled changes immediately without app/server restart. -- Thread switch keeps showing the updated skills list (no stale cache rollback). - -#### Rollback/Cleanup -- If needed, run another sync pull/push to restore previous skill state in the sync repo. - -### Feature: Force Refresh Skills button in Skills Sync panel - -#### Prerequisites -- App running from this repository with Skills Hub route accessible. -- At least one installed skill is available for the current thread cwd. - -#### Steps -1. Open `Skills Hub`. -2. In `Skills Sync (GitHub)`, click `Force Refresh Skills`. -3. Verify button text changes to `Refreshing...` during the request and returns after completion. -4. Verify success toast appears. -5. Open the thread composer skills picker and confirm installed skills list is present and current. -6. Switch to another thread and back to ensure refreshed list remains consistent. - -#### Expected Results -- `Force Refresh Skills` triggers a manual refresh without requiring pull/push. -- Loading state prevents duplicate clicks while refresh is in progress. -- Installed skills list updates immediately and remains updated across thread switches. - -#### Rollback/Cleanup -- No cleanup required. - -### Feature: SkillHub shows detailed skill load errors - -#### Prerequisites -- App running from this repository. -- At least one invalid installed skill file exists (for example unresolved merge markers in `SKILL.md`). - -#### Steps -1. Open `Skills Hub`. -2. Trigger `Force Refresh Skills`. -3. Locate the `Some skills failed to load` panel above the skills sections. -4. Verify each row shows: - - the failing `SKILL.md` path - - the exact parser error message from app server (for example invalid YAML line/column details). -5. Fix the invalid skill file and trigger `Force Refresh Skills` again. - -#### Expected Results -- SkillHub surfaces app-server load failures with detailed path and message. -- Messages are specific enough to identify the broken file and parser failure reason. -- Error panel disappears after invalid skills are fixed and refreshed. - -#### Rollback/Cleanup -- Restore any intentionally broken local skill files used for testing. - -### Feature: Startup sync preserves local skill edits when remote is ahead - -#### Prerequisites -- Skills sync configured to a private GitHub fork. -- Local skills repo has a tracked edit in an existing skill file. -- Remote `main` has at least one newer commit than local (simulate from another machine or commit directly on GitHub). - -#### Steps -1. Edit a local skill file (for example update description text in `SKILL.md`) and keep the change. -2. Trigger `Startup Sync` in Skills Hub. -3. If a non-fast-forward condition exists, allow startup sync to complete retry path. -4. Re-open the same local skill file and verify your edit remains. -5. Trigger `Force Refresh Skills` and verify no unexpected skill removals occurred. - -#### Expected Results -- Startup sync no longer fails with non-fast-forward push due to missing remote integration. -- Local tracked skill edits remain after sync (not overwritten by remote state). -- Sync path rebases/pulls with autostash and auto-resolves conflicts by mtime policy: - - choose remote (`theirs`) when remote file commit time is newer than local file mtime. - - choose local (`ours`) otherwise. -- No manual conflict intervention is required during startup sync retries. - -#### Rollback/Cleanup -- Revert test-only skill text changes if they were not intended to keep. - -### Feature: Startup sync conflict fallback when one side is missing - -#### Prerequisites -- Skills sync repo contains a conflict candidate where only one side exists for a path (for example delete/modify scenario). -- Skills Hub is accessible. - -#### Steps -1. Open `Skills Hub`. -2. Click `Startup Sync`. -3. Wait for sync completion or error toast. -4. Verify no toast/error contains `does not have our version`. - -#### Expected Results -- Sync conflict resolver handles missing `--ours`/`--theirs` versions safely. -- Startup sync does not fail with `git checkout --ours/--theirs` missing-version errors. - -#### Rollback/Cleanup -- None. - -### Feature: Remote changes win when no local uncommitted skill edits exist - -#### Prerequisites -- Skills sync configured with GitHub. -- Local skills repo working tree is clean (`git status --porcelain` empty under skills dir). -- Remote skills repo has newer commits touching existing skill files. - -#### Steps -1. Confirm no local uncommitted changes in skills directory. -2. Trigger `Startup Sync` in Skills Hub. -3. After sync, inspect the skill file changed remotely. -4. Trigger `Force Refresh Skills` and confirm loaded skill content matches remote update. - -#### Expected Results -- Sync pull/reconcile does not preserve stale local file content when local tree is clean. -- Remote updates are applied locally and remain after startup sync completes. - -#### Rollback/Cleanup -- None. - -### Feature: Startup sync does not delete remote AGENTS.md - -#### Prerequisites -- Skills sync configured to `friuns2/codexskills`. -- Remote `main` contains `AGENTS.md`. -- Local skills repo is clean before startup sync. - -#### Steps -1. Confirm remote `AGENTS.md` exists on `main`. -2. Confirm local `~/.codex/skills` is clean. -3. Trigger `Startup Sync`. -4. After completion, inspect latest commit created by sync (if any). -5. Verify `AGENTS.md` still exists locally and in remote `origin/main`. - -#### Expected Results -- Startup sync may update manifest, but must not delete `AGENTS.md`. -- If sync creates a commit, changed files do not include `D AGENTS.md`. -- Local and remote `AGENTS.md` hashes remain equal after sync. - -#### Rollback/Cleanup -- None. - -### Feature: Bidirectional AGENTS.md sync via Startup Sync - -#### Prerequisites -- Skills sync configured to `friuns2/codexskills`. -- `~/.codex/skills` is a clean git working tree before each sub-test. -- Skills Hub startup sync endpoint is reachable. - -#### Steps -1. Remote -> Local: -2. Add a unique marker to remote `AGENTS.md` on `main`. -3. Confirm local `HEAD` is behind `origin/main`. -4. Trigger `Startup Sync`. -5. Verify local `AGENTS.md` contains the remote marker and local `HEAD == origin/main`. -6. Local -> Remote: -7. Add a different unique marker to local `~/.codex/skills/AGENTS.md`. -8. Confirm local working tree shows `M AGENTS.md`. -9. Trigger `Startup Sync`. -10. Verify remote `origin/main:AGENTS.md` contains the local marker and local `HEAD == origin/main`. - -#### Expected Results -- Remote-only AGENTS edits are pulled into local without deletion. -- Local AGENTS edits are pushed to remote after startup sync. -- After each sync direction, local and remote commit SHAs match. - -#### Rollback/Cleanup -- Remove temporary test markers from `AGENTS.md` if required. - -### Feature: Mixed local+remote AGENTS edits do not stall Startup Sync - -#### Prerequisites -- Skills sync configured and working. -- Local skills repo clean before test start. - -#### Steps -1. Add marker `A` to remote `AGENTS.md`. -2. Add marker `B` to local `AGENTS.md` before syncing. -3. Trigger `Startup Sync`. -4. Wait for startup status to finish (`inProgress=false`). -5. Verify sync outcome explicitly: -6. If sync succeeds, local/remote SHAs match and expected merged marker result is present. -7. If sync fails, status includes a concrete error message (not silent success). - -#### Expected Results -- Startup sync must not report success while local remains behind remote. -- No stale stash side-effects are introduced (no unexpected conflict from old stash entries). -- Final state is either a valid synchronized result or an explicit failure status with actionable error. - -#### Rollback/Cleanup -- Reset local skills repo to `origin/main` after test if needed. - -### Feature: Startup sync uses deterministic pull reconcile (`fetch + reset --hard`) before local replay - -#### Prerequisites -- Skills sync is logged in and targets `friuns2/codexskills`. -- Local repo path is `~/.codex/skills`. -- Startup Sync endpoint is reachable at `/codex-api/skills-sync/startup-sync`. - -#### Steps -1. Remote-only case: -2. Commit a unique marker to remote `AGENTS.md` on `main`. -3. Ensure local repo is clean and reset to `origin/main`, then trigger `Startup Sync`. -4. Confirm marker appears locally and `HEAD == origin/main`. -5. Local-only case: -6. Add a unique local marker to `~/.codex/skills/AGENTS.md` (uncommitted), trigger `Startup Sync`. -7. Confirm marker is pushed and `HEAD == origin/main` with clean worktree. -8. Mixed case: -9. Add local marker first, then commit a newer remote marker. -10. Trigger `Startup Sync` and verify mtime policy result (newer remote marker wins, older local marker dropped). -11. Confirm final state is clean with `HEAD == origin/main`. - -#### Expected Results -- Startup sync does not fail with missing merge refs (`MERGE_HEAD`/`REBASE_HEAD`) in this path. -- Remote-only changes are always pulled first and visible locally. -- Local-only changes are preserved and pushed during the same startup sync run. -- Mixed local+remote edits converge automatically with no manual conflict handling. - -#### Rollback/Cleanup -- Remove temporary test markers from `AGENTS.md` if not needed. - ### Feature: Revert Renat scrolling/input-layout behavior (without Fast mode changes) #### Prerequisites @@ -4553,3 +4287,30 @@ Managed worktree threads remain visible under their matching canonical workspace #### Rollback/Cleanup - None. + +--- + +### Remove GitHub skills sync from Skills Hub + +#### Feature/Change Name +Skills Hub is local-only and no longer shows or triggers any GitHub skills sync workflow. + +#### Prerequisites/Setup +1. Dev server running (`pnpm run dev --host 127.0.0.1 --port 4173`) +2. At least one installed skill is available locally +3. Light theme and dark theme are available from the appearance switcher + +#### Steps +1. In light theme, open `#/skills`. +2. Confirm there is no `Skills Sync (GitHub)` panel and no login, pull, push, or startup sync controls. +3. Search for a skill and open an installed skill from the `Installed skills` section. +4. Toggle the installed skill enabled state and confirm the UI updates without any sync-related error toast. +5. Switch to dark theme and repeat steps 1, 2, and 4. + +#### Expected Results +- Skills Hub only shows local installed-skill management and registry search UI. +- No GitHub sync actions or status copy remain visible in light theme or dark theme. +- Enabling or disabling a local skill still works after the sync feature removal. + +#### Rollback/Cleanup +- Revert any installed-skill enablement change made during the test if needed. From 023322d1db13cd0c2d32b72542625c8db04c0c4c Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 6 May 2026 09:12:57 +0700 Subject: [PATCH 2/2] Add generic GitHub skills sync --- src/components/content/SkillsHub.vue | 110 ++++ src/composables/useGithubSkillsSync.ts | 246 +++++++ src/composables/useUiLanguage.ts | 18 + src/server/codexAppServerBridge.ts | 3 +- src/server/skillsRoutes.ts | 857 ++++++++++++++++++++++++- src/style.css | 33 + tests.md | 294 ++++++++- 7 files changed, 1531 insertions(+), 30 deletions(-) create mode 100644 src/composables/useGithubSkillsSync.ts diff --git a/src/components/content/SkillsHub.vue b/src/components/content/SkillsHub.vue index bb5a89578..4c55c353b 100644 --- a/src/components/content/SkillsHub.vue +++ b/src/components/content/SkillsHub.vue @@ -4,6 +4,50 @@

{{ t('Skills Hub') }}

{{ t('Manage installed skills on this machine') }}

+ +
+
+ {{ t('Skills Sync (GitHub)') }} + + {{ t('Connected') }}: {{ syncStatus.repoOwner }}/{{ syncStatus.repoName }} + + {{ t('Logged in as') }} {{ syncStatus.githubUsername }} + {{ t('Not connected') }} +
+
+ {{ t('Startup') }}: {{ syncStatus.startup.mode }} + {{ t('Branch') }}: {{ syncStatus.startup.branch }} + {{ t('Action') }}: {{ syncStatus.startup.lastAction }} +
+
+ {{ syncStatus.startup.lastError }} +
+
+ {{ t('Manual sync') }}: {{ syncActionStatus }} +
+
+ {{ syncActionError }} +
+
+ {{ t('Open') }} {{ t('GitHub device login') }} {{ t('and enter code:') }} + {{ deviceLogin.user_code }} +
+
+ + + + + + +
+
+
{{ toast.text }}
@@ -97,6 +141,7 @@ import { computed, onMounted, ref } from 'vue' import IconTablerChevronRight from '../icons/IconTablerChevronRight.vue' import SkillCard from './SkillCard.vue' import SkillDetailModal, { type HubSkill } from './SkillDetailModal.vue' +import { useGithubSkillsSync } from '../../composables/useGithubSkillsSync' import { useUiLanguage } from '../../composables/useUiLanguage' const EMPTY_SKILL: HubSkill = { name: '', owner: '', description: '', url: '', installed: false } @@ -138,6 +183,13 @@ const isDetailInstalling = computed(() => const isDetailUninstalling = computed(() => isUninstallActionInFlight.value && actionSkillKey.value === currentDetailSkillKey.value, ) +const githubRepoUrl = computed(() => { + if (!syncStatus.value.configured) return '' + const owner = syncStatus.value.repoOwner.trim() + const repo = syncStatus.value.repoName.trim() + if (!owner || !repo) return '' + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` +}) const filteredInstalled = computed(() => installedSkills.value) function showToast(text: string, type: 'success' | 'error' = 'success'): void { @@ -280,6 +332,7 @@ async function handleToggleEnabled(skill: HubSkill, enabled: boolean): Promise { + await fetchSkills() + emit('skills-changed') + }, +}) + onMounted(() => { void fetchSkills() + void loadSyncStatus() }) @@ -331,6 +409,38 @@ onMounted(() => { @apply shrink-0 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs font-medium text-zinc-600 transition hover:bg-zinc-50 hover:border-zinc-300 cursor-pointer; } +.skills-sync-panel { + @apply rounded-xl border border-zinc-200 bg-zinc-50 p-3 flex flex-col gap-2; +} + +.skills-sync-header { + @apply flex flex-wrap items-center gap-2 text-sm text-zinc-700; +} + +.skills-sync-badge { + @apply text-xs rounded-md border border-zinc-300 bg-white px-2 py-0.5; +} + +.skills-sync-badge-link { + @apply text-zinc-700 hover:text-zinc-900 hover:border-zinc-400; +} + +.skills-sync-device { + @apply text-xs text-zinc-600 flex items-center gap-2 flex-wrap; +} + +.skills-sync-meta { + @apply text-xs text-zinc-600 flex items-center gap-3 flex-wrap; +} + +.skills-sync-error { + @apply text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md px-2 py-1; +} + +.skills-sync-actions { + @apply flex flex-wrap gap-2; +} + .skills-search-panel { @apply rounded-xl border border-zinc-200 bg-white p-3 flex flex-col gap-2; } diff --git a/src/composables/useGithubSkillsSync.ts b/src/composables/useGithubSkillsSync.ts new file mode 100644 index 000000000..64d3946df --- /dev/null +++ b/src/composables/useGithubSkillsSync.ts @@ -0,0 +1,246 @@ +import { computed, ref } from 'vue' + +type ToastType = 'success' | 'error' + +type SyncStartupStatus = { + inProgress: boolean + mode: string + branch: string + lastAction: string + lastRunAtIso: string + lastSuccessAtIso: string + lastError: string +} + +export type SkillsSyncStatus = { + loggedIn: boolean + githubUsername: string + repoOwner: string + repoName: string + configured: boolean + startup: SyncStartupStatus +} + +type UseGithubSkillsSyncOptions = { + showToast: (text: string, type?: ToastType) => void + onPulled: () => Promise +} + +const firebaseConfig = { + apiKey: 'AIzaSyAf0CIHBZ-wEQJ8CCUUWo1Wl9P7typ_ZPI', + authDomain: 'gptcall-416910.firebaseapp.com', + projectId: 'gptcall-416910', + storageBucket: 'gptcall-416910.appspot.com', + messagingSenderId: '99275526699', + appId: '1:99275526699:web:3b623e1e2996108b52106e', +} + +let firebaseGithubAuthLoader: + Promise<[typeof import('firebase/app'), typeof import('firebase/auth')]> | null = null + +function loadFirebaseGithubAuth() { + if (!firebaseGithubAuthLoader) { + firebaseGithubAuthLoader = Promise.all([ + import('firebase/app'), + import('firebase/auth'), + ]) + } + return firebaseGithubAuthLoader +} + +export function useGithubSkillsSync(options: UseGithubSkillsSyncOptions) { + const deviceLogin = ref<{ device_code: string; user_code: string; verification_uri: string } | null>(null) + const syncActionStatus = ref('') + const syncActionError = ref('') + const syncActionInFlight = ref<'pull' | 'push' | 'startup-sync' | ''>('') + const syncStatus = ref({ + loggedIn: false, + githubUsername: '', + repoOwner: '', + repoName: '', + configured: false, + startup: { + inProgress: false, + mode: 'idle', + branch: 'main', + lastAction: 'not-started', + lastRunAtIso: '', + lastSuccessAtIso: '', + lastError: '', + }, + }) + + const isPullInFlight = computed(() => syncActionInFlight.value === 'pull') + const isPushInFlight = computed(() => syncActionInFlight.value === 'push') + const isStartupSyncInFlight = computed(() => syncActionInFlight.value === 'startup-sync') + const isSyncActionInFlight = computed(() => syncActionInFlight.value !== '') + + async function loadSyncStatus(): Promise { + try { + const resp = await fetch('/codex-api/skills-sync/status') + if (!resp.ok) return + const payload = (await resp.json()) as { data?: SkillsSyncStatus } + if (payload.data) syncStatus.value = payload.data + } catch { + // best effort + } + } + + async function startGithubLogin(): Promise { + try { + const startResp = await fetch('/codex-api/skills-sync/github/start-login', { method: 'POST' }) + const startData = (await startResp.json()) as { data?: { device_code: string; user_code: string; verification_uri: string; interval?: number } } + if (!startResp.ok || !startData.data) throw new Error('Failed to start GitHub login') + deviceLogin.value = startData.data + const maxAttempts = 30 + const waitMs = Math.max((startData.data.interval ?? 5) * 1000, 3000) + let loggedIn = false + for (let i = 0; i < maxAttempts; i++) { + await new Promise((resolve) => setTimeout(resolve, waitMs)) + const completeResp = await fetch('/codex-api/skills-sync/github/complete-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceCode: startData.data.device_code }), + }) + const completeData = (await completeResp.json()) as { ok?: boolean; pending?: boolean; error?: string } + if (!completeResp.ok) throw new Error(completeData.error || 'Failed to complete GitHub login') + if (completeData.ok) { + loggedIn = true + break + } + if (!completeData.pending) throw new Error(completeData.error || 'Failed to complete GitHub login') + } + if (!loggedIn) throw new Error('GitHub login timed out. Please retry.') + deviceLogin.value = null + await loadSyncStatus() + options.showToast('GitHub login successful') + } catch (e) { + options.showToast(e instanceof Error ? e.message : 'Failed GitHub login', 'error') + } + } + + async function startGithubFirebaseLogin(): Promise { + try { + const [firebaseApp, firebaseAuth] = await loadFirebaseGithubAuth() + const { getApp, getApps, initializeApp } = firebaseApp + const { getAuth, GithubAuthProvider, signInWithPopup } = firebaseAuth + const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig) + const auth = getAuth(app) + const provider = new GithubAuthProvider() + provider.addScope('repo') + const result = await signInWithPopup(auth, provider) + const credential = GithubAuthProvider.credentialFromResult(result) + const token = credential?.accessToken ?? '' + if (!token) { + throw new Error('GitHub access token missing from Firebase login') + } + const resp = await fetch('/codex-api/skills-sync/github/token-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + const data = (await resp.json()) as { ok?: boolean; error?: string } + if (!resp.ok || !data.ok) { + throw new Error(data.error || 'Failed to login with GitHub token') + } + await loadSyncStatus() + options.showToast('GitHub login successful') + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed Firebase GitHub login' + options.showToast(message, 'error') + } + } + + async function pullSkillsSync(): Promise { + syncActionError.value = '' + syncActionStatus.value = 'pull-started' + syncActionInFlight.value = 'pull' + try { + const resp = await fetch('/codex-api/skills-sync/pull', { method: 'POST' }) + const data = (await resp.json()) as { ok?: boolean; error?: string } + if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to pull synced skills') + await options.onPulled() + syncActionStatus.value = 'pull-success' + options.showToast(syncStatus.value.loggedIn ? 'Pulled skills from private sync repo' : 'Pulled skills from upstream repo') + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to pull sync' + syncActionError.value = message + syncActionStatus.value = 'pull-failed' + options.showToast(message, 'error') + } finally { + syncActionInFlight.value = '' + } + } + + async function pushSkillsSync(): Promise { + syncActionError.value = '' + syncActionStatus.value = 'push-started' + syncActionInFlight.value = 'push' + try { + const resp = await fetch('/codex-api/skills-sync/push', { method: 'POST' }) + const data = (await resp.json()) as { ok?: boolean; error?: string } + if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to push synced skills') + syncActionStatus.value = 'push-success' + options.showToast('Pushed skills to private sync repo') + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to push sync' + syncActionError.value = message + syncActionStatus.value = 'push-failed' + options.showToast(message, 'error') + } finally { + syncActionInFlight.value = '' + } + } + + async function startupSkillsSync(): Promise { + syncActionError.value = '' + syncActionStatus.value = 'startup-sync-started' + syncActionInFlight.value = 'startup-sync' + try { + const resp = await fetch('/codex-api/skills-sync/startup-sync', { method: 'POST' }) + const data = (await resp.json()) as { ok?: boolean; error?: string } + if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to run startup sync') + await options.onPulled() + await loadSyncStatus() + syncActionStatus.value = 'startup-sync-success' + options.showToast('Startup sync completed') + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed startup sync' + syncActionError.value = message + syncActionStatus.value = 'startup-sync-failed' + options.showToast(message, 'error') + } finally { + syncActionInFlight.value = '' + } + } + + async function logoutGithub(): Promise { + try { + const resp = await fetch('/codex-api/skills-sync/github/logout', { method: 'POST' }) + const data = (await resp.json()) as { ok?: boolean; error?: string } + if (!resp.ok || !data.ok) throw new Error(data.error || 'Failed to logout GitHub') + await loadSyncStatus() + options.showToast('Logged out from GitHub') + } catch (e) { + options.showToast(e instanceof Error ? e.message : 'Failed to logout GitHub', 'error') + } + } + + return { + deviceLogin, + isPullInFlight, + isPushInFlight, + isStartupSyncInFlight, + isSyncActionInFlight, + loadSyncStatus, + logoutGithub, + pullSkillsSync, + pushSkillsSync, + startupSkillsSync, + startGithubFirebaseLogin, + startGithubLogin, + syncActionError, + syncActionStatus, + syncStatus, + } +} diff --git a/src/composables/useUiLanguage.ts b/src/composables/useUiLanguage.ts index 921491b19..606ab8c5e 100644 --- a/src/composables/useUiLanguage.ts +++ b/src/composables/useUiLanguage.ts @@ -253,6 +253,24 @@ const zhCN: Record = { 'Hide all': '隐藏全部', 'No types yet': '还没有类型', 'Manage installed skills on this machine': '管理此机器上已安装的技能', + 'Skills Sync (GitHub)': '技能同步(GitHub)', + 'Connected': '已连接', + 'Logged in as': '已登录为', + 'Not connected': '未连接', + 'Startup': '启动', + 'Action': '动作', + 'Manual sync': '手动同步', + 'GitHub device login': 'GitHub 设备登录', + 'and enter code:': '并输入代码:', + 'Login with GitHub': '使用 GitHub 登录', + 'Device Login': '设备登录', + 'Logout GitHub': '退出 GitHub', + 'Syncing...': '同步中...', + 'Startup Sync': '启动同步', + 'Pulling...': '拉取中...', + 'Pull': '拉取', + 'Pushing...': '推送中...', + 'Push': '推送', 'Installed ({count})': '已安装({count})', 'Find skills': '查找技能', 'Search the Skills registry with npx skills find.': '使用 npx skills find 搜索 Skills 注册表。', diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index c59264cc8..1304d271a 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -13,7 +13,7 @@ import { writeFile } from 'node:fs/promises' import { handleAccountRoutes } from './accountRoutes.js' import { buildAppServerArgs } from './appServerRuntimeConfig.js' import { handleReviewRoutes } from './reviewGit.js' -import { handleSkillsRoutes } from './skillsRoutes.js' +import { handleSkillsRoutes, initializeSkillsSyncOnStartup } from './skillsRoutes.js' import { TelegramThreadBridge } from './telegramThreadBridge.js' import { getRandomFreeKey, @@ -4839,6 +4839,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } return threadSearchIndexPromise } + void initializeSkillsSyncOnStartup(appServer) void readTelegramBridgeConfig() .then((config) => { if (!config.botToken) return diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index adc74dfd7..ebd635706 100644 --- a/src/server/skillsRoutes.ts +++ b/src/server/skillsRoutes.ts @@ -1,8 +1,10 @@ import { spawn } from 'node:child_process' -import { readFile, readdir, rm, stat } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, mkdir, stat, lstat, readlink, symlink } from 'node:fs/promises' +import { existsSync } from 'node:fs' import type { IncomingMessage, ServerResponse } from 'node:http' -import { homedir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' +import { writeFile } from 'node:fs/promises' import { resolvePythonCommand, resolveSkillInstallerScriptPath } from '../commandResolution.js' import { getSpawnInvocation } from '../utils/commandInvocation.js' @@ -237,6 +239,20 @@ type SkillHubEntry = { installCountLabel?: string } +async function runGitFetchWithRefLockRetry(repoDir: string, args: string[] = ['fetch', 'origin']): Promise { + try { + await runCommand('git', args, { cwd: repoDir }) + } catch (error) { + const message = getErrorMessage(error, '') + if (!message.includes("cannot lock ref 'refs/remotes/origin/")) throw error + const branchMatch = message.match(/refs\/remotes\/origin\/([^\s':]+)/) + if (!branchMatch?.[1]) throw error + const refPath = join(repoDir, '.git', 'refs', 'remotes', 'origin', branchMatch[1]) + try { await rm(refPath, { force: true }) } catch {} + await runCommand('git', args, { cwd: repoDir }) + } +} + async function buildLocalHubEntry(info: InstalledSkillInfo): Promise { let description = '' if (info.path) { @@ -482,6 +498,57 @@ function groupRpcSkillRecords(skills: T[]): T[] { type InstalledSkillInfo = { name: string; path: string; enabled: boolean } +type SkillsSyncState = { + githubToken?: string + githubUsername?: string + repoOwner?: string + repoName?: string + lastPullCommitSha?: string + lastPushCommitSha?: string + lastSyncAttemptCount?: number + lastSyncError?: string + lastSyncAtIso?: string +} + +type GithubDeviceCodeResponse = { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +type GithubTokenResponse = { access_token?: string; error?: string } + +const GITHUB_DEVICE_CLIENT_ID = 'Iv1.b507a08c87ecfe98' +const DEFAULT_SKILLS_SYNC_REPO_NAME = 'codexskills' +const SYNC_UPSTREAM_SKILLS_OWNER = 'OpenClawAndroid' +const SYNC_UPSTREAM_SKILLS_REPO = 'skills' +const PRIVATE_SYNC_BRANCH = 'main' +const PUBLIC_UPSTREAM_BRANCH_ANDROID = 'android' +const PUBLIC_UPSTREAM_BRANCH_DEFAULT = 'main' +let startupSkillsSyncInitialized = false + +type StartupSyncStatus = { + inProgress: boolean + mode: 'unauthenticated-bootstrap' | 'authenticated-fork-sync' | 'idle' + branch: string + lastAction: string + lastRunAtIso: string + lastSuccessAtIso: string + lastError: string +} + +const startupSyncStatus: StartupSyncStatus = { + inProgress: false, + mode: 'idle', + branch: PRIVATE_SYNC_BRANCH, + lastAction: 'not-started', + lastRunAtIso: '', + lastSuccessAtIso: '', + lastError: '', +} + async function scanInstalledSkillsFromDisk(): Promise> { const map = new Map() const skillsDir = getSkillsInstallDir() @@ -551,6 +618,636 @@ function extractSkillDescriptionFromMarkdown(markdown: string): string { return '' } +function getSkillsSyncStatePath(): string { + return join(getCodexHomeDir(), 'skills-sync.json') +} + +async function readSkillsSyncState(): Promise { + try { + const raw = await readFile(getSkillsSyncStatePath(), 'utf8') + const parsed = JSON.parse(raw) as SkillsSyncState + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +async function writeSkillsSyncState(state: SkillsSyncState): Promise { + await writeFile(getSkillsSyncStatePath(), JSON.stringify(state), 'utf8') +} + +async function getGithubJson(url: string, token: string, method = 'GET', body?: unknown): Promise { + const resp = await fetch(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'codex-web-local', + }, + body: body ? JSON.stringify(body) : undefined, + }) + if (!resp.ok) { + const text = await resp.text() + throw new Error(`GitHub API ${method} ${url} failed (${resp.status}): ${text}`) + } + return await resp.json() as T +} + +async function startGithubDeviceLogin(): Promise { + const resp = await fetch('https://github.com/login/device/code', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'codex-web-local', + }, + body: new URLSearchParams({ + client_id: GITHUB_DEVICE_CLIENT_ID, + scope: 'repo read:user', + }), + }) + if (!resp.ok) { + throw new Error(`GitHub device flow init failed (${resp.status})`) + } + return await resp.json() as GithubDeviceCodeResponse +} + +async function completeGithubDeviceLogin(deviceCode: string): Promise<{ token: string | null; error: string | null }> { + const resp = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'codex-web-local', + }, + body: new URLSearchParams({ + client_id: GITHUB_DEVICE_CLIENT_ID, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }) + if (!resp.ok) { + throw new Error(`GitHub token exchange failed (${resp.status})`) + } + const payload = await resp.json() as GithubTokenResponse + if (!payload.access_token) return { token: null, error: payload.error || 'unknown_error' } + return { token: payload.access_token, error: null } +} + +function isAndroidLikeRuntime(): boolean { + if (process.platform === 'android') return true + if (existsSync('/data/data/com.termux')) return true + if (process.env.TERMUX_VERSION) return true + const prefix = process.env.PREFIX?.toLowerCase() ?? '' + if (prefix.includes('/com.termux/')) return true + const proot = process.env.PROOT_TMP_DIR?.toLowerCase() ?? '' + return proot.length > 0 +} + +function getPreferredPublicUpstreamBranch(): string { + return isAndroidLikeRuntime() ? PUBLIC_UPSTREAM_BRANCH_ANDROID : PUBLIC_UPSTREAM_BRANCH_DEFAULT +} + +function isUpstreamSkillsRepo(repoOwner: string, repoName: string): boolean { + return repoOwner.toLowerCase() === SYNC_UPSTREAM_SKILLS_OWNER.toLowerCase() + && repoName.toLowerCase() === SYNC_UPSTREAM_SKILLS_REPO.toLowerCase() +} + +async function resolveGithubUsername(token: string): Promise { + const user = await getGithubJson<{ login: string }>('https://api.github.com/user', token) + return user.login +} + +async function ensurePrivateForkFromUpstream(token: string, username: string, repoName: string): Promise { + const repoUrl = `https://api.github.com/repos/${username}/${repoName}` + let created = false + const existing = await fetch(repoUrl, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'codex-web-local', + }, + }) + if (existing.ok) { + const details = await existing.json() as { private?: boolean } + if (details.private === true) return + await getGithubJson(repoUrl, token, 'PATCH', { private: true }) + return + } + if (existing.status !== 404) { + throw new Error(`Failed to check personal repo existence (${existing.status})`) + } + + await getGithubJson( + 'https://api.github.com/user/repos', + token, + 'POST', + { name: repoName, private: true, auto_init: false, description: 'Codex skills private mirror sync' }, + ) + created = true + + let ready = false + for (let i = 0; i < 20; i++) { + const check = await fetch(repoUrl, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'codex-web-local', + }, + }) + if (check.ok) { + ready = true + break + } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + if (!ready) throw new Error('Private mirror repo was created but is not available yet') + if (!created) return + + const tmp = await mkdtemp(join(tmpdir(), 'codex-skills-seed-')) + try { + const upstreamUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git` + const branch = PRIVATE_SYNC_BRANCH + try { + await runCommand('git', ['clone', '--depth', '1', '--single-branch', '--branch', branch, upstreamUrl, tmp]) + } catch { + await runCommand('git', ['clone', '--depth', '1', upstreamUrl, tmp]) + } + const privateRemote = toGitHubTokenRemote(username, repoName, token) + await runCommand('git', ['remote', 'set-url', 'origin', privateRemote], { cwd: tmp }) + try { await runCommand('git', ['checkout', '-B', branch], { cwd: tmp }) } catch {} + await runCommand('git', ['push', '-u', 'origin', `HEAD:${branch}`], { cwd: tmp }) + } finally { + await rm(tmp, { recursive: true, force: true }) + } +} + +function toGitHubTokenRemote(repoOwner: string, repoName: string, token: string): string { + return `https://x-access-token:${encodeURIComponent(token)}@github.com/${repoOwner}/${repoName}.git` +} + +async function ensureSkillsWorkingTreeRepo(repoUrl: string, branch: string): Promise { + const localDir = getSkillsInstallDir() + await mkdir(localDir, { recursive: true }) + const gitDir = join(localDir, '.git') + let hasGitDir = false + try { hasGitDir = (await stat(gitDir)).isDirectory() } catch { hasGitDir = false } + + if (!hasGitDir) { + await runCommand('git', ['init'], { cwd: localDir }) + await runCommand('git', ['config', 'user.email', 'skills-sync@local'], { cwd: localDir }) + await runCommand('git', ['config', 'user.name', 'Skills Sync'], { cwd: localDir }) + await runCommand('git', ['add', '-A'], { cwd: localDir }) + try { await runCommand('git', ['commit', '-m', 'Local skills snapshot before sync'], { cwd: localDir }) } catch {} + await runCommand('git', ['branch', '-M', branch], { cwd: localDir }) + try { await runCommand('git', ['remote', 'add', 'origin', repoUrl], { cwd: localDir }) } catch { + await runCommand('git', ['remote', 'set-url', 'origin', repoUrl], { cwd: localDir }) + } + await runGitFetchWithRefLockRetry(localDir) + try { + await runCommand('git', ['merge', '--allow-unrelated-histories', '--no-edit', `origin/${branch}`], { cwd: localDir }) + } catch {} + return localDir + } + + await runCommand('git', ['remote', 'set-url', 'origin', repoUrl], { cwd: localDir }) + await runGitFetchWithRefLockRetry(localDir) + const hasLocalChangesBeforeSync = await hasLocalUncommittedChanges(localDir) + const localMtimesBeforeSync = hasLocalChangesBeforeSync ? await snapshotFileMtimes(localDir) : new Map() + await resolveMergeConflictsByNewerCommit(localDir, branch, localMtimesBeforeSync) + try { + await runCommand('git', ['checkout', branch], { cwd: localDir }) + } catch { + await resolveMergeConflictsByNewerCommit(localDir, branch, localMtimesBeforeSync) + await runCommand('git', ['checkout', '-B', branch], { cwd: localDir }) + } + await resolveMergeConflictsByNewerCommit(localDir, branch, localMtimesBeforeSync) + const hasLocalChangesBeforePull = await hasLocalUncommittedChanges(localDir) + const localMtimesBeforePull = hasLocalChangesBeforePull ? await snapshotFileMtimes(localDir) : new Map() + let createdAutostash = false + try { + const stashOutput = await runCommandWithOutput('git', ['stash', 'push', '--include-untracked', '-m', 'codex-skills-autostash'], { cwd: localDir }) + createdAutostash = !stashOutput.includes('No local changes to save') + } catch {} + let pulledMtimes = new Map() + await runGitFetchWithRefLockRetry(localDir, ['fetch', 'origin', branch]) + await runCommand('git', ['reset', '--hard', `origin/${branch}`], { cwd: localDir }) + pulledMtimes = await snapshotFileMtimes(localDir) + if (createdAutostash) { + try { + await runCommand('git', ['stash', 'pop'], { cwd: localDir }) + } catch { + await resolveStashPopConflictsByFileTime(localDir, localMtimesBeforePull, pulledMtimes) + } + } + return localDir +} + +async function resolveMergeConflictsByNewerCommit( + repoDir: string, + branch: string, + localMtimesBeforeSync: Map = new Map(), +): Promise { + // Keep resolving until merge/rebase no longer reports unmerged paths. + for (let i = 0; i < 20; i++) { + const unmerged = (await runCommandWithOutput('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: repoDir })) + .split(/\r?\n/) + .map((row) => row.trim()) + .filter(Boolean) + if (unmerged.length === 0) return + for (const path of unmerged) { + const localMtimeMs = localMtimesBeforeSync.get(path) ?? 0 + const localMtimeSec = Math.floor(localMtimeMs / 1000) + const remoteCommitTime = await getCommitTime(repoDir, `origin/${branch}`, path) + if (remoteCommitTime > localMtimeSec) { + await checkoutConflictSideWithFallback(repoDir, path, '--theirs') + } else { + await checkoutConflictSideWithFallback(repoDir, path, '--ours') + } + await runCommand('git', ['add', '--', path], { cwd: repoDir }) + } + const rebaseHead = await readOptionalGitRef(repoDir, 'REBASE_HEAD') + if (rebaseHead) { + try { + await runCommand('git', ['rebase', '--continue'], { cwd: repoDir }) + continue + } catch { + // Continue loop and resolve next rebase-conflict batch. + continue + } + } + const mergeHead = await readOptionalGitRef(repoDir, 'MERGE_HEAD') + if (mergeHead) { + await runCommand('git', ['commit', '-m', 'Auto-resolve skills merge by mtime policy'], { cwd: repoDir }) + continue + } + } + throw new Error('Auto-resolve exceeded retry limit while reconciling sync conflicts') +} + +async function readOptionalGitRef(repoDir: string, ref: string): Promise { + try { + return (await runCommandWithOutput('git', ['rev-parse', '-q', '--verify', ref], { cwd: repoDir })).trim() + } catch { + return '' + } +} + +async function listUnmergedStages(repoDir: string, path: string): Promise> { + const raw = (await runCommandWithOutput('git', ['ls-files', '-u', '--', path], { cwd: repoDir })).trim() + const stages = new Set() + if (!raw) return stages + for (const line of raw.split(/\r?\n/)) { + const parts = line.trim().split(/\s+/) + const stage = Number.parseInt(parts[2] ?? '', 10) + if (Number.isInteger(stage)) stages.add(stage) + } + return stages +} + +async function checkoutConflictSideWithFallback( + repoDir: string, + path: string, + preferredSide: '--ours' | '--theirs', +): Promise { + const stages = await listUnmergedStages(repoDir, path) + const hasOurs = stages.has(2) + const hasTheirs = stages.has(3) + if (!hasOurs && !hasTheirs) return + if (preferredSide === '--ours') { + if (hasOurs) { + await runCommand('git', ['checkout', '--ours', '--', path], { cwd: repoDir }) + return + } + await runCommand('git', ['checkout', '--theirs', '--', path], { cwd: repoDir }) + return + } + if (hasTheirs) { + await runCommand('git', ['checkout', '--theirs', '--', path], { cwd: repoDir }) + return + } + await runCommand('git', ['checkout', '--ours', '--', path], { cwd: repoDir }) +} + +async function getCommitTime(repoDir: string, ref: string, path: string): Promise { + try { + const output = (await runCommandWithOutput('git', ['log', '-1', '--format=%ct', ref, '--', path], { cwd: repoDir })).trim() + return output ? Number.parseInt(output, 10) : 0 + } catch { + return 0 + } +} + +async function resolveStashPopConflictsByFileTime( + repoDir: string, + localMtimesBeforePull: Map, + pulledMtimes: Map, +): Promise { + const unmerged = (await runCommandWithOutput('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: repoDir })) + .split(/\r?\n/) + .map((row) => row.trim()) + .filter(Boolean) + if (unmerged.length === 0) return + for (const path of unmerged) { + const localMtime = localMtimesBeforePull.get(path) ?? 0 + const pulledMtime = pulledMtimes.get(path) ?? 0 + const side = localMtime >= pulledMtime ? '--theirs' : '--ours' + await checkoutConflictSideWithFallback(repoDir, path, side) + await runCommand('git', ['add', '--', path], { cwd: repoDir }) + } + const mergeHead = await readOptionalGitRef(repoDir, 'MERGE_HEAD') + if (mergeHead) { + await runCommand('git', ['commit', '-m', 'Auto-resolve stash-pop conflicts by file time'], { cwd: repoDir }) + } +} + +async function snapshotFileMtimes(dir: string): Promise> { + const mtimes = new Map() + await walkFileMtimes(dir, dir, mtimes) + return mtimes +} + +async function hasLocalUncommittedChanges(repoDir: string): Promise { + const status = (await runCommandWithOutput('git', ['status', '--porcelain'], { cwd: repoDir })).trim() + return status.length > 0 +} + +async function hasCommittableWorkingTreeChanges(repoDir: string): Promise { + try { + await runCommand('git', ['diff', '--quiet', '--exit-code', '--ignore-submodules=dirty'], { cwd: repoDir }) + await runCommand('git', ['diff', '--cached', '--quiet', '--exit-code', '--ignore-submodules=dirty'], { cwd: repoDir }) + } catch { + return true + } + const untracked = (await runCommandWithOutput('git', ['ls-files', '--others', '--exclude-standard'], { cwd: repoDir })).trim() + return untracked.length > 0 +} + +async function walkFileMtimes(rootDir: string, currentDir: string, out: Map): Promise { + let entries: Array<{ name: string | Buffer; isDirectory: () => boolean; isFile: () => boolean }> + try { + entries = (await readdir(currentDir, { withFileTypes: true })) as Array<{ name: string | Buffer; isDirectory: () => boolean; isFile: () => boolean }> + } catch { + return + } + for (const entry of entries) { + const entryName = String(entry.name) + if (entryName === '.git') continue + const absolutePath = join(currentDir, entryName) + const relativePath = absolutePath.slice(rootDir.length + 1) + if (entry.isDirectory()) { + await walkFileMtimes(rootDir, absolutePath, out) + continue + } + if (!entry.isFile()) continue + try { + const info = await stat(absolutePath) + out.set(relativePath, info.mtimeMs) + } catch {} + } +} + +async function syncInstalledSkillsFolderToRepo( + token: string, + repoOwner: string, + repoName: string, + _installedMap: Map, +): Promise { + async function hasTrackedLocalFileChanges(repoDir: string, filePath: string): Promise { + const diffHead = (await runCommandWithOutput('git', ['diff', '--name-only', 'HEAD', '--', filePath], { cwd: repoDir })).trim() + if (diffHead.length > 0) return true + const diffCached = (await runCommandWithOutput('git', ['diff', '--cached', '--name-only', '--', filePath], { cwd: repoDir })).trim() + return diffCached.length > 0 + } + + async function restoreProtectedFilesFromOrigin(repoDir: string, branch: string): Promise { + const protectedFiles = ['AGENTS.md'] + for (const filePath of protectedFiles) { + const hasLocalEdits = await hasTrackedLocalFileChanges(repoDir, filePath) + if (hasLocalEdits) continue + try { + await runCommand('git', ['cat-file', '-e', `origin/${branch}:${filePath}`], { cwd: repoDir }) + } catch { + continue + } + await runCommand('git', ['checkout', `origin/${branch}`, '--', filePath], { cwd: repoDir }) + } + try { + await runCommand('git', ['cat-file', '-e', `origin/${branch}:shared_skills`], { cwd: repoDir }) + await runCommand('git', ['checkout', `origin/${branch}`, '--', 'shared_skills'], { cwd: repoDir }) + } catch { + // Ignore when the branch does not track the nested shared_skills gitlink. + } + } + + function isNonFastForwardPushError(error: unknown): boolean { + const text = getErrorMessage(error, '').toLowerCase() + return text.includes('non-fast-forward') + || text.includes('fetch first') + || (text.includes('rejected') && text.includes('push')) + } + + async function pushWithNonFastForwardRetry(repoDir: string, branch: string): Promise { + const maxAttempts = 3 + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const hasLocalChangesBeforeReconcile = await hasLocalUncommittedChanges(repoDir) + const localMtimesBeforeReconcile = hasLocalChangesBeforeReconcile ? await snapshotFileMtimes(repoDir) : new Map() + await runGitFetchWithRefLockRetry(repoDir) + try { + await runCommand('git', ['rebase', `origin/${branch}`], { cwd: repoDir }) + } catch { + try { await runCommand('git', ['rebase', '--abort'], { cwd: repoDir }) } catch {} + try { + await runCommand('git', ['pull', '--rebase', '--autostash', 'origin', branch], { cwd: repoDir }) + } catch { + await resolveMergeConflictsByNewerCommit(repoDir, branch, localMtimesBeforeReconcile) + await runCommand('git', ['pull', '--rebase', '--autostash', 'origin', branch], { cwd: repoDir }) + } + } + try { + await runCommand('git', ['push', '--no-recurse-submodules', 'origin', `HEAD:${branch}`], { cwd: repoDir }) + const state = await readSkillsSyncState() + const pushedHead = await runCommandWithOutput('git', ['rev-parse', 'HEAD'], { cwd: repoDir }) + await writeSkillsSyncState({ + ...state, + lastPushCommitSha: pushedHead.trim(), + lastSyncAttemptCount: attempt, + lastSyncError: '', + lastSyncAtIso: new Date().toISOString(), + }) + return + } catch (error) { + if (!isNonFastForwardPushError(error) || attempt >= maxAttempts) { + const state = await readSkillsSyncState() + await writeSkillsSyncState({ + ...state, + lastSyncAttemptCount: attempt, + lastSyncError: getErrorMessage(error, 'push failed'), + lastSyncAtIso: new Date().toISOString(), + }) + throw error + } + } + } + throw new Error('Failed to push after non-fast-forward retries') + } + + const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token) + const branch = PRIVATE_SYNC_BRANCH + const repoDir = await ensureSkillsWorkingTreeRepo(remoteUrl, branch) + void _installedMap + await runCommand('git', ['config', 'user.email', 'skills-sync@local'], { cwd: repoDir }) + await runCommand('git', ['config', 'user.name', 'Skills Sync'], { cwd: repoDir }) + await restoreProtectedFilesFromOrigin(repoDir, branch) + await runCommand('git', ['add', '.'], { cwd: repoDir }) + try { + await runCommand('git', ['diff', '--cached', '--quiet', '--exit-code'], { cwd: repoDir }) + return + } catch {} + await runCommand('git', ['commit', '-m', 'Sync skills files'], { cwd: repoDir }) + await pushWithNonFastForwardRetry(repoDir, branch) +} + +async function pullInstalledSkillsFolderFromRepo(token: string, repoOwner: string, repoName: string): Promise { + const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token) + const branch = PRIVATE_SYNC_BRANCH + await ensureSkillsWorkingTreeRepo(remoteUrl, branch) +} + +async function bootstrapSkillsFromUpstreamIntoLocal(): Promise { + const repoUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git` + const branch = getPreferredPublicUpstreamBranch() + await ensureSkillsWorkingTreeRepo(repoUrl, branch) +} + +async function autoPushSyncedSkills(_appServer: AppServerLike): Promise { + const state = await readSkillsSyncState() + if (!state.githubToken || !state.repoOwner || !state.repoName) return + if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) { + throw new Error('Refusing to push to upstream skills repository') + } + const repoDir = getSkillsInstallDir() + await runCommand('git', ['fetch', 'origin', PRIVATE_SYNC_BRANCH], { cwd: repoDir }) + const head = (await runCommandWithOutput('git', ['rev-parse', 'HEAD'], { cwd: repoDir })).trim() + const originHead = (await runCommandWithOutput('git', ['rev-parse', `origin/${PRIVATE_SYNC_BRANCH}`], { cwd: repoDir })).trim() + const hasCommittableChanges = await hasCommittableWorkingTreeChanges(repoDir) + // After a successful pull, if local tree is already clean and equal to remote, + // skip push entirely to avoid rewriting/deleting remote-only updates. + if (!hasCommittableChanges && head === originHead) return + const installedMap = await scanInstalledSkillsFromDisk() + await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap) +} + +async function ensureCodexAgentsSymlinkToSkillsAgents(): Promise { + const codexHomeDir = getCodexHomeDir() + const skillsAgentsPath = join(codexHomeDir, 'skills', 'AGENTS.md') + const codexAgentsPath = join(codexHomeDir, 'AGENTS.md') + await mkdir(join(codexHomeDir, 'skills'), { recursive: true }) + let copiedFromCodex = false + try { + const codexAgentsStat = await lstat(codexAgentsPath) + if (codexAgentsStat.isFile() || codexAgentsStat.isSymbolicLink()) { + const content = await readFile(codexAgentsPath, 'utf8') + await writeFile(skillsAgentsPath, content, 'utf8') + copiedFromCodex = true + } else { + await rm(codexAgentsPath, { force: true, recursive: true }) + } + } catch {} + if (!copiedFromCodex) { + try { + const skillsAgentsStat = await stat(skillsAgentsPath) + if (!skillsAgentsStat.isFile()) { + await rm(skillsAgentsPath, { force: true, recursive: true }) + await writeFile(skillsAgentsPath, '', 'utf8') + } + } catch { + await writeFile(skillsAgentsPath, '', 'utf8') + } + } + const relativeTarget = join('skills', 'AGENTS.md') + try { + const current = await lstat(codexAgentsPath) + if (current.isSymbolicLink()) { + const existingTarget = await readlink(codexAgentsPath) + if (existingTarget === relativeTarget) return + } + await rm(codexAgentsPath, { force: true, recursive: true }) + } catch {} + await symlink(relativeTarget, codexAgentsPath) +} + +async function runSkillsSyncStartup( + appServer: AppServerLike, + options: { propagateErrors?: boolean } = {}, +): Promise { + if (startupSyncStatus.inProgress) return + startupSyncStatus.inProgress = true + startupSyncStatus.lastRunAtIso = new Date().toISOString() + startupSyncStatus.lastError = '' + startupSyncStatus.branch = PRIVATE_SYNC_BRANCH + try { + const state = await readSkillsSyncState() + if (!state.githubToken) { + await ensureCodexAgentsSymlinkToSkillsAgents() + if (!isAndroidLikeRuntime()) { + startupSyncStatus.mode = 'idle' + startupSyncStatus.lastAction = 'skip-upstream-non-android' + startupSyncStatus.lastSuccessAtIso = new Date().toISOString() + return + } + startupSyncStatus.mode = 'unauthenticated-bootstrap' + startupSyncStatus.branch = getPreferredPublicUpstreamBranch() + startupSyncStatus.lastAction = 'pull-upstream' + await bootstrapSkillsFromUpstreamIntoLocal() + try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} + startupSyncStatus.lastSuccessAtIso = new Date().toISOString() + startupSyncStatus.lastAction = 'pull-upstream-complete' + return + } + startupSyncStatus.mode = 'authenticated-fork-sync' + startupSyncStatus.branch = PRIVATE_SYNC_BRANCH + startupSyncStatus.lastAction = 'ensure-private-fork' + const username = state.githubUsername || await resolveGithubUsername(state.githubToken) + const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME + await ensurePrivateForkFromUpstream(state.githubToken, username, repoName) + await writeSkillsSyncState({ ...state, githubUsername: username, repoOwner: username, repoName }) + startupSyncStatus.lastAction = 'pull-private-fork' + await pullInstalledSkillsFolderFromRepo(state.githubToken, username, repoName) + try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} + startupSyncStatus.lastAction = 'push-private-fork' + await autoPushSyncedSkills(appServer) + startupSyncStatus.lastSuccessAtIso = new Date().toISOString() + startupSyncStatus.lastAction = 'startup-sync-complete' + } catch (error) { + startupSyncStatus.lastError = getErrorMessage(error, 'startup-sync-failed') + startupSyncStatus.lastAction = 'startup-sync-failed' + if (options.propagateErrors) throw error + } finally { + startupSyncStatus.inProgress = false + } +} + +export async function initializeSkillsSyncOnStartup(appServer: AppServerLike): Promise { + if (startupSkillsSyncInitialized) return + startupSkillsSyncInitialized = true + await runSkillsSyncStartup(appServer) +} + +async function finalizeGithubLoginAndSync(token: string, username: string, appServer: AppServerLike): Promise { + const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME + await ensurePrivateForkFromUpstream(token, username, repoName) + const current = await readSkillsSyncState() + await writeSkillsSyncState({ ...current, githubToken: token, githubUsername: username, repoOwner: username, repoName }) + await pullInstalledSkillsFolderFromRepo(token, username, repoName) + try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} + await autoPushSyncedSkills(appServer) +} + export async function handleSkillsRoutes( req: IncomingMessage, res: ServerResponse, @@ -587,6 +1284,160 @@ export async function handleSkillsRoutes( return true } + if (req.method === 'GET' && url.pathname === '/codex-api/skills-sync/status') { + const state = await readSkillsSyncState() + setJson(res, 200, { + data: { + loggedIn: Boolean(state.githubToken), + githubUsername: state.githubUsername ?? '', + repoOwner: state.repoOwner ?? '', + repoName: state.repoName ?? '', + configured: Boolean(state.githubToken && state.repoOwner && state.repoName), + telemetry: { + lastPullCommitSha: state.lastPullCommitSha ?? '', + lastPushCommitSha: state.lastPushCommitSha ?? '', + lastSyncAttemptCount: state.lastSyncAttemptCount ?? 0, + lastSyncError: state.lastSyncError ?? '', + lastSyncAtIso: state.lastSyncAtIso ?? '', + }, + startup: { + inProgress: startupSyncStatus.inProgress, + mode: startupSyncStatus.mode, + branch: startupSyncStatus.branch, + lastAction: startupSyncStatus.lastAction, + lastRunAtIso: startupSyncStatus.lastRunAtIso, + lastSuccessAtIso: startupSyncStatus.lastSuccessAtIso, + lastError: startupSyncStatus.lastError, + }, + }, + }) + return true + } + + if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/start-login') { + try { + const started = await startGithubDeviceLogin() + setJson(res, 200, { data: started }) + } catch (error) { + setJson(res, 502, { error: getErrorMessage(error, 'Failed to start GitHub login') }) + } + return true + } + + if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/token-login') { + try { + const payload = asRecord(await readJsonBody(req)) + const token = typeof payload?.token === 'string' ? payload.token.trim() : '' + if (!token) { + setJson(res, 400, { error: 'Missing GitHub token' }) + return true + } + const username = await resolveGithubUsername(token) + await finalizeGithubLoginAndSync(token, username, appServer) + setJson(res, 200, { ok: true, data: { githubUsername: username } }) + } catch (error) { + setJson(res, 502, { error: getErrorMessage(error, 'Failed to login with GitHub token') }) + } + return true + } + + if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/logout') { + try { + const state = await readSkillsSyncState() + await writeSkillsSyncState({ + ...state, + githubToken: undefined, + githubUsername: undefined, + repoOwner: undefined, + repoName: undefined, + }) + setJson(res, 200, { ok: true }) + } catch (error) { + setJson(res, 500, { error: getErrorMessage(error, 'Failed to logout GitHub') }) + } + return true + } + + if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/github/complete-login') { + try { + const payload = asRecord(await readJsonBody(req)) + const deviceCode = typeof payload?.deviceCode === 'string' ? payload.deviceCode : '' + if (!deviceCode) { + setJson(res, 400, { error: 'Missing deviceCode' }) + return true + } + const result = await completeGithubDeviceLogin(deviceCode) + if (!result.token) { + setJson(res, 200, { ok: false, pending: result.error === 'authorization_pending', error: result.error || 'login_failed' }) + return true + } + const token = result.token + const username = await resolveGithubUsername(token) + await finalizeGithubLoginAndSync(token, username, appServer) + setJson(res, 200, { ok: true, data: { githubUsername: username } }) + } catch (error) { + setJson(res, 502, { error: getErrorMessage(error, 'Failed to complete GitHub login') }) + } + return true + } + + if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/push') { + try { + const state = await readSkillsSyncState() + if (!state.githubToken || !state.repoOwner || !state.repoName) { + setJson(res, 400, { error: 'Skills sync is not configured yet' }) + return true + } + if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) { + setJson(res, 400, { error: 'Refusing to push to upstream repository' }) + return true + } + const installedMap = await collectInstalledSkillsMap(appServer) + await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap) + setJson(res, 200, { ok: true, data: { synced: installedMap.size } }) + } catch (error) { + setJson(res, 502, { error: getErrorMessage(error, 'Failed to push synced skills') }) + } + return true + } + + if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/startup-sync') { + try { + await runSkillsSyncStartup(appServer, { propagateErrors: true }) + setJson(res, 200, { ok: true }) + } catch (error) { + setJson(res, 502, { error: getErrorMessage(error, 'Failed to run startup sync') }) + } + return true + } + + if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/pull') { + try { + const state = await readSkillsSyncState() + if (!state.githubToken || !state.repoOwner || !state.repoName) { + await bootstrapSkillsFromUpstreamIntoLocal() + try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} + setJson(res, 200, { ok: true, data: { synced: 0, source: 'upstream' } }) + return true + } + await pullInstalledSkillsFolderFromRepo(state.githubToken, state.repoOwner, state.repoName) + const localSkills = await scanInstalledSkillsFromDisk() + const pulledHead = await runCommandWithOutput('git', ['rev-parse', 'HEAD'], { cwd: getSkillsInstallDir() }).catch(() => '') + await writeSkillsSyncState({ + ...state, + lastPullCommitSha: pulledHead.trim(), + lastSyncAttemptCount: 1, + lastSyncError: '', + lastSyncAtIso: new Date().toISOString(), + }) + try { await appServer.rpc('skills/list', { forceReload: true }) } catch {} + setJson(res, 200, { ok: true, data: { synced: localSkills.size } }) + } catch (error) { + setJson(res, 502, { error: getErrorMessage(error, 'Failed to pull synced skills') }) + } + return true + } + if (req.method === 'GET' && url.pathname === '/codex-api/skills-hub/readme') { try { const owner = url.searchParams.get('owner') || '' @@ -635,6 +1486,7 @@ export async function handleSkillsRoutes( throw new Error(`Skill install completed but ${installSource} was not found in local installed skills`) } await ensureInstalledSkillIsValid(appServer, installed.path) + autoPushSyncedSkills(appServer).catch(() => {}) setJson(res, 200, { ok: true, path: installed.path }) } catch (error) { setJson(res, 502, { error: getErrorMessage(error, 'Failed to install skill') }) @@ -654,6 +1506,7 @@ export async function handleSkillsRoutes( return true } await rm(target, { recursive: true, force: true }) + autoPushSyncedSkills(appServer).catch(() => {}) try { await withTimeout(appServer.rpc('skills/list', { forceReload: true }), 10_000, 'skills/list reload') } catch {} setJson(res, 200, { ok: true, deletedPath: target }) } catch (error) { diff --git a/src/style.css b/src/style.css index 133bdfae4..86e958aa3 100644 --- a/src/style.css +++ b/src/style.css @@ -1353,6 +1353,39 @@ @apply text-zinc-500; } +:root.dark .skills-sync-panel { + @apply border-zinc-700 bg-zinc-800; +} + +:root.dark .skills-sync-header { + @apply text-zinc-300; +} + +:root.dark .skills-sync-badge { + @apply border-zinc-600 bg-zinc-700 text-zinc-200; +} + +:root.dark .skills-sync-badge-link { + @apply text-zinc-200 hover:text-white hover:border-zinc-500; +} + +:root.dark .skills-sync-device, +:root.dark .skills-sync-meta { + @apply text-zinc-400; +} + +:root.dark .skills-sync-device a { + @apply text-blue-400 hover:text-blue-300; +} + +:root.dark .skills-sync-device code { + @apply border border-zinc-600 bg-zinc-700 px-1.5 py-0.5 rounded text-zinc-200; +} + +:root.dark .skills-sync-error { + @apply border-rose-800 bg-rose-950/40 text-rose-200; +} + :root.dark .skills-search-panel { @apply border-zinc-700 bg-zinc-900; } diff --git a/tests.md b/tests.md index 8a119e80a..d05eef1a6 100644 --- a/tests.md +++ b/tests.md @@ -342,6 +342,41 @@ The accidental `npx run dev` command starts the repository dev wrapper instead o #### Rollback/Cleanup - Stop any dev server process started for validation. +--- + +### Skills sync generic file sync and nested shared skills handling + +#### Feature/Change Name +Skills Sync treats the skills repository as generic files and does not fail parent commits when only nested `shared_skills` content is dirty. + +#### Prerequisites/Setup +1. Dev server running (`pnpm run dev --host 127.0.0.1 --port 5173`) +2. GitHub Skills Sync is connected to a private skills sync repo +3. `/Users/igor/.codex/skills/shared_skills` exists as a nested Git repository +4. Light theme and dark theme are available from the appearance switcher + +#### Steps +1. In light theme, open `#/skills`. +2. Click `Startup Sync` when no tracked skill file content has changed. +3. Confirm the sync completes without adding a new manifest-only commit to the GitHub repo. +4. Modify a file inside `/Users/igor/.codex/skills/shared_skills` without committing it inside that nested repository. +5. Click `Push` or `Startup Sync` again. +6. Confirm the sync does not show a no-change `git commit` failure for the parent `/Users/igor/.codex/skills` repository. +7. Confirm the startup auto-push path skips when the only local status is dirty nested `shared_skills` content and local `HEAD` equals `origin/main`. +8. Switch to dark theme and repeat steps 1, 2, and 5. + +#### Expected Results +- Skills Sync does not read, write, or rely on `installed-skills.json`; the repository file tree is the sync source of truth. +- A missing `installed-skills.json` does not delete local skills during Pull or Startup Sync. +- A dirty nested `shared_skills` repository does not make the parent skills sync fail with `no changes added to commit`. +- Dirty nested `shared_skills` content alone does not keep triggering no-op startup push work. +- Skills Sync status, errors, and action buttons remain readable in light theme and dark theme. + +#### Rollback/Cleanup +- Revert or commit the intentional test edit inside `/Users/igor/.codex/skills/shared_skills`. + +--- + ### Header Git branch dropdown with commit reset #### Feature/Change Name @@ -1301,6 +1336,238 @@ Model, skill, thinking, and plan controls remain usable while a thread turn is i #### Rollback/Cleanup - No cleanup required. +### Feature: Skills sync pull live-reloads installed skills list + +#### Prerequisites +- App running from this repository with Skills Hub available. +- GitHub skills sync configured and connected. +- At least one skill update available in the sync source (new or edited skill metadata). + +#### Steps +1. Open the app and note the currently visible installed skills for the active thread cwd. +2. In Skills Hub, trigger `Pull` from GitHub sync. +3. Wait for the pull success toast. +4. Without restarting the app/server, navigate to thread composer skill picker and verify the installed skills list. +5. Switch to another thread and back to force a normal UI refresh path. + +#### Expected Results +- Pull completes successfully. +- Installed skills list reflects pulled changes immediately without app/server restart. +- Thread switch keeps showing the updated skills list (no stale cache rollback). + +#### Rollback/Cleanup +- If needed, run another sync pull/push to restore previous skill state in the sync repo. + +### Feature: Force Refresh Skills button in Skills Sync panel + +#### Prerequisites +- App running from this repository with Skills Hub route accessible. +- At least one installed skill is available for the current thread cwd. + +#### Steps +1. Open `Skills Hub`. +2. In `Skills Sync (GitHub)`, click `Force Refresh Skills`. +3. Verify button text changes to `Refreshing...` during the request and returns after completion. +4. Verify success toast appears. +5. Open the thread composer skills picker and confirm installed skills list is present and current. +6. Switch to another thread and back to ensure refreshed list remains consistent. + +#### Expected Results +- `Force Refresh Skills` triggers a manual refresh without requiring pull/push. +- Loading state prevents duplicate clicks while refresh is in progress. +- Installed skills list updates immediately and remains updated across thread switches. + +#### Rollback/Cleanup +- No cleanup required. + +### Feature: SkillHub shows detailed skill load errors + +#### Prerequisites +- App running from this repository. +- At least one invalid installed skill file exists (for example unresolved merge markers in `SKILL.md`). + +#### Steps +1. Open `Skills Hub`. +2. Trigger `Force Refresh Skills`. +3. Locate the `Some skills failed to load` panel above the skills sections. +4. Verify each row shows: + - the failing `SKILL.md` path + - the exact parser error message from app server (for example invalid YAML line/column details). +5. Fix the invalid skill file and trigger `Force Refresh Skills` again. + +#### Expected Results +- SkillHub surfaces app-server load failures with detailed path and message. +- Messages are specific enough to identify the broken file and parser failure reason. +- Error panel disappears after invalid skills are fixed and refreshed. + +#### Rollback/Cleanup +- Restore any intentionally broken local skill files used for testing. + +### Feature: Startup sync preserves local skill edits when remote is ahead + +#### Prerequisites +- Skills sync configured to a private GitHub fork. +- Local skills repo has a tracked edit in an existing skill file. +- Remote `main` has at least one newer commit than local (simulate from another machine or commit directly on GitHub). + +#### Steps +1. Edit a local skill file (for example update description text in `SKILL.md`) and keep the change. +2. Trigger `Startup Sync` in Skills Hub. +3. If a non-fast-forward condition exists, allow startup sync to complete retry path. +4. Re-open the same local skill file and verify your edit remains. +5. Trigger `Force Refresh Skills` and verify no unexpected skill removals occurred. + +#### Expected Results +- Startup sync no longer fails with non-fast-forward push due to missing remote integration. +- Local tracked skill edits remain after sync (not overwritten by remote state). +- Sync path rebases/pulls with autostash and auto-resolves conflicts by mtime policy: + - choose remote (`theirs`) when remote file commit time is newer than local file mtime. + - choose local (`ours`) otherwise. +- No manual conflict intervention is required during startup sync retries. + +#### Rollback/Cleanup +- Revert test-only skill text changes if they were not intended to keep. + +### Feature: Startup sync conflict fallback when one side is missing + +#### Prerequisites +- Skills sync repo contains a conflict candidate where only one side exists for a path (for example delete/modify scenario). +- Skills Hub is accessible. + +#### Steps +1. Open `Skills Hub`. +2. Click `Startup Sync`. +3. Wait for sync completion or error toast. +4. Verify no toast/error contains `does not have our version`. + +#### Expected Results +- Sync conflict resolver handles missing `--ours`/`--theirs` versions safely. +- Startup sync does not fail with `git checkout --ours/--theirs` missing-version errors. + +#### Rollback/Cleanup +- None. + +### Feature: Remote changes win when no local uncommitted skill edits exist + +#### Prerequisites +- Skills sync configured with GitHub. +- Local skills repo working tree is clean (`git status --porcelain` empty under skills dir). +- Remote skills repo has newer commits touching existing skill files. + +#### Steps +1. Confirm no local uncommitted changes in skills directory. +2. Trigger `Startup Sync` in Skills Hub. +3. After sync, inspect the skill file changed remotely. +4. Trigger `Force Refresh Skills` and confirm loaded skill content matches remote update. + +#### Expected Results +- Sync pull/reconcile does not preserve stale local file content when local tree is clean. +- Remote updates are applied locally and remain after startup sync completes. + +#### Rollback/Cleanup +- None. + +### Feature: Startup sync does not delete remote AGENTS.md + +#### Prerequisites +- Skills sync configured to `friuns2/codexskills`. +- Remote `main` contains `AGENTS.md`. +- Local skills repo is clean before startup sync. + +#### Steps +1. Confirm remote `AGENTS.md` exists on `main`. +2. Confirm local `~/.codex/skills` is clean. +3. Trigger `Startup Sync`. +4. After completion, inspect latest commit created by sync (if any). +5. Verify `AGENTS.md` still exists locally and in remote `origin/main`. + +#### Expected Results +- Startup sync may update skill files, but must not delete `AGENTS.md`. +- If sync creates a commit, changed files do not include `D AGENTS.md`. +- Local and remote `AGENTS.md` hashes remain equal after sync. + +#### Rollback/Cleanup +- None. + +### Feature: Bidirectional AGENTS.md sync via Startup Sync + +#### Prerequisites +- Skills sync configured to `friuns2/codexskills`. +- `~/.codex/skills` is a clean git working tree before each sub-test. +- Skills Hub startup sync endpoint is reachable. + +#### Steps +1. Remote -> Local: +2. Add a unique marker to remote `AGENTS.md` on `main`. +3. Confirm local `HEAD` is behind `origin/main`. +4. Trigger `Startup Sync`. +5. Verify local `AGENTS.md` contains the remote marker and local `HEAD == origin/main`. +6. Local -> Remote: +7. Add a different unique marker to local `~/.codex/skills/AGENTS.md`. +8. Confirm local working tree shows `M AGENTS.md`. +9. Trigger `Startup Sync`. +10. Verify remote `origin/main:AGENTS.md` contains the local marker and local `HEAD == origin/main`. + +#### Expected Results +- Remote-only AGENTS edits are pulled into local without deletion. +- Local AGENTS edits are pushed to remote after startup sync. +- After each sync direction, local and remote commit SHAs match. + +#### Rollback/Cleanup +- Remove temporary test markers from `AGENTS.md` if required. + +### Feature: Mixed local+remote AGENTS edits do not stall Startup Sync + +#### Prerequisites +- Skills sync configured and working. +- Local skills repo clean before test start. + +#### Steps +1. Add marker `A` to remote `AGENTS.md`. +2. Add marker `B` to local `AGENTS.md` before syncing. +3. Trigger `Startup Sync`. +4. Wait for startup status to finish (`inProgress=false`). +5. Verify sync outcome explicitly: +6. If sync succeeds, local/remote SHAs match and expected merged marker result is present. +7. If sync fails, status includes a concrete error message (not silent success). + +#### Expected Results +- Startup sync must not report success while local remains behind remote. +- No stale stash side-effects are introduced (no unexpected conflict from old stash entries). +- Final state is either a valid synchronized result or an explicit failure status with actionable error. + +#### Rollback/Cleanup +- Reset local skills repo to `origin/main` after test if needed. + +### Feature: Startup sync uses deterministic pull reconcile (`fetch + reset --hard`) before local replay + +#### Prerequisites +- Skills sync is logged in and targets `friuns2/codexskills`. +- Local repo path is `~/.codex/skills`. +- Startup Sync endpoint is reachable at `/codex-api/skills-sync/startup-sync`. + +#### Steps +1. Remote-only case: +2. Commit a unique marker to remote `AGENTS.md` on `main`. +3. Ensure local repo is clean and reset to `origin/main`, then trigger `Startup Sync`. +4. Confirm marker appears locally and `HEAD == origin/main`. +5. Local-only case: +6. Add a unique local marker to `~/.codex/skills/AGENTS.md` (uncommitted), trigger `Startup Sync`. +7. Confirm marker is pushed and `HEAD == origin/main` with clean worktree. +8. Mixed case: +9. Add local marker first, then commit a newer remote marker. +10. Trigger `Startup Sync` and verify mtime policy result (newer remote marker wins, older local marker dropped). +11. Confirm final state is clean with `HEAD == origin/main`. + +#### Expected Results +- Startup sync does not fail with missing merge refs (`MERGE_HEAD`/`REBASE_HEAD`) in this path. +- Remote-only changes are always pulled first and visible locally. +- Local-only changes are preserved and pushed during the same startup sync run. +- Mixed local+remote edits converge automatically with no manual conflict handling. + +#### Rollback/Cleanup +- Remove temporary test markers from `AGENTS.md` if not needed. + ### Feature: Revert Renat scrolling/input-layout behavior (without Fast mode changes) #### Prerequisites @@ -4287,30 +4554,3 @@ Managed worktree threads remain visible under their matching canonical workspace #### Rollback/Cleanup - None. - ---- - -### Remove GitHub skills sync from Skills Hub - -#### Feature/Change Name -Skills Hub is local-only and no longer shows or triggers any GitHub skills sync workflow. - -#### Prerequisites/Setup -1. Dev server running (`pnpm run dev --host 127.0.0.1 --port 4173`) -2. At least one installed skill is available locally -3. Light theme and dark theme are available from the appearance switcher - -#### Steps -1. In light theme, open `#/skills`. -2. Confirm there is no `Skills Sync (GitHub)` panel and no login, pull, push, or startup sync controls. -3. Search for a skill and open an installed skill from the `Installed skills` section. -4. Toggle the installed skill enabled state and confirm the UI updates without any sync-related error toast. -5. Switch to dark theme and repeat steps 1, 2, and 4. - -#### Expected Results -- Skills Hub only shows local installed-skill management and registry search UI. -- No GitHub sync actions or status copy remain visible in light theme or dark theme. -- Enabling or disabling a local skill still works after the sync feature removal. - -#### Rollback/Cleanup -- Revert any installed-skill enablement change made during the test if needed.