Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/feat-1170-update-check-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@bradygaster/squad-cli": minor
---

Add `squad update-check` — reads the existing self-update cache (`update-check.json`) and reports current/latest version, release channel, and update availability, without replicating OS-specific cache path resolution or TTL-freshness checks in every consumer.

- `--json` emits structured output for editor extensions, CI scripts, and coordinator instructions.
- `--refresh` bypasses the cache and re-fetches from the npm registry.
- Exit codes: `0` (up to date / no cache yet), `1` (update available), `2` (transport failure during `--refresh`).
- Honors `SQUAD_NO_UPDATE_CHECK=1` — no network call, exits `0` silently.

Closes #1170.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ Say **"squad commands"** in chat to see a categorized menu of common operations
| `squad init` | **Init** — scaffold Squad in the current directory (idempotent — safe to run multiple times); alias: `cast`; use `--global` to init in personal squad directory, `--mode remote <path>` for dual-root mode |
| `squad upgrade` | Update Squad-owned files to latest; never touches your team state; use `--global` to upgrade personal squad, `--migrate-directory` to rename `.ai-team/` → `.squad/` |
| `squad upgrade --self` | Update the Squad CLI package itself; add `--insider` for dev-channel prerelease builds |
| `squad update-check` | Report cached CLI update status for tooling/CI; use `--json` for structured output, `--refresh` to bypass the cache |
| `squad status` | Show which squad is active and why |
| `squad triage` | **Watch mode** — poll for issues and auto-triage to team (aliases: `watch`, `loop`); use `--interval <minutes>` to set polling frequency (default: 10); with `--execute` dispatch Copilot agents; use `--agent-cmd`, `--copilot-flags`, `--auth-user` to customize agent execution; `--health` shows watch status; `--log-file` for diagnostics |
| `squad copilot` | Add/remove the Copilot coding agent (@copilot); use `--off` to remove, `--auto-assign` to enable auto-assignment |
Expand Down
39 changes: 39 additions & 0 deletions docs/src/content/docs/features/self-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,45 @@ Package Current Wanted Latest Location

---

## Checking Update Status Programmatically

```bash
squad update-check --json
```

Reads the same cache the background startup check maintains and prints it as structured JSON, without making a network call:

```json
{
"current": "0.9.6-insider.2",
"channel": "insider",
"latest": "0.9.7-insider.1",
"updateAvailable": true,
"cacheAge": "PT2H15M",
"checkedAt": "2026-05-26T12:00:00.000Z"
}
```

This gives editor extensions, coordinator instructions, and CI scripts a stable interface to query update status without replicating the OS-specific cache path or TTL-freshness logic themselves.

**Flags:**
- `--json` — structured output (shown above)
- `--refresh` — bypass the cache and re-fetch from the npm registry now

**Exit codes:** `0` (up to date, or no cache yet), `1` (update available), `2` (transport failure during `--refresh`)

**Default (non-JSON) output:**

```
Current: 0.9.6-insider.2 (insider channel)
Latest: 0.9.7-insider.1
Update available. Run `squad upgrade --self` to install.
```

Honors `SQUAD_NO_UPDATE_CHECK=1` — exits `0` with no output (or `{}` with `--json`) and never calls the network.

---

## Release Channels

| Channel | Tag | Description |
Expand Down
4 changes: 4 additions & 0 deletions packages/squad-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
"types": "./dist/cli/commands/doctor.d.ts",
"import": "./dist/cli/commands/doctor.js"
},
"./commands/update-check": {
"types": "./dist/cli/commands/update-check.d.ts",
"import": "./dist/cli/commands/update-check.js"
},
"./commands/aspire": {
"types": "./dist/cli/commands/aspire.d.ts",
"import": "./dist/cli/commands/aspire.js"
Expand Down
8 changes: 8 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ async function main(): Promise<void> {
console.log(` Flags: --global (upgrade personal squad)`);
console.log(` --migrate-directory (rename .ai-team/ → .squad/)`);
console.log(` --state-backend <type> (migrate to orphan|two-layer)`);
console.log(` ${BOLD}update-check${RESET} Report cached CLI update status (for tooling/CI)`);
console.log(` Flags: --json (structured output), --refresh (bypass cache)`);
console.log(` ${BOLD}migrate${RESET} Convert between markdown and SDK-First squad formats`);
console.log(` Flags: --to sdk|markdown, --from ai-team, --dry-run`);
console.log(` ${BOLD}sync${RESET} Sync squad-state branch(es) with remote (push/pull/both)`);
Expand Down Expand Up @@ -501,6 +503,12 @@ async function main(): Promise<void> {
return;
}

if (cmd === 'update-check') {
const { runUpdateCheckCommand } = await import('./cli/commands/update-check.js');
const exitCode = await runUpdateCheckCommand(args.slice(1));
process.exit(exitCode);
}

if (cmd === 'memory') {
const { runMemoryCommand } = await import('./cli/commands/memory.js');
await runMemoryCommand(getSquadStartDir(), args.slice(1));
Expand Down
166 changes: 166 additions & 0 deletions packages/squad-cli/src/cli/commands/update-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* `squad update-check` — expose cached CLI update status for tooling.
*
* Reads the update-check cache maintained by self-update.ts and reports it
* as human-readable text or structured JSON (--json). This gives editor
* extensions, coordinator instructions, and CI scripts a stable interface
* to query update status without replicating OS-specific cache path
* resolution or TTL freshness checks themselves.
*
* @module cli/commands/update-check
*/

import { FSStorageProvider } from '@bradygaster/squad-sdk';
import { type CacheData, getCachePath, fetchLatestVersion, writeCache } from '../self-update.js';
import { parseVersion, isNewer, type ReleaseChannel } from '../upgrade.js';
import { getPackageVersion } from '../core/version.js';

const storage = new FSStorageProvider();

export interface UpdateCheckResult {
current: string;
channel: ReleaseChannel;
latest: string | null;
updateAvailable: boolean;
cacheAge: string | null;
checkedAt: string | null;
error?: string;
}

/** Detect release channel from a version's prerelease tag (e.g. "insider.2" → "insider"). */
export function detectChannel(version: string): ReleaseChannel {
const { prerelease } = parseVersion(version);
if (prerelease.startsWith('insider')) return 'insider';
if (prerelease.startsWith('preview')) return 'preview';
return 'stable';
}

/** Format a millisecond duration as an ISO 8601 duration string (e.g. "PT2H15M"). */
export function formatCacheAge(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
let result = 'PT';
if (hours > 0) result += `${hours}H`;
if (minutes > 0) result += `${minutes}M`;
if (seconds > 0 || result === 'PT') result += `${seconds}S`;
return result;
}

/** Read the raw update-check cache, regardless of TTL freshness. */
function readRawCache(): CacheData | null {
try {
const raw = storage.readSync(getCachePath());
if (!raw) return null;
return JSON.parse(raw) as CacheData;
} catch {
return null;
}
}

/**
* Build the update-check result for the given current version.
*
* Without `refresh`, only reads the existing cache — never makes a network
* call. With `refresh`, always re-fetches from the npm registry and updates
* the cache.
*/
export async function runUpdateCheck(
currentVersion: string,
options: { refresh?: boolean } = {},
): Promise<UpdateCheckResult> {
const channel = detectChannel(currentVersion);

if (options.refresh) {
const latest = await fetchLatestVersion();
if (!latest) {
return {
current: currentVersion,
channel,
latest: null,
updateAvailable: false,
cacheAge: null,
checkedAt: null,
error: 'Failed to reach the npm registry',
};
}
const checkedAt = Date.now();
writeCache({ latestVersion: latest, checkedAt });
return {
current: currentVersion,
channel,
latest,
updateAvailable: isNewer(currentVersion, latest),
cacheAge: formatCacheAge(0),
checkedAt: new Date(checkedAt).toISOString(),
};
}

const cached = readRawCache();
if (!cached) {
return {
current: currentVersion,
channel,
latest: null,
updateAvailable: false,
cacheAge: null,
checkedAt: null,
};
}

return {
current: currentVersion,
channel,
latest: cached.latestVersion,
updateAvailable: isNewer(currentVersion, cached.latestVersion),
cacheAge: formatCacheAge(Date.now() - cached.checkedAt),
checkedAt: new Date(cached.checkedAt).toISOString(),
};
}

/** Print the result in human-readable form. */
function printHuman(result: UpdateCheckResult): void {
if (result.error) {
console.error(`❌ ${result.error}`);
return;
}
console.log(`Current: ${result.current} (${result.channel} channel)`);
if (!result.latest) {
console.log('No cached update status yet. Run `squad update-check --refresh` to check now.');
return;
}
console.log(`Latest: ${result.latest}`);
if (result.updateAvailable) {
console.log('Update available. Run `squad upgrade --self` to install.');
} else {
console.log('Up to date.');
}
}

/**
* CLI entry point for `squad update-check`.
* @returns the process exit code: 0 (up to date / no cache yet),
* 1 (update available), 2 (transport failure during --refresh).
*/
export async function runUpdateCheckCommand(args: string[]): Promise<number> {
const json = args.includes('--json');

// Respect the same opt-out as the background startup check.
if (process.env.SQUAD_NO_UPDATE_CHECK === '1') {
if (json) console.log('{}');
return 0;
}

const refresh = args.includes('--refresh');
const result = await runUpdateCheck(getPackageVersion(), { refresh });

if (json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}

if (result.error) return 2;
return result.updateAvailable ? 1 : 0;
}
12 changes: 12 additions & 0 deletions packages/squad-cli/src/cli/core/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ const COMMAND_HELP: Record<string, HelpPrinter> = {
console.log(` ${BOLD}--force${RESET} Overwrite files without prompting\n`);
},

'update-check': (version) => {
header('update-check', version, 'Report cached CLI update status');
console.log(`Usage: squad update-check [options]\n`);
console.log(`Reads the update-check cache maintained in the background by the CLI`);
console.log(`and reports it without making a network call, unless --refresh is used.\n`);
console.log(`Options:`);
console.log(` ${BOLD}--json${RESET} Emit structured JSON instead of text`);
console.log(` ${BOLD}--refresh${RESET} Bypass the cache and re-fetch from npm\n`);
console.log(`Exit codes: 0 (up to date / no cache yet), 1 (update available),`);
console.log(` 2 (transport failure during --refresh)\n`);
},

migrate: (version) => {
header('migrate', version, 'Convert between markdown and SDK-First squad formats');
console.log(`Usage: squad migrate --to sdk|markdown [options]\n`);
Expand Down
9 changes: 5 additions & 4 deletions packages/squad-cli/src/cli/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const FETCH_TIMEOUT_MS = 3000; // 3 seconds

interface CacheData {
export interface CacheData {
latestVersion: string;
checkedAt: number;
}
Expand All @@ -37,7 +37,8 @@ function getCacheDir(): string {
return path.join(base, 'squad-cli');
}

function getCachePath(): string {
/** Path to the update-check cache file (shared with `squad update-check`). */
export function getCachePath(): string {
return path.join(getCacheDir(), 'update-check.json');
}

Expand All @@ -57,7 +58,7 @@ function readCache(): CacheData | null {
}

/** Write version check result to cache. */
function writeCache(data: CacheData): void {
export function writeCache(data: CacheData): void {
try {
const dir = getCacheDir();
storage.mkdirSync(dir, { recursive: true });
Expand All @@ -68,7 +69,7 @@ function writeCache(data: CacheData): void {
}

/** Fetch latest version from npm registry with timeout. */
async function fetchLatestVersion(): Promise<string | null> {
export async function fetchLatestVersion(): Promise<string | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
Expand Down
1 change: 1 addition & 0 deletions test/cli/command-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ describe('printCommandHelp', () => {
'status',
'subsquads',
'triage',
'update-check',
'upgrade',
'upstream',
'watch',
Expand Down
Loading