Skip to content

Commit 7d1f022

Browse files
authored
chore: release v5.2.1 (#275)
1 parent ba4ec8b commit 7d1f022

File tree

8 files changed

+189
-53
lines changed

8 files changed

+189
-53
lines changed

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "agentsys",
33
"description": "14 specialized plugins for AI workflow automation - task orchestration, PR workflow, slop detection, code review, drift detection, enhancement analysis, documentation sync, repo mapping, perf investigations, topic research, agent config linting, cross-tool AI consultation, and structured AI debate",
4-
"version": "5.2.0",
4+
"version": "5.2.1",
55
"owner": {
66
"name": "Avi Fenesh",
77
"url": "https://github.com/avifenesh"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agentsys",
3-
"version": "5.2.0",
3+
"version": "5.2.1",
44
"description": "Professional-grade slash commands for Claude Code with cross-platform support",
55
"keywords": [
66
"workflow",

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
## [5.2.1] - 2026-03-01
13+
14+
### Fixed
15+
16+
- **Installer marketplace source parsing** — Added compatibility for both legacy string `source` values and structured source objects (`{ source: "url", url: "..." }`) so installs no longer crash with `plugin.source.startsWith is not a function`.
17+
- **Plugin fetch resilience and failure behavior** — Normalized `.git` repository URLs, added GitHub ref fallback order (`vX.Y.Z`, `X.Y.Z`, `main`, `master`), and fail-fast behavior when any plugin fetch fails.
18+
- **Cross-platform install ordering** — Fixed install sequence so local install directory reset no longer wipes the fetched plugin cache before OpenCode/Codex installation.
19+
1220
## [5.2.0] - 2026-02-27
1321

1422
### Added

__tests__/cli-subcommands.test.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ const {
2020
parseInstallTarget,
2121
loadComponents,
2222
resolveComponent,
23-
buildFilterFromComponent
23+
buildFilterFromComponent,
24+
resolvePluginSource,
25+
parseGitHubSource
2426
} = require('../bin/cli.js');
2527

2628
describe('CLI subcommand parsing', () => {
@@ -379,3 +381,52 @@ describe('loadMarketplace', () => {
379381
}
380382
});
381383
});
384+
385+
describe('resolvePluginSource', () => {
386+
test('supports legacy string URL sources', () => {
387+
expect(resolvePluginSource('https://github.com/agent-sh/ship.git')).toEqual({
388+
type: 'remote',
389+
value: 'https://github.com/agent-sh/ship.git'
390+
});
391+
});
392+
393+
test('supports structured URL source objects', () => {
394+
expect(resolvePluginSource({
395+
source: 'url',
396+
url: 'https://github.com/agent-sh/ship.git'
397+
})).toEqual({
398+
type: 'remote',
399+
value: 'https://github.com/agent-sh/ship.git'
400+
});
401+
});
402+
403+
test('classifies local path sources as local', () => {
404+
expect(resolvePluginSource({
405+
source: 'path',
406+
path: './plugins/ship'
407+
})).toEqual({
408+
type: 'local',
409+
value: './plugins/ship'
410+
});
411+
});
412+
});
413+
414+
describe('parseGitHubSource', () => {
415+
test('normalizes .git suffix in https URLs', () => {
416+
expect(parseGitHubSource('https://github.com/agent-sh/ship.git', '1.0.0')).toEqual({
417+
owner: 'agent-sh',
418+
repo: 'ship',
419+
ref: 'v1.0.0',
420+
explicitRef: false
421+
});
422+
});
423+
424+
test('preserves explicit refs', () => {
425+
expect(parseGitHubSource('https://github.com/agent-sh/ship.git#main', '1.0.0')).toEqual({
426+
owner: 'agent-sh',
427+
repo: 'ship',
428+
ref: 'main',
429+
explicitRef: true
430+
});
431+
});
432+
});

bin/cli.js

Lines changed: 123 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -231,18 +231,60 @@ function loadMarketplace() {
231231
}
232232

233233
/**
234-
* Resolve the source URL string from a plugin's source field.
235-
* Handles both legacy string format ("https://...") and the new object
236-
* format ({ source: "url", url: "https://..." }) from Claude Code plugin schema.
237-
* Returns null for local/bundled sources or missing values.
234+
* Normalize marketplace plugin source entries.
235+
*
236+
* Supported formats:
237+
* - string URL/path (legacy)
238+
* - object: { source: "url", url: "..." } (current)
239+
* - object: { source: "path", path: "..." } (local/bundled)
240+
*
241+
* @param {string|Object} source
242+
* @returns {{type: 'remote'|'local', value: string}|null}
238243
*/
239-
function resolveSourceUrl(source) {
240-
if (!source) return null;
241-
if (typeof source === 'string') return source;
242-
if (typeof source === 'object' && source.url) return source.url;
244+
function resolvePluginSource(source) {
245+
if (typeof source === 'string') {
246+
const value = source.trim();
247+
if (!value) return null;
248+
if (value.startsWith('./') || value.startsWith('../')) {
249+
return { type: 'local', value };
250+
}
251+
return { type: 'remote', value };
252+
}
253+
254+
if (!source || typeof source !== 'object') return null;
255+
256+
const sourceType = typeof source.source === 'string' ? source.source.toLowerCase() : null;
257+
258+
if ((sourceType === 'path' || sourceType === 'local') && typeof source.path === 'string') {
259+
return { type: 'local', value: source.path };
260+
}
261+
262+
if (sourceType === 'url' && typeof source.url === 'string') {
263+
return { type: 'remote', value: source.url };
264+
}
265+
266+
// Backward/forward-compatible fallbacks
267+
if (typeof source.path === 'string') {
268+
return { type: 'local', value: source.path };
269+
}
270+
if (typeof source.url === 'string') {
271+
return { type: 'remote', value: source.url };
272+
}
273+
243274
return null;
244275
}
245276

277+
/**
278+
* Backward-compatible helper returning only the source URL/path value.
279+
*
280+
* @param {string|Object} source
281+
* @returns {string|null}
282+
*/
283+
function resolveSourceUrl(source) {
284+
const normalized = resolvePluginSource(source);
285+
return normalized ? normalized.value : null;
286+
}
287+
246288
/**
247289
* Resolve plugin dependencies transitively.
248290
*
@@ -313,45 +355,72 @@ async function fetchPlugin(name, source, version) {
313355
}
314356
}
315357

358+
const parsedSource = parseGitHubSource(source, version, name);
359+
const owner = parsedSource.owner;
360+
const repo = parsedSource.repo;
361+
362+
const refCandidates = parsedSource.explicitRef
363+
? [parsedSource.ref]
364+
: [parsedSource.ref, version, 'main', 'master'];
365+
366+
let lastError = null;
367+
for (const ref of [...new Set(refCandidates.filter(Boolean))]) {
368+
const tarballUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`;
369+
370+
try {
371+
console.log(` Fetching ${name}@${version} from ${owner}/${repo} (${ref})...`);
372+
373+
// Clean and recreate
374+
if (fs.existsSync(pluginDir)) {
375+
fs.rmSync(pluginDir, { recursive: true, force: true });
376+
}
377+
fs.mkdirSync(pluginDir, { recursive: true });
378+
379+
// Download and extract tarball
380+
await downloadAndExtractTarball(tarballUrl, pluginDir);
381+
382+
// Write version marker
383+
fs.writeFileSync(versionFile, version);
384+
return pluginDir;
385+
} catch (err) {
386+
lastError = err;
387+
const isNotFound = /HTTP 404/.test(err.message);
388+
if (isNotFound && !parsedSource.explicitRef) {
389+
continue;
390+
}
391+
throw err;
392+
}
393+
}
394+
395+
throw new Error(
396+
`Unable to fetch ${name} from ${owner}/${repo}. Tried refs: ${[...new Set(refCandidates.filter(Boolean))].join(', ')}. Last error: ${lastError ? lastError.message : 'unknown error'}`
397+
);
398+
}
399+
400+
/**
401+
* Parse GitHub source URL formats and normalize repo name.
402+
*
403+
* @param {string} source
404+
* @param {string} version
405+
* @param {string} [name]
406+
* @returns {{owner: string, repo: string, ref: string, explicitRef: boolean}}
407+
*/
408+
function parseGitHubSource(source, version, name = 'plugin') {
316409
// Parse source formats:
317410
// "https://github.com/owner/repo" or "https://github.com/owner/repo#ref"
318411
// "github:owner/repo" or "github:owner/repo#ref"
319-
let owner, repo, ref;
320412
const urlMatch = source.match(/github\.com\/([^/]+)\/([^/#]+)(?:#(.+))?/);
321413
const shortMatch = !urlMatch && source.match(/^github:([^/]+)\/([^#]+)(?:#(.+))?$/);
322414
const match = urlMatch || shortMatch;
323415
if (!match) {
324416
throw new Error(`Unsupported source format for ${name}: ${source}`);
325417
}
326-
owner = match[1];
327-
repo = match[2].replace(/\.git$/, '');
328-
ref = match[3] || `v${version}`;
329-
330-
console.log(` Fetching ${name}@${version} from ${owner}/${repo}...`);
331-
332-
// Clean and recreate
333-
if (fs.existsSync(pluginDir)) {
334-
fs.rmSync(pluginDir, { recursive: true, force: true });
335-
}
336-
fs.mkdirSync(pluginDir, { recursive: true });
337-
338-
// Download and extract tarball, falling back to main branch if version tag 404s
339-
const tarballUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`;
340-
try {
341-
await downloadAndExtractTarball(tarballUrl, pluginDir);
342-
} catch (err) {
343-
if (ref !== 'main' && err.message && err.message.includes('404')) {
344-
const mainUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/main`;
345-
await downloadAndExtractTarball(mainUrl, pluginDir);
346-
} else {
347-
throw err;
348-
}
349-
}
350418

351-
// Write version marker
352-
fs.writeFileSync(versionFile, version);
353-
354-
return pluginDir;
419+
const owner = match[1];
420+
const repo = match[2].replace(/\.git$/, '');
421+
const explicitRef = Boolean(match[3]);
422+
const ref = match[3] || `v${version}`;
423+
return { owner, repo, ref, explicitRef };
355424
}
356425

357426
/**
@@ -470,16 +539,17 @@ async function fetchExternalPlugins(pluginNames, marketplace) {
470539
const plugin = pluginMap[name];
471540
if (!plugin) continue;
472541

473-
// If source is local (starts with ./), plugin is bundled - just use PACKAGE_DIR
474-
const sourceUrl = resolveSourceUrl(plugin.source);
475-
if (!sourceUrl || sourceUrl.startsWith('./') || sourceUrl.startsWith('../')) {
542+
const source = resolvePluginSource(plugin.source);
543+
544+
// Local/bundled plugin, no external fetch needed
545+
if (!source || source.type === 'local') {
476546
// Bundled plugin, no fetch needed
477547
fetched.push(name);
478548
continue;
479549
}
480550

481551
try {
482-
await fetchPlugin(name, sourceUrl, plugin.version);
552+
await fetchPlugin(name, source.value, plugin.version);
483553
fetched.push(name);
484554
} catch (err) {
485555
failed.push(name);
@@ -493,6 +563,8 @@ async function fetchExternalPlugins(pluginNames, marketplace) {
493563
console.error(`\n [WARN] Missing dependencies: ${missingDeps.join(', ')}`);
494564
console.error(` Some plugins may not work correctly without their dependencies.`);
495565
}
566+
567+
throw new Error(`Failed to fetch ${failed.length} plugin(s): ${failed.join(', ')}`);
496568
}
497569

498570
return fetched;
@@ -917,12 +989,15 @@ async function installPlugin(nameWithVersion, args) {
917989
// Fetch all
918990
for (const depName of toFetch) {
919991
const dep = pluginMap[depName];
920-
const depSourceUrl = resolveSourceUrl(dep && dep.source);
921-
if (!dep || !depSourceUrl || depSourceUrl.startsWith('./')) continue;
992+
if (!dep) continue;
993+
994+
const source = resolvePluginSource(dep.source);
995+
if (!source || source.type === 'local') continue;
996+
922997
checkCoreCompat(dep);
923998
const ver = depName === name && requestedVersion ? requestedVersion : dep.version;
924999
try {
925-
await fetchPlugin(depName, depSourceUrl, ver);
1000+
await fetchPlugin(depName, source.value, ver);
9261001
} catch (err) {
9271002
console.error(` [ERROR] Failed to fetch ${depName}: ${err.message}`);
9281003
}
@@ -1931,8 +2006,6 @@ async function main() {
19312006
if (entry) checkCoreCompat(entry);
19322007
}
19332008

1934-
await fetchExternalPlugins(pluginNames, marketplace);
1935-
19362009
// Only copy to ~/.agentsys if OpenCode, Codex, or Cursor selected (they need local files)
19372010
const needsLocalInstall = selected.includes('opencode') || selected.includes('codex') || selected.includes('cursor');
19382011
let installDir = null;
@@ -1944,6 +2017,8 @@ async function main() {
19442017
installDependencies(installDir);
19452018
}
19462019

2020+
await fetchExternalPlugins(pluginNames, marketplace);
2021+
19472022
// Install for each platform
19482023
const failedPlatforms = [];
19492024
for (const platform of selected) {
@@ -2023,5 +2098,7 @@ module.exports = {
20232098
loadComponents,
20242099
resolveComponent,
20252100
buildFilterFromComponent,
2101+
resolvePluginSource,
2102+
parseGitHubSource,
20262103
installForCursor
20272104
};

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agentsys",
3-
"version": "5.2.0",
3+
"version": "5.2.1",
44
"description": "A modular runtime and orchestration system for AI agents - works with Claude Code, OpenCode, and Codex CLI",
55
"main": "lib/platform/detect-platform.js",
66
"type": "commonjs",

site/content.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"url": "https://agent-sh.github.io/agentsys",
66
"repo": "https://github.com/agent-sh/agentsys",
77
"npm": "https://www.npmjs.com/package/agentsys",
8-
"version": "5.2.0",
8+
"version": "5.2.1",
99
"author": "Avi Fenesh",
1010
"author_url": "https://github.com/avifenesh"
1111
},

0 commit comments

Comments
 (0)