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
60 changes: 60 additions & 0 deletions .changeset/feat-preset-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@bradygaster/squad-cli": minor
"@bradygaster/squad-sdk": minor
---

feat: add `squad preset install <source>` for sharing presets via repo URL or local path (#1224)

Closes #1224. Adds a new subcommand that installs a single preset from a GitHub URL or local path into `$SQUAD_HOME/presets/<name>/` — the peer-to-peer preset sharing flow that was missing in v0.10.0.

### CLI

```bash
# From a GitHub repo (preset at <repo-root>/preset.json or <repo-root>/presets/<name>/)
squad preset install https://github.com/tamir/my-presets#my-awesome-team

# From a sub-path
squad preset install https://github.com/tamir/my-presets/tree/main/presets/my-awesome-team

# From SSH URL
squad preset install git@github.com:tamir/my-presets.git#my-awesome-team

# From a local path
squad preset install ./my-awesome-team

# Override the installed name
squad preset install https://github.com/tamir/my-presets#my-awesome-team --name corp-team

# Overwrite an existing preset
squad preset install <source> --force
```

After install, the preset is a normal entry in `$SQUAD_HOME/presets/` — `squad preset list`, `squad preset apply <name>`, and `squad init --preset <name>` all work as today.

### Why

The existing `squad preset init --remote` flow is for syncing your *whole* `$SQUAD_HOME` repo across machines (single-user, multi-machine use case). There was no built-in way to install just *one* preset from someone else's repo. Users had to:
1. Manually clone the repo to a temp dir
2. `cp -r <clone>/presets/<name> $SQUAD_HOME/presets/`
3. Then `squad preset apply <name>`

…or hijack `SQUAD_HOME` (which collides with their own personal squad config).

### What it does

1. Resolves source — GitHub URL → shallow `git clone --depth 1` to a temp dir; local path → use as-is
2. Locates the preset within the source via 3 patterns:
- Source dir contains `preset.json` → single-preset source
- Source dir contains a `presets/` collection → require `--name` (or `#name` fragment) to pick
- Source dir IS the `presets/` dir → require `--name`, or auto-pick if only one preset
3. Validates the preset's `preset.json` manifest (name, agents[]) before any destructive action
4. Verifies `agents/` directory exists
5. Copies `preset.json` (with optional rename) + `agents/` into `$SQUAD_HOME/presets/<name>/`
6. Fail-stops if destination exists, unless `--force` is passed
7. Cleans up the temp clone whether install succeeds or fails

### What's NOT in scope (deferred to follow-ups)

- `squad preset uninstall` — `rm -rf $SQUAD_HOME/presets/<name>` works for now
- `squad preset update <name>` to pull a fresh version from origin
- Public preset registry / discovery catalog
57 changes: 56 additions & 1 deletion packages/squad-cli/src/cli/commands/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* squad preset show <name> — show preset details
* squad preset apply <name> — install preset agents into current squad
* squad preset save <name> — save current project agents as a preset
* squad preset install <src> — install a preset from a GitHub URL or local path
* squad preset init — initialize presets directory in squad home
*
* Note: Presets capture agents only (charters). For full squad snapshots
Expand All @@ -22,7 +23,7 @@ import path from 'node:path';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { resolveSquadHome, ensureSquadHome, resolvePresetsDir } from '@bradygaster/squad-sdk/resolution';
import { listPresets, loadPreset, applyPreset, savePreset, seedBuiltinPresets } from '@bradygaster/squad-sdk/presets';
import { listPresets, loadPreset, applyPreset, savePreset, seedBuiltinPresets, installPresetFromSource } from '@bradygaster/squad-sdk/presets';
import { resolveSquad } from '@bradygaster/squad-sdk/resolution';
import { success, warn, info, BOLD, RESET, DIM } from '../core/output.js';
import { fatal } from '../core/errors.js';
Expand Down Expand Up @@ -68,6 +69,33 @@ export async function runPreset(cwd: string, subcommand: string, args: string[])
await presetSave(cwd, name!, force, description);
break;
}
case 'install': {
const source = args[0];
if (!source) {
fatal('Usage: squad preset install <source> [--name <override>] [--force]\n' +
' <source> can be:\n' +
' - GitHub URL: https://github.com/owner/repo[#preset-name]\n' +
' - GitHub URL with sub-path: https://github.com/owner/repo/tree/main/presets/my-team\n' +
' - SSH URL: git@github.com:owner/repo.git\n' +
' - Local path: ./my-preset OR ./my-presets-collection');
}
const force = args.includes('--force');
const nameIdx = args.indexOf('--name');
let nameOverride: string | undefined;
if (nameIdx >= 0) {
// Defend against `squad preset install <src> --name` (no value) and
// `--name --force` (value is another flag) — both currently produce
// confusing downstream errors. Fail fast with a clear usage hint
// per review on bradygaster/squad#1225.
const candidate = args[nameIdx + 1];
if (!candidate || candidate.startsWith('-')) {
fatal('`--name` requires a value. Usage: squad preset install <source> --name <override>');
}
nameOverride = candidate;
}
await presetInstall(source!, nameOverride, force);
break;
}
default:
fatal(
`Unknown preset subcommand: ${subcommand}\n` +
Expand All @@ -76,6 +104,7 @@ export async function runPreset(cwd: string, subcommand: string, args: string[])
` squad preset show <name>\n` +
` squad preset apply <name> [--force]\n` +
` squad preset save <name>\n` +
` squad preset install <source> [--name <override>] [--force]\n` +
` squad preset init [--remote]`,
Comment on lines 104 to 108
);
}
Expand Down Expand Up @@ -387,3 +416,29 @@ async function presetSave(cwd: string, name: string, force: boolean, description
fatal(String(err));
}
}

