Skip to content

Make skills sync use generic file sync#136

Open
friuns2 wants to merge 2 commits into
mainfrom
codex/generic-skills-sync
Open

Make skills sync use generic file sync#136
friuns2 wants to merge 2 commits into
mainfrom
codex/generic-skills-sync

Conversation

@friuns2
Copy link
Copy Markdown
Owner

@friuns2 friuns2 commented May 6, 2026

Summary

  • remove installed-skills.json as the source of truth for GitHub skills sync
  • make pull/startup sync use the repository file tree directly and reload skills after syncing
  • propagate manual Startup Sync failures to the HTTP caller while preserving background error recording
  • update manual test coverage for generic file sync behavior

Tests

  • git diff --check
  • pnpm run build:frontend
  • pnpm run build:cli

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Make skills sync use generic file tree instead of manifest

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Remove installed-skills.json manifest as sync source of truth
• Use repository file tree directly for skills sync operations
• Propagate manual startup sync errors to HTTP caller
• Simplify sync logic by eliminating manifest read/write functions
Diagram
flowchart LR
  A["Skills Sync Flow"] --> B["Read Remote Manifest"]
  A --> C["Collect Local Skills"]
  B --> D["Old: Manifest-based"]
  C --> D
  D --> E["Write Manifest"]
  E --> F["Sync Folder"]
  
  A -.->|After| G["Generic File Sync"]
  G --> H["Pull Folder from Repo"]
  G --> I["Scan Disk Files"]
  H --> J["New: File Tree-based"]
  I --> J
  J --> K["Sync Folder Only"]
  K --> L["No Manifest Operations"]
Loading

Grey Divider

File Changes

1. src/server/skillsRoutes.ts ✨ Enhancement +10/-143

Remove manifest-based sync, use file tree directly

• Removed detectUserSkillsDir() function that queried skills list RPC
• Removed readRemoteSkillsManifest() and writeRemoteSkillsManifest() functions that handled
 installed-skills.json
• Removed SyncedSkill type and installedOwners field from SkillsSyncState
• Removed collectLocalSyncedSkills() function that built manifest from installed skills
• Updated runSkillsSyncStartup() to accept propagateErrors option for manual sync error handling
• Updated autoPushSyncedSkills() to skip manifest operations and use file tree directly
• Updated pull sync endpoint to remove manifest reading and owner tracking logic
• Updated push sync endpoint to return synced count from installed map instead of manifest
• Updated skill deletion to remove owner tracking from sync state

src/server/skillsRoutes.ts


2. tests.md 📝 Documentation +8/-7

Update test documentation for generic file sync

• Updated test case title from "idempotent commits" to "generic file sync"
• Updated feature description to reflect file tree-based sync instead of manifest-based
• Updated test steps to reference "tracked skill files" instead of "manifest content"
• Updated expected results to confirm no manifest operations occur
• Updated related test case to reference "skill files" instead of "manifest"

tests.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 6, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0)

Grey Divider


Action required

1. Enabled state not synced 🐞 Bug ≡ Correctness
Description
The sync flow no longer propagates per-skill enabled/disabled state: push commits the skills
directory as-is while discarding the collected enabled flags, and pull no longer applies any remote
enabled state back via skills/config/write. As a result, toggling a skill enabled/disabled can
fail to reproduce on another machine after pull/startup-sync.
Code

src/server/skillsRoutes.ts[R1423-1434]

      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<string, string> = {}
-      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 } })
+      setJson(res, 200, { ok: true, data: { synced: localSkills.size } })
Evidence
The UI toggles enabled/disabled via skills/config/write and then triggers /skills-sync/push,
implying enablement is part of what should be synced. On the server, push still collects enabled
state but the sync implementation explicitly ignores it (void _installedMap) and just
stages/commits the git working tree, and pull no longer performs any skills/config/write calls
after pulling to re-apply enabled state.

src/components/content/SkillsHub.vue[327-336]
src/server/skillsRoutes.ts[1384-1397]
src/server/skillsRoutes.ts[1099-1112]
src/server/skillsRoutes.ts[1414-1434]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Enabled/disabled status is no longer synchronized across devices because push ignores the collected `enabled` flags and pull no longer applies remote enablement back into local configuration.

### Issue Context
- UI toggles enabled state via `skills/config/write` and then calls `/skills-sync/push`.
- Server sync now commits the skills working tree and discards the installed map that contains `enabled`.
- Pull/startup-sync reloads skills but does not apply enablement.

