Skip to content

Commit d00a7a2

Browse files
hottaigerzhangshuo12
andauthored
fix: sync global Native Skills during update (#266)
* fix: sync global Native Skills during update Global scope used to hardcode the Skill workflowSelection to 'classic', which filtered out comet-native/* and left global Native installs frozen at their first-install version. Mirror comet init by selecting 'both' for global Skills so Native files stay in sync. Rules and hooks keep the Classic selection (their workflowSelection is effectively ignored and Native write-guarding is routed through comet-hook-router). Closes #262 * refactor: address review feedback on global Native Skill update - Extract skillWorkflowSelectionFor helper so the global-vs-project Skill selection lives in one place instead of two duplicated ternaries. - Seed a stale comet-native Skill in the regression test and assert it is overwritten, proving existing installs are refreshed, not just created. - Document why the test silences console.log. Follow-up to #262. * fix: derive global Skill selection from disk and count filtered Skills Address maintainer feedback on #262. Selection by installed workflow: - skillWorkflowSelectionFor now checks whether comet-native/SKILL.md exists on disk for global targets instead of always returning 'both'. An existing Native install selects 'both' so it stays current; a Classic-only global install keeps 'classic' so update never adds Native Skills the user did not choose. Project scope still honors the project workflow configuration. Honest skipped counts: - copyCometSkillsForPlatform and installSkillsAsSymlink now add the number of manifest entries filtered out by the workflow selection to the skipped total, so a Classic-only update reports the Native files it intentionally skips instead of hiding them. Tests: - The Native-sync regression test now asserts zero skipped Skills for a Native install (both selection copies everything). - New test verifies a Classic-only global install stays Native-free after update and reports the filtered Native entries as skipped. * fix: derive all three global Skill selections from disk Check both comet-native/SKILL.md and comet-classic/SKILL.md so a Native-only global install (comet init --scope global --workflow native) selects 'native' and update does not add Classic Skills, mirroring the Classic-only protection already in place. neither/both markers map to classic/both respectively. Tests cover all three derivations: - both markers -> 'both' (skipped 0, stale Native refreshed) - classic only -> 'classic' (Native not added, skipped > 0) - native only -> 'native' (Classic not added, skipped > 0) * refactor: move workflow marker detection into skill domain Extract detectInstalledWorkflowSelection into domains/skill/platform-install.ts so the marker-to-selection mapping (comet-native/SKILL.md + comet-classic/SKILL.md -> classic|native|both) lives with the rest of the Skill install domain logic instead of the command orchestration layer. update.ts now computes the skills root and delegates the detection, keeping the global/project behavior unchanged. Follow-up to architecture feedback on #262. --------- Co-authored-by: zhangshuo12 <zhangshuo12@guazi.com>
1 parent 2f45917 commit d00a7a2

3 files changed

Lines changed: 224 additions & 7 deletions

File tree

app/commands/update.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import {
1616
copyCometSkillsForPlatform,
1717
copyCometRulesForPlatform,
18+
detectInstalledWorkflowSelection,
1819
installCometHooksForPlatform,
1920
getManifestSkills,
2021
mergeProjectConfig,
@@ -1542,9 +1543,24 @@ async function updateSingleProject(
15421543
);
15431544
}
15441545

1545-
const targetWorkflowSelections = targets.map((target) =>
1546-
target.scope === 'global' ? 'classic' : projectWorkflowSelection,
1547-
);
1546+
// Global scope mirrors `comet init`: `comet update --scope global` must keep
1547+
// already-installed Skills in sync without expanding the workflow range the
1548+
// user chose at install time. The selection is derived from what is on disk
1549+
// by checking the two workflow markers (see detectInstalledWorkflowSelection
1550+
// in domains/skill/platform-install.ts). Project scope keeps honoring the
1551+
// project workflow configuration.
1552+
const skillWorkflowSelectionFor = async (
1553+
target: InstalledCometTarget,
1554+
): Promise<InitWorkflowSelection> => {
1555+
if (target.scope !== 'global') return projectWorkflowSelection;
1556+
const skillsRoot = path.join(
1557+
getBaseDir('global', projectPath),
1558+
getPlatformSkillsDir(target.platform, 'global'),
1559+
'skills',
1560+
);
1561+
return detectInstalledWorkflowSelection(skillsRoot);
1562+
};
1563+
const targetWorkflowSelections = await Promise.all(targets.map(skillWorkflowSelectionFor));
15481564
const updateSkillPaths = new Set(
15491565
(
15501566
await Promise.all(
@@ -1571,8 +1587,7 @@ async function updateSingleProject(
15711587
const languageSkillsDir = languageToSkillsDir(languageId);
15721588
const targetInstallMode = installModeFor(target);
15731589
const nativeProjectTarget = nativeProject && target.scope === 'project';
1574-
const targetWorkflowSelection =
1575-
target.scope === 'global' ? 'classic' : projectWorkflowSelection;
1590+
const targetSkillWorkflowSelection = await skillWorkflowSelectionFor(target);
15761591
if (target.scope === 'project') {
15771592
await assertClassicProjectMutationAllowed?.();
15781593
}
@@ -1581,7 +1596,7 @@ async function updateSingleProject(
15811596
baseDir,
15821597
target.platform,
15831598
target.scope,
1584-
targetWorkflowSelection,
1599+
targetSkillWorkflowSelection,
15851600
);
15861601
}
15871602
const { copied, skipped, failed } = await copyCometSkillsForPlatform(
@@ -1591,7 +1606,7 @@ async function updateSingleProject(
15911606
languageSkillsDir,
15921607
target.scope,
15931608
targetInstallMode,
1594-
targetWorkflowSelection,
1609+
targetSkillWorkflowSelection,
15951610
);
15961611
const cleanupResult =
15971612
failed === 0

domains/skill/platform-install.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,30 @@ function isManagedSkillPathForSelection(
115115
);
116116
}
117117

118+
/**
119+
* Derive the workflow selection from the Skills already on disk by checking
120+
* the two workflow markers (comet-native/SKILL.md and comet-classic/SKILL.md).
121+
* This lets `comet update` keep already-installed workflows in sync without
122+
* expanding the range the user chose at install time:
123+
* neither / classic only -> 'classic' (no Native added)
124+
* native only -> 'native' (no Classic added)
125+
* both -> 'both'
126+
* The caller is responsible for computing `skillsRoot` (e.g. base dir +
127+
* platform skills dir + 'skills'); this function only performs the marker
128+
* check and mapping.
129+
*/
130+
export async function detectInstalledWorkflowSelection(
131+
skillsRoot: string,
132+
): Promise<InitWorkflowSelection> {
133+
const [hasNative, hasClassic] = await Promise.all([
134+
fileExists(path.join(skillsRoot, 'comet-native', 'SKILL.md')),
135+
fileExists(path.join(skillsRoot, 'comet-classic', 'SKILL.md')),
136+
]);
137+
if (hasNative && hasClassic) return 'both';
138+
if (hasNative) return 'native';
139+
return 'classic';
140+
}
141+
118142
function getManagedSkillPathsForSelection(
119143
manifest: Manifest,
120144
workflowSelection: InitWorkflowSelection,
@@ -458,6 +482,9 @@ async function installSkillsAsSymlink(
458482
let copied = 0;
459483
let skippedCount = 0;
460484
let failedCount = 0;
485+
// Count manifest entries filtered out by the workflow selection so the
486+
// symlink path reports skipped files consistently with copy mode.
487+
skippedCount += getManagedSkillPaths(manifest).length - managedSkillPaths.length;
461488

462489
for (const skillRelPath of managedSkillPaths) {
463490
const isScript = skillRelPath.includes('/scripts/');
@@ -566,6 +593,11 @@ async function copyCometSkillsForPlatform(
566593
let failedCount = 0;
567594
const managedSkillPaths = getManagedSkillPathsForSelection(manifest, workflowSelection);
568595
const userFacingSkillPaths = getUserFacingSkillPathsForSelection(manifest, workflowSelection);
596+
// Count manifest entries that the workflow selection filters out so the
597+
// update summary stays honest: a Classic-only update reports the Native
598+
// files it intentionally skips instead of pretending they do not exist.
599+
const filteredCount = getManagedSkillPaths(manifest).length - managedSkillPaths.length;
600+
skippedCount += filteredCount;
569601

570602
for (const skillRelPath of managedSkillPaths) {
571603
const isScript = skillRelPath.includes('/scripts/');

test/app/update.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3322,6 +3322,176 @@ describe('update command helpers', () => {
33223322
await expect(fs.stat(path.join(fakeHome, 'docs', 'superpowers'))).rejects.toThrow();
33233323
});
33243324

3325+
it('syncs Native Skills during a global update of a Native+Classic install', async () => {
3326+
// Regression for #262: global scope used to hardcode the Skill
3327+
// workflowSelection to 'classic', which filtered out comet-native/* and
3328+
// left global Native installs frozen at their first-install version. With
3329+
// both comet-native and comet-classic on disk the selection is 'both', so
3330+
// Native Skills stay current.
3331+
const fakeHome = path.join(tmpDir, 'fake-home-global-native-sync');
3332+
await fs.mkdir(path.join(fakeHome, '.codex', 'skills', 'comet'), { recursive: true });
3333+
await fs.writeFile(
3334+
path.join(fakeHome, '.codex', 'skills', 'comet', 'SKILL.md'),
3335+
'# Comet\n',
3336+
'utf-8',
3337+
);
3338+
// Seed a both-workflow install: stale comet-native plus comet-classic so
3339+
// the derived selection is 'both', and prove the stale Native Skill is
3340+
// overwritten rather than merely created.
3341+
await fs.mkdir(path.join(fakeHome, '.agents', 'skills', 'comet-native'), {
3342+
recursive: true,
3343+
});
3344+
await fs.writeFile(
3345+
path.join(fakeHome, '.agents', 'skills', 'comet-native', 'SKILL.md'),
3346+
'---\nname: comet-native\n---\n# STALE\n',
3347+
'utf-8',
3348+
);
3349+
await fs.mkdir(path.join(fakeHome, '.agents', 'skills', 'comet-classic'), {
3350+
recursive: true,
3351+
});
3352+
await fs.writeFile(
3353+
path.join(fakeHome, '.agents', 'skills', 'comet-classic', 'SKILL.md'),
3354+
'# stale classic\n',
3355+
'utf-8',
3356+
);
3357+
const homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
3358+
// Silence update progress logging; this test only asserts file contents.
3359+
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
3360+
let json: string | undefined;
3361+
3362+
try {
3363+
await updateCommand(tmpDir, {
3364+
json: true,
3365+
skipNpm: true,
3366+
scope: 'global',
3367+
});
3368+
json = log.mock.calls.map((call) => call.join(' ')).join('\n');
3369+
} finally {
3370+
log.mockRestore();
3371+
homeSpy.mockRestore();
3372+
}
3373+
3374+
// Native Skills must be copied alongside Classic ones for global installs.
3375+
// Codex installs land in the canonical `.agents/skills/` directory, and a
3376+
// previously stale Native Skill must be replaced with the current asset.
3377+
const nativeSkill = await fs.readFile(
3378+
path.join(fakeHome, '.agents', 'skills', 'comet-native', 'SKILL.md'),
3379+
'utf8',
3380+
);
3381+
expect(nativeSkill).toContain('name: comet-native');
3382+
expect(nativeSkill).not.toContain('# STALE');
3383+
await expect(
3384+
fs.readFile(path.join(fakeHome, '.agents', 'skills', 'comet', 'SKILL.md'), 'utf8'),
3385+
).resolves.toContain('comet workflow resolve');
3386+
3387+
// With 'both' selected, no manifest entries are filtered out, so the
3388+
// Codex target reports zero skipped Skills.
3389+
const result = json ? JSON.parse(json) : {};
3390+
const codexSkills = (result.skills?.targets ?? []).find(
3391+
(t: { platform: string }) => t.platform === 'codex',
3392+
);
3393+
expect(codexSkills?.skipped).toBe(0);
3394+
});
3395+
3396+
it('does not add Native Skills to a Classic-only global install during update', async () => {
3397+
// A global install created with `comet init --scope global --workflow
3398+
// classic` must not gain comet-native after `comet update --scope global`.
3399+
const fakeHome = path.join(tmpDir, 'fake-home-global-classic-only');
3400+
await fs.mkdir(path.join(fakeHome, '.codex', 'skills', 'comet'), { recursive: true });
3401+
await fs.writeFile(
3402+
path.join(fakeHome, '.codex', 'skills', 'comet', 'SKILL.md'),
3403+
'# Comet\n',
3404+
'utf-8',
3405+
);
3406+
// No comet-native directory: this is a Classic-only install.
3407+
const homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
3408+
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
3409+
let json: string | undefined;
3410+
3411+
try {
3412+
await updateCommand(tmpDir, {
3413+
json: true,
3414+
skipNpm: true,
3415+
scope: 'global',
3416+
});
3417+
json = log.mock.calls.map((call) => call.join(' ')).join('\n');
3418+
} finally {
3419+
log.mockRestore();
3420+
homeSpy.mockRestore();
3421+
}
3422+
3423+
// Classic Skills update, but comet-native must NOT be introduced.
3424+
await expect(
3425+
fs.readFile(path.join(fakeHome, '.agents', 'skills', 'comet', 'SKILL.md'), 'utf8'),
3426+
).resolves.toContain('comet workflow resolve');
3427+
await expect(
3428+
fs.access(path.join(fakeHome, '.agents', 'skills', 'comet-native', 'SKILL.md')),
3429+
).rejects.toMatchObject({ code: 'ENOENT' });
3430+
3431+
// The Native manifest entries filtered out by the Classic selection are
3432+
// reported as skipped rather than hidden.
3433+
const result = json ? JSON.parse(json) : {};
3434+
const codexSkills = (result.skills?.targets ?? []).find(
3435+
(t: { platform: string }) => t.platform === 'codex',
3436+
);
3437+
expect(codexSkills?.skipped).toBeGreaterThan(0);
3438+
});
3439+
3440+
it('does not add Classic Skills to a Native-only global install during update', async () => {
3441+
// A global install created with `comet init --scope global --workflow
3442+
// native` must not gain comet-classic after `comet update --scope global`.
3443+
const fakeHome = path.join(tmpDir, 'fake-home-global-native-only');
3444+
await fs.mkdir(path.join(fakeHome, '.codex', 'skills', 'comet'), { recursive: true });
3445+
await fs.writeFile(
3446+
path.join(fakeHome, '.codex', 'skills', 'comet', 'SKILL.md'),
3447+
'# Comet\n',
3448+
'utf-8',
3449+
);
3450+
// Native-only install: comet-native present, comet-classic absent.
3451+
await fs.mkdir(path.join(fakeHome, '.agents', 'skills', 'comet-native'), {
3452+
recursive: true,
3453+
});
3454+
await fs.writeFile(
3455+
path.join(fakeHome, '.agents', 'skills', 'comet-native', 'SKILL.md'),
3456+
'---\nname: comet-native\n---\n# STALE\n',
3457+
'utf-8',
3458+
);
3459+
const homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
3460+
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
3461+
let json: string | undefined;
3462+
3463+
try {
3464+
await updateCommand(tmpDir, {
3465+
json: true,
3466+
skipNpm: true,
3467+
scope: 'global',
3468+
});
3469+
json = log.mock.calls.map((call) => call.join(' ')).join('\n');
3470+
} finally {
3471+
log.mockRestore();
3472+
homeSpy.mockRestore();
3473+
}
3474+
3475+
// Native Skills update, but comet-classic must NOT be introduced.
3476+
const nativeSkill = await fs.readFile(
3477+
path.join(fakeHome, '.agents', 'skills', 'comet-native', 'SKILL.md'),
3478+
'utf8',
3479+
);
3480+
expect(nativeSkill).toContain('name: comet-native');
3481+
expect(nativeSkill).not.toContain('# STALE');
3482+
await expect(
3483+
fs.access(path.join(fakeHome, '.agents', 'skills', 'comet-classic', 'SKILL.md')),
3484+
).rejects.toMatchObject({ code: 'ENOENT' });
3485+
3486+
// The Classic manifest entries filtered out by the Native selection are
3487+
// reported as skipped rather than hidden.
3488+
const result = json ? JSON.parse(json) : {};
3489+
const codexSkills = (result.skills?.targets ?? []).find(
3490+
(t: { platform: string }) => t.platform === 'codex',
3491+
);
3492+
expect(codexSkills?.skipped).toBeGreaterThan(0);
3493+
});
3494+
33253495
it('preserves installed language for an explicit global platform update', async () => {
33263496
const fakeHome = path.join(tmpDir, 'fake-home-explicit-global-language');
33273497
await fs.mkdir(path.join(fakeHome, '.codex', 'skills', 'comet'), { recursive: true });

0 commit comments

Comments
 (0)