// ============================================================================
// Subcommand: install
// ============================================================================

/**
* Install a preset from a remote URL or local path into $SQUAD_HOME/presets/<name>/.
* After install, the preset is available to `squad preset apply <name>`.
*/
async function presetInstall(source: string, nameOverride: string | undefined, force: boolean): Promise<void> {
info(`Installing preset from: ${source}${DIM}${nameOverride ? ` (as '${nameOverride}')` : ''}${RESET}`);
console.log();

try {
const result = installPresetFromSource(source, { name: nameOverride, force });
success(`Preset '${result.installedName}' installed`);
info(` Location: ${result.installedDir}`);
info(` Source: ${result.source}`);
console.log();
info(` Apply in a project: ${BOLD}squad preset apply ${result.installedName}${RESET}`);
info(` Show details: ${BOLD}squad preset show ${result.installedName}${RESET}`);
info(` Use during init: ${BOLD}squad init --preset ${result.installedName}${RESET}`);
} catch (err) {
fatal(String(err instanceof Error ? err.message : err));
}
}
14 changes: 12 additions & 2 deletions packages/squad-cli/src/cli/core/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,19 @@ const COMMAND_HELP: Record<string, HelpPrinter> = {

preset: (version) => {
header('preset', version, 'Manage squad presets (curated agent collections)');
console.log(`Usage: squad preset <list|show <name>|apply <name>|save <name>|init> [options]\n`);
console.log(`Usage: squad preset <list|show <name>|apply <name>|save <name>|install <source>|init> [options]\n`);
console.log(`Subcommands:`);
console.log(` ${BOLD}install <source>${RESET} Install a preset from a GitHub URL, SSH URL, or local path`);
console.log(` <source> shapes (see review on #1225 for the fragment semantics):`);
console.log(` https://github.com/owner/repo`);
console.log(` https://github.com/owner/repo#my-preset (bare = preset-name hint)`);
console.log(` https://github.com/owner/repo#path/to/preset (slash = literal subPath)`);
console.log(` https://github.com/owner/repo/tree/branch/path/to/preset`);
console.log(` git@github.com:owner/repo.git`);
console.log(` ./local/path/to/preset OR ./local/presets-collection\n`);
console.log(`Options:`);
console.log(` ${BOLD}--force${RESET} Overwrite existing agents on apply`);
console.log(` ${BOLD}--force${RESET} Overwrite existing agents on apply, or existing preset on install`);
console.log(` ${BOLD}--name <override>${RESET} install: rename the preset on install (also picks one from a collection)`);
console.log(` ${BOLD}--remote${RESET} init: back presets with a GitHub repo\n`);
},

Expand Down
Loading
Loading