Skip to content

feat: add 'squad preset install <source>' for sharing presets via repo URL (#1224)#1225

Open
tamirdresher wants to merge 2 commits into
bradygaster:devfrom
tamirdresher:feat/1224-preset-install
Open

feat: add 'squad preset install <source>' for sharing presets via repo URL (#1224)#1225
tamirdresher wants to merge 2 commits into
bradygaster:devfrom
tamirdresher:feat/1224-preset-install

Conversation

@tamirdresher

Copy link
Copy Markdown
Collaborator

feat: squad preset install <source> for sharing presets via repo URL (#1224)

Closes #1224.

What

New subcommand that installs a single preset from a GitHub URL or local path into $SQUAD_HOME/presets/<name>/:

# GitHub URLs
squad preset install https://github.com/tamir/my-presets#my-awesome-team
squad preset install https://github.com/tamir/my-presets/tree/main/presets/my-awesome-team
squad preset install git@github.com:tamir/my-presets.git#my-awesome-team

# Local paths
squad preset install ./my-awesome-team             # single preset
squad preset install ./my-presets-collection --name my-awesome-team   # collection

# Modifiers
squad preset install <source> --name corp-team    # rename on install
squad preset install <source> --force             # overwrite existing

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 shares your whole $SQUAD_HOME repo. There was no way to install just one preset from someone else's repo. Users had to manually clone + copy + apply, or hijack SQUAD_HOME (collides with personal config).

Source resolution rules

  1. Source dir contains preset.json → install that as a single preset
  2. Source dir contains a presets/ collection → require --name (or #name URL fragment) to pick
  3. Source dir IS the presets/ dir → require --name, or auto-pick if only one preset present

Implementation

SDK (packages/squad-sdk/src/presets/index.ts):

  • New installPresetFromSource(source, options) function
  • Top-level import os from 'node:os' and execSync from node:child_process (ESM-safe)
  • Shallow clones with git clone --depth 1 [--branch <ref>]
  • Always cleans up temp clones in finally
  • Stamps manifest.name to match installed name when --name is used

CLI (packages/squad-cli/src/cli/commands/preset.ts):

  • New 'install' dispatcher case + presetInstall() function
  • Module docstring + default usage help updated to include install

Smoke tests (all pass on local build)

# Case Result
1 Install single preset from local path ✅ Installed under manifest.name
2 Re-install without --force ✅ Fails with already exists
3 Re-install with --force ✅ Overwrites
4 --name rename + manifest rewrite ✅ Installed as override, manifest.name updated
5 Invalid source (no preset.json/no presets/) ✅ Clear No preset found error
6 GitHub URL — cloned bradygaster/squad/.../builtin/default ✅ Installed brady-default with 5 agents

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

  • squad preset uninstallrm -rf $SQUAD_HOME/presets/<name> works manually
  • squad preset update <name> to pull fresh from origin
  • Public preset registry / discovery catalog

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Copilot AI review requested due to automatic review settings June 7, 2026 16:35
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

🟡 Impact Analysis — PR #1225

Risk tier: 🟡 MEDIUM

📊 Summary

Metric Count
Files changed 5
Files added 1
Files modified 4
Files deleted 0
Modules touched 4
Critical files 1

🎯 Risk Factors

  • 5 files changed (≤5 → LOW)
  • 4 modules touched (2-4 → MEDIUM)
  • Critical files touched: packages/squad-sdk/src/presets/index.ts

📦 Modules Affected

root (1 file)
  • .changeset/feat-preset-install.md
squad-cli (2 files)
  • packages/squad-cli/src/cli/commands/preset.ts
  • packages/squad-cli/src/cli/core/command-help.ts
squad-sdk (1 file)
  • packages/squad-sdk/src/presets/index.ts
tests (1 file)
  • test/presets.test.ts

⚠️ Critical Files

  • packages/squad-sdk/src/presets/index.ts

This report is generated automatically for every PR. See #733 for details.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

ℹ️ This comment updates on each push. Last checked: commit 8288297

PR Scope: 📦🔧 Mixed (product + infrastructure)

⚠️ 4 item(s) to address before review

Status Check Details
Single commit 2 commits — consider squashing before review
Not in draft Ready for review
Branch up to date Up to date with dev
Copilot review No Copilot review yet — it may still be processing
Changeset present Changeset file found
Scope clean No .squad/ or docs/proposals/ files
No merge conflicts No merge conflicts
Copilot threads resolved 2 unresolved Copilot thread(s) — fix and resolve before merging
CI passing 4 check(s) still running

Files Changed (5 files, +581 −3)

File +/−
.changeset/feat-preset-install.md +60 −0
packages/squad-cli/src/cli/commands/preset.ts +56 −1
packages/squad-cli/src/cli/core/command-help.ts +12 −2
packages/squad-sdk/src/presets/index.ts +330 −0
test/presets.test.ts +123 −0

Total: +581 −3


This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.

@tamirdresher

Copy link
Copy Markdown
Collaborator Author

@bradygaster — fix for #1224. Adds squad preset install <source> (GitHub URL, SSH URL, or local path).

Smoke-tested 6 cases on local build:

  1. Local single-preset install
  2. Idempotency (re-install fails without --force)
  3. --force overwrites
  4. --name rename + manifest rewrite
  5. Invalid source → clear error
  6. Real GitHub URL — https://github.com/bradygaster/squad/tree/main/packages/squad-sdk/src/presets/builtin/default → installed as brady-default with all 5 agents

Minor bump for both sdk + cli. CHANGELOG gate should pass cleanly (changeset included).

Build hit a snag locally — workspaces had a stale real-copy of squad-sdk inside packages/squad-cli/node_modules/@bradygaster/ instead of a junction. npm install after removing it restored the workspace link. Worth a clean script in the squad-cli package if this happens again, but not blocking this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new preset distribution workflow by introducing squad preset install <source>, allowing users to install a single preset from a GitHub repo URL (via shallow clone) or a local path into $SQUAD_HOME/presets/<name>/.

Changes:

  • SDK: add installPresetFromSource(source, options) with source resolution (local vs git clone), preset discovery rules, optional rename stamping, and --force overwrite behavior.
  • CLI: add preset install subcommand wiring, usage output, and install status messages.
  • Release: add changeset bumping CLI and SDK as minor.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
packages/squad-sdk/src/presets/index.ts Implements the core install-from-source logic, including git shallow-clone + preset discovery/copying.
packages/squad-cli/src/cli/commands/preset.ts Adds the install subcommand routing and user-facing CLI output for installs.
.changeset/feat-preset-install.md Documents the feature and bumps CLI/SDK versions.

Comment on lines +292 to +304
// Resolve the source into a local working directory + optional sub-path inside it.
// Returns { workDir, subPath, cleanup } — caller must call cleanup() in a finally.
const { workDir, subPath, cleanup } = resolveInstallSource(source);

try {
// Locate the actual preset directory inside workDir
const startDir = subPath ? path.join(workDir, subPath) : workDir;
if (!storage.existsSync(startDir) || !isDirSync(startDir)) {
throw new Error(`Source path does not exist or is not a directory: ${startDir}`);
}

const { presetDir, defaultName } = locatePresetWithinSource(startDir, options.name);

Comment thread packages/squad-sdk/src/presets/index.ts Outdated
Comment on lines +415 to +418
try {
const refArgs = ref ? ['--branch', ref] : [];
const args = ['clone', '--depth', '1', ...refArgs, cloneUrl, tmpBase];
execSync(`git ${args.map(a => /[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ')}`, { stdio: 'pipe' });
Comment on lines +449 to +454
const presetsSubDir = path.join(startDir, 'presets');
if (storage.existsSync(presetsSubDir) && isDirSync(presetsSubDir)) {
if (nameHint) {
const candidate = path.join(presetsSubDir, nameHint);
if (storage.existsSync(path.join(candidate, 'preset.json'))) {
return { presetDir: candidate, defaultName: nameHint };
Comment on lines +82 to +85
const force = args.includes('--force');
const nameIdx = args.indexOf('--name');
const nameOverride = nameIdx >= 0 ? args[nameIdx + 1] : undefined;
await presetInstall(source!, nameOverride, force);
*
* @throws Error on any validation failure, manifest invalidity, or destination collision.
*/
export function installPresetFromSource(source: string, options: InstallPresetOptions = {}): InstallPresetResult {
Comment on lines 93 to 97
` 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]`,

@bradygaster bradygaster left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Flight requests changes. Blocking issues: the new git clone path in packages/squad-sdk/src/presets/index.ts builds an execSync shell string from user-controlled input (shell injection risk), and the documented #preset-name repo URL flow does not resolve multi-preset repos correctly. Please switch to execFileSync/spawnSync with arg arrays and fix fragment handling before approval.

…o URL (bradygaster#1224)

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

SDK side (squad-sdk/src/presets/index.ts):
- New \installPresetFromSource(source, options)\ function
- Resolves source: GitHub URL → shallow git clone --depth 1 to OS temp;
  local path → use as-is
- Locates preset within source via 3 patterns:
  - dir contains preset.json → single-preset source
  - dir contains presets/ subdir → require --name to pick
  - dir IS the presets/ dir → require --name (or auto-pick if only one)
- Validates preset.json before any destructive action
- Copies preset.json (with optional rename) + agents/ into squad home
- Cleans up temp clones in finally block (success or failure)
- Exports: installPresetFromSource, InstallPresetOptions, InstallPresetResult

CLI side (squad-cli/src/cli/commands/preset.ts):
- New 'install' dispatcher case + presetInstall() function
- Supports --name <override>, --force
- Module docstring + default usage help updated to include 'install'

Supported source shapes:
  https://github.com/owner/repo
  https://github.com/owner/repo#preset-name           (frag as subdir hint)
  https://github.com/owner/repo/tree/branch/path/...  (sub-path)
  git\@github.com:owner/repo.git                     (SSH)
  ./local/path                                       (single preset OR collection)

Smoke tested all 6 cases locally:
  1. Local single-preset → installs under manifest.name ✅
  2. Idempotent re-install fails without --force ✅
  3. --force overwrites ✅
  4. --name renames + updates manifest.name ✅
  5. Invalid source → clear error ✅
  6. GitHub URL (cloned bradygaster/squad's presets/builtin/default) ✅

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tamirdresher tamirdresher force-pushed the feat/1224-preset-install branch from 4cafee9 to 0c7f354 Compare June 13, 2026 18:30
tamirdresher added a commit to tamirdresher/squad that referenced this pull request Jun 13, 2026
…ent semantics, tests, help block

Addresses all six review comments on bradygaster#1225:

1. [security] git clone via execSync was vulnerable to shell injection
   because the command was built as a string. Switched to execFileSync
   with an argument array (no shell), so source / ref values containing
   ';' '&&' '|' backticks '\' etc. can no longer be interpreted by a
   shell. The earlier ad-hoc whitespace/quote escaping was the wrong
   defence layer.

2. [security] nameHint was used in path.join without validation. A value
   like '../something' would have escaped the presets/ directory. Added
   validatePathSegment() that rejects path separators ('/' '\\'), '..',
   '.', null bytes — same shape as validateName for preset identifiers.
   Applied both at the public installPresetFromSource() entry AND inside
   locatePresetWithinSource() as defence-in-depth in case future callers
   go direct. Also added validateSubPath() for the URL-fragment-derived
   subPath: rejects absolute paths and '..' segments.

3. [correctness] Fragment semantics: 'repo#some-name' (bare fragment, no
   slash) was being treated as a literal subPath, so it looked for
   <clone>/some-name/ and broke the documented <clone>/presets/some-name/
   collection layout from the PR description. Restructured
   resolveInstallSource to return a new nameHint field alongside subPath:
     - Fragment WITH '/'   → literal subPath (e.g. repo#packs/team-a)
     - Fragment WITHOUT '/' → preset-name HINT (e.g. repo#my-team)
   The nameHint is forwarded to locatePresetWithinSource without being
   used as a path segment itself, so the common collection layout now
   works as advertised.

4. [UX] --name parsing didn't validate that a value was actually
   provided. 'squad preset install <src> --name' (no value) or
   '--name --force' (next arg is a flag) silently produced undefined or
   '--force' as the override and failed downstream with a confusing
   error. Added an early fail-fast guard with a clear usage hint.

5. [tests] Added 7 focused tests for installPresetFromSource covering
   the new code path:
     - single-preset local source (startDir/preset.json present)
     - collection local source + --name selection
     - collection source without --name throws with helpful 'specify
       one with --name' message
     - --force overwrite of an existing same-name preset
     - --name rename + manifest.name stamping (other manifest fields
       preserved)
     - --name path-escape attempts are rejected (../escape, a/b, a\\b,
       '.', '..', embedded null bytes)
     - empty source throws 'required'
   Remote (URL) branch isn't stubbed here — splitting the git-clone
   call into a small helper that tests can mock is a separate follow-up.

6. [docs] preset help block in command-help.ts still printed
   Usage: squad preset <list|show|apply|save|init> without 'install'.
   Updated to include 'install <source>', the new --name option, and a
   concise documentation of the fragment semantics from fix #3 so users
   see the right shapes from --help.

Verified locally: 36/36 preset tests pass (29 existing + 7 new);
14/14 command-help tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🏗️ Architectural Review

⚠️ Architectural review: 1 warning(s).

Severity Category Finding Files
🟡 warning bootstrap-area 1 file(s) in the bootstrap area (packages/squad-cli/src/cli/core/) were modified. These files must maintain zero external dependencies. Review carefully. packages/squad-cli/src/cli/core/command-help.ts

Automated architectural review — informational only.

…ent semantics, tests, help block

Addresses all six review comments on bradygaster#1225:

1. [security] git clone via execSync was vulnerable to shell injection
   because the command was built as a string. Switched to execFileSync
   with an argument array (no shell), so source / ref values containing
   ';' '&&' '|' backticks etc. can no longer be interpreted by a shell.
   The earlier ad-hoc whitespace/quote escaping was the wrong defence
   layer.

2. [security] nameHint was used in path.join without validation. A value
   like '../something' would have escaped the presets/ directory. Added
   validatePathSegment() that rejects path separators ('/' '\\'), '..',
   '.', null bytes. Applied both at the public installPresetFromSource()
   entry AND inside locatePresetWithinSource() as defence-in-depth in
   case future callers go direct. Also added validateSubPath() for the
   URL-fragment-derived subPath: rejects absolute paths and '..' segments.

3. [correctness] Fragment semantics: 'repo#some-name' (bare fragment, no
   slash) was being treated as a literal subPath, so it looked for
   <clone>/some-name/ and broke the documented <clone>/presets/some-name/
   collection layout from the PR description. Restructured
   resolveInstallSource to return a new nameHint field alongside subPath:
     - Fragment WITH '/'    -> literal subPath (e.g. repo#packs/team-a)
     - Fragment WITHOUT '/' -> preset-name HINT (e.g. repo#my-team)
   The nameHint is forwarded to locatePresetWithinSource without being
   used as a path segment itself, so the common collection layout now
   works as advertised.

4. [UX] --name parsing didn't validate that a value was actually
   provided. 'squad preset install <src> --name' (no value) or
   '--name --force' (next arg is a flag) silently produced undefined or
   '--force' as the override and failed downstream with a confusing
   error. Added an early fail-fast guard with a clear usage hint.

5. [tests] Added 7 focused tests for installPresetFromSource covering
   the new code path:
     - single-preset local source (startDir/preset.json present)
     - collection local source + --name selection
     - collection source without --name throws with helpful message
     - --force overwrite of an existing same-name preset
     - --name rename + manifest.name stamping (other fields preserved)
     - --name path-escape attempts are rejected
     - empty source throws 'required'
   Remote (URL) branch isn't stubbed here — splitting the git-clone
   call into a small helper that tests can mock is a separate follow-up.

6. [docs] preset help block in command-help.ts still printed
   Usage: squad preset <list|show|apply|save|init> without 'install'.
   Updated to include 'install <source>', the new --name option, and a
   concise documentation of the fragment semantics from fix #3.

Verified locally: 36/36 preset tests pass (29 existing + 7 new);
14/14 command-help tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tamirdresher tamirdresher force-pushed the feat/1224-preset-install branch from 3d3c111 to 8288297 Compare June 13, 2026 18:55
@tamirdresher

Copy link
Copy Markdown
Collaborator Author

All 6 review comments addressed in commit 8288297d — the comment line numbers stay anchored to the previous force-push so I'm summarising here instead of posting per-thread replies:

# Reviewer thread Fix
1 Fragment semantics: repo#some-name was treated as a literal subpath Restructured resolveInstallSource to return a new nameHint field alongside subPath. Fragment WITH / → subPath; fragment WITHOUT / → preset-name hint, forwarded to locatePresetWithinSource without being used as a path segment. repo#preset-name now correctly resolves to <clone>/presets/preset-name/. Also added validateSubPath() (rejects absolute + .. segments).
2 git clone via execSync was shell-injection-vulnerable Switched to execFileSync('git', args, { stdio: 'pipe' }) with an argument array. No shell, so ; && `
3 nameHint used in path.join without validation Added validatePathSegment(name, 'preset name') that rejects path separators (/ \), .., ., null bytes. Applied at the public installPresetFromSource() entry AND inside locatePresetWithinSource() as defence-in-depth in case future callers go direct.
4 --name parsing didn't validate a value was provided Added a fail-fast guard: squad preset install <src> --name (no value) or --name --force (next arg is a flag) now exit with --name requires a value. Usage: squad preset install <source> --name <override> instead of silently using undefined or '--force'.
5 No tests for the new installer Added 7 focused tests in test/presets.test.ts: single-preset local source; collection local source + --name selection; collection without --name throws helpful message; --force overwrite; --name rename + manifest.name stamping (other fields preserved); path-escape attempts rejected (../escape, a/b, a\b, ., .., null bytes); empty source throws required. Remote (URL) branch tests need stubbing the git clone — extracting that into a small helper for hermetic tests is filed as a follow-up.
6 squad preset --help block didn't list install Updated packages/squad-cli/src/cli/core/command-help.ts to include install <source> in the usage line, add a Subcommands: section documenting the supported <source> shapes (with the fixed fragment semantics from fix #1), and document the new --name <override> option.

Verified locally: 36/36 preset tests pass (29 existing + 7 new); 14/14 command-help tests pass.

Branch force-pushed to tamirdresher:feat/1224-preset-install. The commit contains only the 4 files that should change (packages/squad-sdk/src/presets/index.ts, packages/squad-cli/src/cli/commands/preset.ts, packages/squad-cli/src/cli/core/command-help.ts, test/presets.test.ts).

@tamirdresher

Copy link
Copy Markdown
Collaborator Author

@bradygaster ready for re-review — your two blocking concerns are addressed in 8288297d:

  1. Shell injection → replaced execSync(string) with execFileSync('git', args[]) and added validatePathSegment() + validateSubPath() guards (rejects /, \, .., ., null bytes; rejects absolute paths and parent-segment escapes)
  2. Fragment semantics for multi-preset repos → distinct paths now:
    • repo#name (no slash in fragment) → nameHint (selects a single preset by name from a collection)
    • repo#sub/path (slash in fragment) → subPath (selects a specific dir)
    • --name <new> flag overrides both with fail-fast validation that the next arg isn't another flag

Plus 7 new tests in test/presets.test.ts covering single-preset, collection, --force, --name rename, and path-escape rejection. CI is 12/12 ✓.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add 'squad preset install <source>' for sharing presets via repo URL

3 participants