### Fix Focus Areas
- src/server/skillsRoutes.ts[1099-1112]
- src/server/skillsRoutes.ts[1384-1397]
- src/server/skillsRoutes.ts[1414-1434]
- src/components/content/SkillsHub.vue[327-336]

### Suggested fix
Implement a file-tree source of truth for enablement (since the PR goal is “generic file sync”), e.g.:
1) Write a tracked config file under the skills repo (e.g. `.codex/skills-enabled.json` or similar) during push that contains `{ [skillName]: boolean }` derived from `collectInstalledSkillsMap(appServer)`.
2) On pull/startup-sync, after `pullInstalledSkillsFolderFromRepo(...)`, read that file (if present) and apply it via `appServer.rpc('skills/config/write', { path, enabled })` for each affected skill, then `skills/list` with `forceReload: true`.
3) Ensure missing config file does not break sync (treat as “no enablement overrides”).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. GitHub token in errors 🐞 Bug ⛨ Security
Description
Git command failures can leak the GitHub token because toGitHubTokenRemote() embeds it in the
remote URL and runCommand() includes full arguments in the thrown error message. With
propagateErrors: true for the manual startup-sync endpoint, these token-bearing errors can now be
returned to the HTTP caller/UI.
Code

src/server/skillsRoutes.ts[R1404-1409]

  if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/startup-sync') {
    try {
-      await runSkillsSyncStartup(appServer)
+      await runSkillsSyncStartup(appServer, { propagateErrors: true })
      setJson(res, 200, { ok: true })
    } catch (error) {
      setJson(res, 502, { error: getErrorMessage(error, 'Failed to run startup sync') })
Evidence
Authenticated git remotes are constructed with the token embedded in the URL. When a spawned command
fails, runCommand throws an Error that includes command and args.join(' '), which can include
that URL. Manual startup-sync now calls runSkillsSyncStartup(..., { propagateErrors: true }),
which rethrows errors to the route handler; the handler returns getErrorMessage(error, ...) in the
HTTP response, potentially exposing the token.

src/server/skillsRoutes.ts[789-791]
src/server/skillsRoutes.ts[121-158]
src/server/skillsRoutes.ts[1226-1229]
src/server/skillsRoutes.ts[1404-1410]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
GitHub access tokens can be exposed in HTTP responses/UI because the token is embedded in git remote URLs and command failure messages include full arguments.

### Issue Context
- `toGitHubTokenRemote()` builds `https://x-access-token:*******@github.com/...`.
- `runCommand()` throws `Command failed (cmd args...)` including those args.
- Manual startup sync now propagates errors to the HTTP handler, which returns the error message.

### Fix Focus Areas
- src/server/skillsRoutes.ts[789-791]
- src/server/skillsRoutes.ts[121-158]
- src/server/skillsRoutes.ts[1226-1229]
- src/server/skillsRoutes.ts[1404-1410]

### Suggested fix
Pick one (best to do both):
1) **Redact secrets in error messages**: before constructing/returning error messages, scrub patterns like `x-access-token:<anything>@github.com` or more generally replace the token portion with `x-access-token:[REDACTED]@github.com`.
2) **Avoid embedding tokens in argv**: use a credential helper/`GIT_ASKPASS`, or pass auth via git http extraheader (so the token never appears in the command string/args).

Also ensure `startupSyncStatus.lastError` and `lastSyncError` store redacted messages.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

3. Push does extra RPC 🐞 Bug ➹ Performance
Description
/skills-sync/push performs collectInstalledSkillsMap(appServer) (RPC + disk scan) but the sync
implementation ignores the map (void _installedMap), so the RPC work is only used to return a
count. This adds avoidable latency and failure surface to pushes.
Code

src/server/skillsRoutes.ts[R1395-1397]

      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 } })
+      setJson(res, 200, { ok: true, data: { synced: installedMap.size } })
Evidence
The push route always calls collectInstalledSkillsMap(appServer) and passes it to
syncInstalledSkillsFolderToRepo. Inside syncInstalledSkillsFolderToRepo, the parameter is
immediately discarded (void _installedMap), so the RPC-derived data does not influence the actual
sync.

src/server/skillsRoutes.ts[1384-1397]
src/server/skillsRoutes.ts[1099-1103]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Push performs an RPC to build `installedMap`, but the sync function ignores that parameter, so the RPC is unnecessary for syncing.

