diff --git a/.changeset/log-projects-at-init.md b/.changeset/log-projects-at-init.md new file mode 100644 index 000000000..a86cdfd3a --- /dev/null +++ b/.changeset/log-projects-at-init.md @@ -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. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 6dc4d2a73..b6253ede7 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -175,6 +175,7 @@ async function main(): Promise { console.log(` Flags: --push, --pull, --remote , --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 ] [--search ]`); console.log(` ${BOLD}cost${RESET} Report token usage from orchestration logs`); @@ -879,6 +880,12 @@ async function main(): Promise { 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)); diff --git a/packages/squad-cli/src/cli/commands/projects.ts b/packages/squad-cli/src/cli/commands/projects.ts new file mode 100644 index 000000000..058ce7395 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/projects.ts @@ -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 { + 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(); +} diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index e92d8f961..59ec89c5f 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -76,6 +76,13 @@ const COMMAND_HELP: Record = { 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 ] [--search ]\n`); diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 38c709487..41c7cd890 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -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'; @@ -445,6 +445,15 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi console.log(`${DIM} Add agents with: squad personal add --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) { diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 3833c1fb4..40fd6a81e 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -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'; diff --git a/packages/squad-sdk/src/projects-registry.ts b/packages/squad-sdk/src/projects-registry.ts new file mode 100644 index 000000000..27532a9ee --- /dev/null +++ b/packages/squad-sdk/src/projects-registry.ts @@ -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'); +} diff --git a/test/cli/command-help.test.ts b/test/cli/command-help.test.ts index 7c73e09fe..4f40f4e84 100644 --- a/test/cli/command-help.test.ts +++ b/test/cli/command-help.test.ts @@ -123,6 +123,7 @@ describe('printCommandHelp', () => { 'personal', 'plugin', 'preset', + 'projects', 'rc', 'rc-tunnel', 'remote-control',