Skip to content
Open
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
15 changes: 15 additions & 0 deletions .changeset/log-projects-at-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@bradygaster/squad-cli": minor
"@bradygaster/squad-sdk": minor
---

Log each project at `squad init` and add `squad projects` to list them

`squad init` now records the project (name, absolute path, and creation timestamp) in a global registry, `projects.json`, stored under the existing global Squad home (`resolveGlobalSquadPath()`). A new `squad projects` command reads that registry and prints every Squad project on the machine, newest first.

This closes a small but common gap: after working across several repositories, a user had no built-in way to remember where all of their Squad projects live. The feature is purely additive:

- **`squad-sdk`** gains `registerProject()` and `readProjectsRegistry()` (plus the `ProjectRegistryEntry` type). The registry write is idempotent on the project path (re-running `squad init` updates the entry in place, never duplicating) and fail-safe (a registry error never blocks init).
- **`squad-cli`** calls `registerProject()` during repo `squad init` (global `--global` init is intentionally not registered, since it is the personal home, not a project) and adds the `squad projects` reader command.

No existing init output, project `.squad/` state, or other command behavior changes.
7 changes: 7 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ async function main(): Promise<void> {
console.log(` Flags: --push, --pull, --remote <name>, --quiet`);
console.log(` No-op for local/worktree backends. Invoked by git hooks.`);
console.log(` ${BOLD}status${RESET} Show which squad is active and why`);
console.log(` ${BOLD}projects${RESET} List all Squad projects on this machine`);
console.log(` ${BOLD}roles${RESET} List built-in Squad roles`);
console.log(` Usage: roles [--category <name>] [--search <query>]`);
console.log(` ${BOLD}cost${RESET} Report token usage from orchestration logs`);
Expand Down Expand Up @@ -879,6 +880,12 @@ async function main(): Promise<void> {
return;
}

if (cmd === 'projects') {
const { runProjects } = await import('./cli/commands/projects.js');
await runProjects(args.slice(1));
return;
}

if (cmd === 'roles') {
const { runRoles } = await import('./cli/commands/roles.js');
await runRoles(args.slice(1));
Expand Down
49 changes: 49 additions & 0 deletions packages/squad-cli/src/cli/commands/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* `squad projects` command.
*
* Lists every Squad project registered on this machine (populated by
* `squad init`), newest first. Read-only: it only reports what the registry
* already contains.
*/

import { readProjectsRegistry } from '@bradygaster/squad-sdk';

function relativeAge(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const days = Math.floor((Date.now() - then) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return '1 day ago';
if (days < 30) return `${days} days ago`;
const months = Math.floor(days / 30);
if (months === 1) return '1 month ago';
return `${months} months ago`;
}

export async function runProjects(_args: string[]): Promise<void> {
const entries = readProjectsRegistry();

if (entries.length === 0) {
console.log('No Squad projects registered yet.');
console.log('Run `squad init` in a project to add it to the list.');
return;
}

const sorted = [...entries].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);

const nameWidth = Math.max(...sorted.map(e => e.name.length), 4);
const pathWidth = Math.max(...sorted.map(e => e.path.length), 4);

console.log();
console.log(`Squad projects on this machine (${sorted.length}):`);
console.log();
console.log(` ${'NAME'.padEnd(nameWidth)} ${'PATH'.padEnd(pathWidth)} CREATED`);
for (const e of sorted) {
console.log(
` ${e.name.padEnd(nameWidth)} ${e.path.padEnd(pathWidth)} ${relativeAge(e.created_at)}`,
);
}
console.log();
}
7 changes: 7 additions & 0 deletions packages/squad-cli/src/cli/core/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ const COMMAND_HELP: Record<string, HelpPrinter> = {
console.log(`global), and the state of the personal squad path.\n`);
},

projects: (version) => {
header('projects', version, 'List all Squad projects on this machine');
console.log(`Usage: squad projects\n`);
console.log(`Lists every project where 'squad init' has run, newest first,`);
console.log(`showing its name, path, and when it was first registered.\n`);
},

roles: (version) => {
header('roles', version, 'List built-in Squad roles');
console.log(`Usage: squad roles [--category <name>] [--search <query>]\n`);
Expand Down
11 changes: 10 additions & 1 deletion packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { success, BOLD, RESET, YELLOW, GREEN, DIM } from './output.js';
import { fatal } from './errors.js';
import { detectProjectType } from './project-type.js';
import { getPackageVersion, stampVersion } from './version.js';
import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, ensurePersonalSquadDir, resolvePersonalSquadDir, clearResolveSquadCache, type InitOptions } from '@bradygaster/squad-sdk';
import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, ensurePersonalSquadDir, resolvePersonalSquadDir, clearResolveSquadCache, registerProject, type InitOptions } from '@bradygaster/squad-sdk';
import { installGitHooks } from '../commands/install-hooks.js';
import { liftInitMutableStateOntoOrphan } from '../commands/migrate-backend.js';
import { resolveSquadStateMcpSpec } from './mcp-spec.js';
Expand Down Expand Up @@ -445,6 +445,15 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
console.log(`${DIM} Add agents with: squad personal add <name> --role <role>${RESET}`);
console.log();
} else {
// Log this project in the global registry so `squad projects` can list
// every Squad project on this machine. Best-effort: a failure here must
// never block init.
try {
registerProject(path.basename(path.resolve(dest)), dest);
} catch {
// Registry write failed; ignore so init still succeeds.
}

// Repo init: inform user if personal squad is available
const personalDir = resolvePersonalSquadDir();
if (personalDir) {
Expand Down
2 changes: 2 additions & 0 deletions packages/squad-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const VERSION: string = pkg.version;
// Export public API
export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile, deriveProjectKey, resolveExternalStateDir, resolveSquadHome, ensureSquadHome, resolvePresetsDir, resolveSquadState, clearResolveSquadCache } from './resolution.js';
export type { ResolvedSquadPaths, SquadDirConfig, SquadStateContext } from './resolution.js';
export { registerProject, readProjectsRegistry } from './projects-registry.js';
export type { ProjectRegistryEntry } from './projects-registry.js';
export * from './config/index.js';
export * from './agents/onboarding.js';
export { resolvePersonalAgents, mergeSessionCast } from './agents/personal.js';
Expand Down
95 changes: 95 additions & 0 deletions packages/squad-sdk/src/projects-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Global project registry.
*
* Records every repository where `squad init` runs so a user can later list all
* of their Squad projects and where each one lives on disk. This is purely
* additive metadata: it never changes how a project is initialized or how its
* own `.squad/` state behaves.
*
* The registry is a single JSON file, `projects.json`, stored under
* {@link resolveGlobalSquadPath} (a sibling of the existing personal-squad and
* externalized-state directories).
*
* @module projects-registry
*/

import path from 'node:path';
import fs from 'node:fs';
import { resolveGlobalSquadPath } from './resolution.js';

/** A single entry in the global project registry. */
export interface ProjectRegistryEntry {
/** Display name of the project (directory basename at init time). */
name: string;
/** Absolute path to the project root. */
path: string;
/** ISO 8601 timestamp of when the project was first registered. */
created_at: string;
}

function registryFilePath(): string {
return path.join(resolveGlobalSquadPath(), 'projects.json');
}

/**
* Windows and macOS use case-insensitive filesystems, so the same project can
* be reached via paths that differ only in case (e.g. a lowercased drive
* letter). Compare paths accordingly so re-registering never duplicates an
* entry. Mirrors the convention used by FSStorageProvider.
*/
const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';

function samePath(a: string, b: string): boolean {
return CASE_INSENSITIVE ? a.toLowerCase() === b.toLowerCase() : a === b;
}

/**
* Reads the global project registry.
*
* Returns an empty array if the registry does not exist yet or cannot be
* parsed, so callers never have to guard against a missing or corrupt file.
*/
export function readProjectsRegistry(): ProjectRegistryEntry[] {
const file = registryFilePath();
try {
if (!fs.existsSync(file)) return [];
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(e): e is ProjectRegistryEntry =>
!!e &&
typeof (e as ProjectRegistryEntry).name === 'string' &&
typeof (e as ProjectRegistryEntry).path === 'string' &&
typeof (e as ProjectRegistryEntry).created_at === 'string',
);
} catch {
return [];
}
}

/**
* Registers a project in the global registry.
*
* Idempotent: re-running for an already-registered path updates that entry in
* place (refreshing the name) rather than adding a duplicate. The original
* `created_at` is preserved. This is a best-effort write; callers that run it
* during initialization should wrap it so a failure here cannot block init.
*
* @param name - Display name for the project.
* @param projectPath - Path to the project root (resolved to absolute).
*/
export function registerProject(name: string, projectPath: string): void {
const file = registryFilePath();
const absPath = path.resolve(projectPath);
const entries = readProjectsRegistry();

const existing = entries.find(e => samePath(path.resolve(e.path), absPath));
if (existing) {
existing.name = name;
existing.path = absPath;
} else {
entries.push({ name, path: absPath, created_at: new Date().toISOString() });
}

fs.writeFileSync(file, `${JSON.stringify(entries, null, 2)}\n`, 'utf8');
}
1 change: 1 addition & 0 deletions test/cli/command-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ describe('printCommandHelp', () => {
'personal',
'plugin',
'preset',
'projects',
'rc',
'rc-tunnel',
'remote-control',
Expand Down