### Issue Context
The only current usage of `installedMap` in the push route is for the response count.

### Fix Focus Areas
- src/server/skillsRoutes.ts[1384-1397]
- src/server/skillsRoutes.ts[1099-1103]

### Suggested fix
Either:
- Replace `collectInstalledSkillsMap(appServer)` with `scanInstalledSkillsFromDisk()` (no RPC) if you still want a count, or
- Remove the count entirely (always return `{ ok: true }`), or
- If enablement syncing is reintroduced, then keep the RPC and actually persist/apply its data (see enabled-state finding).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines 1423 to +1434
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<string, string> = {}
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 } })
setJson(res, 200, { ok: true, data: { synced: localSkills.size } })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Enabled state not synced 🐞 Bug ≡ Correctness

The sync flow no longer propagates per-skill enabled/disabled state: push commits the skills
directory as-is while discarding the collected enabled flags, and pull no longer applies any remote
enabled state back via skills/config/write. As a result, toggling a skill enabled/disabled can
fail to reproduce on another machine after pull/startup-sync.
Agent Prompt
### Issue description
Enabled/disabled status is no longer synchronized across devices because push ignores the collected `enabled` flags and pull no longer applies remote enablement back into local configuration.

### Issue Context
- UI toggles enabled state via `skills/config/write` and then calls `/skills-sync/push`.
- Server sync now commits the skills working tree and discards the installed map that contains `enabled`.
- Pull/startup-sync reloads skills but does not apply enablement.

### Fix Focus Areas
- src/server/skillsRoutes.ts[1099-1112]
- src/server/skillsRoutes.ts[1384-1397]
- src/server/skillsRoutes.ts[1414-1434]
- src/components/content/SkillsHub.vue[327-336]

### Suggested fix
Implement a file-tree source of truth for enablement (since the PR goal is “generic file sync”), e.g.:
1) Write a tracked config file under the skills repo (e.g. `.codex/skills-enabled.json` or similar) during push that contains `{ [skillName]: boolean }` derived from `collectInstalledSkillsMap(appServer)`.
2) On pull/startup-sync, after `pullInstalledSkillsFolderFromRepo(...)`, read that file (if present) and apply it via `appServer.rpc('skills/config/write', { path, enabled })` for each affected skill, then `skills/list` with `forceReload: true`.
3) Ensure missing config file does not break sync (treat as “no enablement overrides”).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines 1404 to 1409
if (req.method === 'POST' && url.pathname === '/codex-api/skills-sync/startup-sync') {
try {
await runSkillsSyncStartup(appServer)
await runSkillsSyncStartup(appServer, { propagateErrors: true })
setJson(res, 200, { ok: true })
} catch (error) {
setJson(res, 502, { error: getErrorMessage(error, 'Failed to run startup sync') })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Github token in errors 🐞 Bug ⛨ Security

Git command failures can leak the GitHub token because toGitHubTokenRemote() embeds it in the
remote URL and runCommand() includes full arguments in the thrown error message. With
propagateErrors: true for the manual startup-sync endpoint, these token-bearing errors can now be
returned to the HTTP caller/UI.
Agent Prompt
### Issue description
GitHub access tokens can be exposed in HTTP responses/UI because the token is embedded in git remote URLs and command failure messages include full arguments.

### Issue Context
- `toGitHubTokenRemote()` builds `https://x-access-token:*******@github.com/...`.
- `runCommand()` throws `Command failed (cmd args...)` including those args.
- Manual startup sync now propagates errors to the HTTP handler, which returns the error message.

### Fix Focus Areas
- src/server/skillsRoutes.ts[789-791]
- src/server/skillsRoutes.ts[121-158]
- src/server/skillsRoutes.ts[1226-1229]
- src/server/skillsRoutes.ts[1404-1410]

### Suggested fix
Pick one (best to do both):
1) **Redact secrets in error messages**: before constructing/returning error messages, scrub patterns like `x-access-token:<anything>@github.com` or more generally replace the token portion with `x-access-token:[REDACTED]@github.com`.
2) **Avoid embedding tokens in argv**: use a credential helper/`GIT_ASKPASS`, or pass auth via git http extraheader (so the token never appears in the command string/args).

Also ensure `startupSyncStatus.lastError` and `lastSyncError` store redacted messages.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants