Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-reinit-stale-version-greeting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Fix `squad init` re-run on an existing project leaving the `squad.agent.md` first-response greeting (`` `Squad v...` ``) stamped with a stale version even though the HTML comment marker and Identity `Version:` line were correctly refreshed. The greeting regex previously only matched the unresolved `{version}` placeholder, so once a real version had been stamped once, later re-stamps could no longer update it. Both `stampVersion` (squad-cli) and `stampVersionInContent` (squad-sdk) now also match an already-resolved `` `Squad vX.Y.Z` `` literal, making all three version locations idempotently updatable on every re-init or upgrade.
6 changes: 4 additions & 2 deletions packages/squad-cli/src/cli/core/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ export function stampVersion(filePath: string, version: string): void {
content = content.replace(/<!-- version: [^>]+ -->/m, `<!-- version: ${version} -->`);
// Replace version in the Identity section's Version line
content = content.replace(/- \*\*Version:\*\* [0-9.]+(?:-[a-z]+(?:\.\d+)?)?/m, `- **Version:** ${version}`);
// Replace {version} placeholder in the greeting instruction so it's unambiguous
content = content.replace(/`Squad v\{version\}`/g, `\`Squad v${version}\``);
// Replace the greeting instruction's version literal so it's unambiguous.
// Matches both the unresolved `{version}` placeholder and an already-resolved
// semver (e.g. from a prior stamp) so re-running this idempotently refreshes it.
content = content.replace(/`Squad v[^`]*`/g, `\`Squad v${version}\``);
storage.writeSync(filePath, content);
}

Expand Down
6 changes: 4 additions & 2 deletions packages/squad-sdk/src/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,9 +631,11 @@ function stampVersionInContent(content: string, version: string): string {
/- \*\*Version:\*\* [0-9.]+(?:-[a-z]+(?:\.\d+)?)?/m,
`- **Version:** ${version}`
);
// Greeting placeholder: `Squad v{version}`
// Greeting placeholder: `Squad v{version}` — also matches an already-resolved
// semver (e.g. `Squad v0.11.0`) from a prior stamp so this is idempotent when
// re-run against a file that already went through stampVersionInContent.
content = content.replace(
/`Squad v\{version\}`/g,
/`Squad v[^`]*`/g,
`\`Squad v${version}\``
);
return content;
Expand Down
62 changes: 60 additions & 2 deletions test/cli/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, rm, readdir, readFile } from 'fs/promises';
import { mkdir, rm, readdir, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';
import { runInit } from '@bradygaster/squad-cli/core/init';
import { getPackageVersion } from '@bradygaster/squad-cli/core/version';
import { getPackageVersion, stampVersion } from '@bradygaster/squad-cli/core/version';

const TEST_ROOT = join(tmpdir(), `.test-cli-init-${randomBytes(4).toString('hex')}`);
const TEST_HOME = join(tmpdir(), `.test-cli-init-home-${randomBytes(4).toString('hex')}`);
Expand Down Expand Up @@ -67,6 +67,64 @@ describe('CLI: init command', () => {
expect(content).toContain(`Squad v${currentVersion}`);
});

it('stampVersion should refresh an already-resolved greeting literal, not just the {version} placeholder (regression)', async () => {
// The greeting regex used to only match the unresolved `Squad v{version}`
// placeholder, so a file that already had a resolved (stale) literal from a
// prior stamp stayed stuck on the old version forever.
const agentPath = join(TEST_ROOT, 'squad.agent.md');
const staleVersion = '0.9.0';
const currentVersion = getPackageVersion();
const before = [
`<!-- version: ${staleVersion} -->`,
'',
`- **Version:** ${staleVersion}. Include it as \`Squad v${staleVersion}\` in your first response.`,
].join('\n');
await writeFile(agentPath, before, 'utf-8');

stampVersion(agentPath, currentVersion);
const after = await readFile(agentPath, 'utf-8');

expect(after).toContain(`<!-- version: ${currentVersion} -->`);
expect(after).toContain(`- **Version:** ${currentVersion}`);
expect(after).toContain(`\`Squad v${currentVersion}\``);
expect(after).not.toContain(staleVersion);
});

it('re-running init against an existing project should refresh a stale resolved version everywhere, including the greeting literal (re-init regression)', async () => {
// First init creates squad.agent.md with the current version stamped
// everywhere. Simulate what a real "installed a while ago" repo looks
// like: all three version locations still show an older resolved
// version (not the unresolved {version} placeholder).
await runInit(TEST_ROOT);

const agentPath = join(TEST_ROOT, '.github', 'agents', 'squad.agent.md');
const currentVersion = getPackageVersion();
const staleVersion = '0.9.0';
const escapedCurrent = currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

let content = await readFile(agentPath, 'utf-8');
content = content
.replace(/<!-- version: [^>]+ -->/, `<!-- version: ${staleVersion} -->`)
.replace(/- \*\*Version:\*\* [0-9.]+(?:-[a-z]+(?:\.\d+)?)?/, `- **Version:** ${staleVersion}`)
.replace(new RegExp('`Squad v' + escapedCurrent + '`'), `\`Squad v${staleVersion}\``);
await writeFile(agentPath, content, 'utf-8');

// Sanity check: the stale literal is actually present before rerunning init.
const beforeRerun = await readFile(agentPath, 'utf-8');
expect(beforeRerun).toContain(`\`Squad v${staleVersion}\``);

// Re-init the existing project. squad.agent.md already exists, so init's
// template-copy step is skipped (skipExisting=true) — only the trailing
// "ensure version is fully stamped" step in runInit touches this file.
await runInit(TEST_ROOT);

const afterRerun = await readFile(agentPath, 'utf-8');
expect(afterRerun).toContain(`<!-- version: ${currentVersion} -->`);
expect(afterRerun).toContain(`- **Version:** ${currentVersion}`);
expect(afterRerun).toContain(`\`Squad v${currentVersion}\``);
expect(afterRerun).not.toContain(staleVersion);
});

it('should create .squad/ directory structure', async () => {
await runInit(TEST_ROOT);

Expand Down