hngr 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { S as HngrError, g as resolveConnection, r as apiRequest, y as writeDirectoryAtomically } from "./http-2HWxLIOq.mjs";
2
- import { _ as resolveCapabilityVersion, a as failedResult, c as stringValues, d as homeDir, f as downloadContent, g as resolveCapability, h as parseCapabilityPath, i as errorBody, l as AGENTS, m as SKILL_KIND, n as readPublishFlags, o as positionalPaths, r as runPublish, s as runBatch, t as explicitName } from "./publish-EUqgdp0G.mjs";
1
+ import { C as HngrError, b as writeDirectoryAtomically, g as resolveConnection, r as apiRequest } from "./http-DgHX7Fux.mjs";
2
+ import { _ as resolveCapability, a as errorBody, c as runBatch, f as homeDir, g as parseCapabilityPath, h as SKILL_KIND, i as runPublish, l as stringValues, n as explicitName, o as failedResult, p as downloadContent, r as readPublishFlags, s as positionalPaths, t as resolveInstallScope, u as AGENTS, v as resolveCapabilityVersion } from "./scope-DVfDeQi2.mjs";
3
3
  import { lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
@@ -92,13 +92,6 @@ function downloadSkillPackage(connection, capabilityId, version, signal) {
92
92
  async function pathExists(path) {
93
93
  return lstat(path).then(() => true, () => false);
94
94
  }
95
- function targetExistsError(path) {
96
- return new HngrError({
97
- type: "io",
98
- title: `Target already exists: ${path}`,
99
- detail: "Pass --force to replace it."
100
- });
101
- }
102
95
  async function writeEntries(dest, entries) {
103
96
  await mkdir(dest, { recursive: true });
104
97
  for (const entry of entries) {
@@ -114,25 +107,22 @@ async function writeEntries(dest, entries) {
114
107
  }
115
108
  /**
116
109
  * Writes a skill through "temp directory → validate → rename": the target is
117
- * either absent or complete, never half-written. `--force` moves the old
118
- * directory aside before the rename so a failed swap puts it back.
110
+ * either absent or complete, never half-written. Re-installing replaces the
111
+ * previous directory, with the old one moved aside so a failed swap puts it
112
+ * back.
119
113
  */
120
- async function installAtomically(dest, entries, force) {
121
- await writeDirectoryAtomically(dest, (temp) => writeEntries(temp, entries), {
122
- force,
123
- existsError: () => targetExistsError(dest)
124
- });
114
+ async function installAtomically(dest, entries) {
115
+ await writeDirectoryAtomically(dest, (temp) => writeEntries(temp, entries));
125
116
  }
126
117
  /**
127
118
  * Creates a relative symlink to the canonical directory, falling back to a
128
119
  * copy when the platform refuses the link (Windows without Developer Mode).
129
120
  * Windows prefers a junction, which does not need elevated privileges; its
130
121
  * target must be absolute. A temp link plus rename replaces an existing link
131
- * atomically.
122
+ * atomically; re-installing replaces an existing directory or link.
132
123
  */
133
- async function linkAtomically(canonical, target, entries, force, options = {}) {
124
+ async function linkAtomically(canonical, target, entries, options = {}) {
134
125
  const exists = await pathExists(target);
135
- if (exists && !force) throw targetExistsError(target);
136
126
  await mkdir(dirname(target), { recursive: true });
137
127
  const temp = resolve(dirname(target), `.hngr-${basename(target)}-${randomUUID()}.link`);
138
128
  const windows = (options.platform ?? process.platform) === "win32";
@@ -150,7 +140,7 @@ async function linkAtomically(canonical, target, entries, force, options = {}) {
150
140
  return { mode: "symlink" };
151
141
  } catch {
152
142
  await rm(temp, { force: true }).catch(() => {});
153
- await installAtomically(target, entries, false);
143
+ await installAtomically(target, entries);
154
144
  return {
155
145
  mode: "copy",
156
146
  warning: `warning: could not create a link at ${target}; copied instead`
@@ -158,8 +148,8 @@ async function linkAtomically(canonical, target, entries, force, options = {}) {
158
148
  }
159
149
  }
160
150
  /** Copies one target directory atomically, without a canonical store. */
161
- async function copyAtomically(target, entries, force) {
162
- await installAtomically(target, entries, force);
151
+ async function copyAtomically(target, entries) {
152
+ await installAtomically(target, entries);
163
153
  }
164
154
  // src/skills/resolve.ts
165
155
  /** Parses `<name>` or `<namespace>/<name>`, with an optional `@YYYY.MM.DD.N`. */
@@ -562,17 +552,19 @@ function failureResult(parsed, err, version) {
562
552
  canonicalPath: null
563
553
  };
564
554
  }
565
- /** Prompts need both stdin and stdout on a TTY plus a prompter that can multi-select. */
555
+ /** Agent prompts need a TTY on both ends, a multi-select prompter, and human output mode. */
566
556
  function canPrompt(ctx) {
567
- return ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts?.multiselect !== void 0;
557
+ return !ctx.flags.json && ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts?.multiselect !== void 0;
568
558
  }
569
559
  function noAgentSelectionError(examplePath, global) {
570
560
  return usage("No agent target selected", `Pass --agent <id> (repeatable) or --all. For example: hngr skills add ${examplePath} --agent claude-code${global ? " --global" : ""}`);
571
561
  }
562
+ const ALL_AGENTS = "__all__";
572
563
  /**
573
- * One multi-select for every Agent Target, pre-selecting those whose skills
574
- * directory is detected. Detection is only a pre-selection hint; a non-TTY
575
- * invocation never consults the filesystem and must pass --agent or --all.
564
+ * One multi-select for the agents that keep their own skills directory.
565
+ * `.agents` is always installed, so universal agents never appear here.
566
+ * Detection only preselects; a non-TTY invocation never consults the
567
+ * filesystem and must pass --agent or --all.
576
568
  */
577
569
  async function selectAgents(ctx, argv, examplePath, global) {
578
570
  const requested = stringValues(argv.agent);
@@ -582,26 +574,28 @@ async function selectAgents(ctx, argv, examplePath, global) {
582
574
  return AGENT_TARGETS.filter((agent) => requested.includes(agent.id));
583
575
  }
584
576
  if (argv.all === true) return [...AGENT_TARGETS];
585
- if (canPrompt(ctx)) {
586
- const multiselect = ctx.io.prompts?.multiselect;
587
- if (!multiselect) throw noAgentSelectionError(examplePath, global);
588
- const cwd = process.cwd();
589
- const choices = [];
590
- for (const agent of AGENT_TARGETS) {
591
- const dir = targetSkillsDir(agent, cwd, ctx.env, global);
592
- choices.push({
593
- option: {
594
- value: agent.id,
595
- label: `${agent.id} ${displayPath(dir, cwd)}`
596
- },
597
- detected: await pathExists(dirname(dir))
598
- });
599
- }
600
- const selected = await multiselect("Select agent targets", choices.map((choice) => choice.option), choices.filter((choice) => choice.detected).map((choice) => choice.option.value));
601
- if (selected.length === 0) throw noAgentSelectionError(examplePath, global);
602
- return AGENT_TARGETS.filter((agent) => selected.includes(agent.id));
577
+ const multiselect = ctx.io.prompts?.multiselect;
578
+ if (!canPrompt(ctx) || !multiselect) throw noAgentSelectionError(examplePath, global);
579
+ const cwd = process.cwd();
580
+ const linkAgents = AGENT_TARGETS.filter((agent) => agent.link);
581
+ const options = [{
582
+ value: ALL_AGENTS,
583
+ label: "All agents",
584
+ hint: "select every agent below"
585
+ }];
586
+ const initialValues = [];
587
+ for (const agent of linkAgents) {
588
+ const dir = targetSkillsDir(agent, cwd, ctx.env, global);
589
+ options.push({
590
+ value: agent.id,
591
+ label: agent.id,
592
+ hint: displayPath(dir, cwd)
593
+ });
594
+ if (await pathExists(dirname(dir))) initialValues.push(agent.id);
603
595
  }
604
- throw noAgentSelectionError(examplePath, global);
596
+ const selected = await multiselect("Install for another agent", options, initialValues, ".agents/skills is always installed; press Enter to skip");
597
+ const chosen = selected.includes(ALL_AGENTS) ? new Set(linkAgents.map((agent) => agent.id)) : new Set(selected);
598
+ return linkAgents.filter((agent) => chosen.has(agent.id));
605
599
  }
606
600
  function frontmatterName(manifest) {
607
601
  const text = manifest.toString("utf8");
@@ -657,16 +651,12 @@ function planInstall(cwd, env, destName, agents, options) {
657
651
  async function install(ctx, plan, entries) {
658
652
  const cwd = process.cwd();
659
653
  const display = (path) => displayPath(path, cwd);
660
- const destinations = [plan.canonical, ...plan.targets.map((target) => target.path)].filter((path) => path !== null);
661
- if (!ctx.flags.force) {
662
- for (const path of destinations) if (await pathExists(path)) throw targetExistsError(display(path));
663
- }
664
- if (plan.canonical) await installAtomically(plan.canonical, entries, ctx.flags.force);
654
+ if (plan.canonical) await installAtomically(plan.canonical, entries);
665
655
  const installed = [];
666
656
  let failure = null;
667
657
  for (const target of plan.targets) try {
668
658
  if (target.mode === "copy" || !plan.canonical) {
669
- await copyAtomically(target.path, entries, ctx.flags.force);
659
+ await copyAtomically(target.path, entries);
670
660
  installed.push({
671
661
  agent: target.agent,
672
662
  path: display(target.path),
@@ -675,7 +665,7 @@ async function install(ctx, plan, entries) {
675
665
  });
676
666
  continue;
677
667
  }
678
- const result = await linkAtomically(plan.canonical, target.path, entries, ctx.flags.force);
668
+ const result = await linkAtomically(plan.canonical, target.path, entries);
679
669
  if (result.warning) ctx.summary(result.warning);
680
670
  installed.push({
681
671
  agent: target.agent,
@@ -697,6 +687,26 @@ async function install(ctx, plan, entries) {
697
687
  failure
698
688
  };
699
689
  }
690
+ function withDot(path) {
691
+ return path.startsWith("/") ? path : `./${path}`;
692
+ }
693
+ /** The boxed result shown after an interactive install. */
694
+ function installSummary(outcome, canonicalPath) {
695
+ const lines = [];
696
+ const installed = outcome.targets.filter((target) => target.status === "installed");
697
+ if (canonicalPath) {
698
+ lines.push(`✓ ${withDot(canonicalPath)}`);
699
+ const universal = AGENT_TARGETS.filter((agent) => !agent.link).map((agent) => agent.id);
700
+ if (universal.length > 0) lines.push(` universal: ${universal.join(", ")}`);
701
+ } else for (const target of installed) lines.push(`✓ ${withDot(target.path)}`);
702
+ const symlinked = installed.filter((target) => target.mode === "symlink" && target.agent).map((target) => target.agent);
703
+ const copied = installed.filter((target) => target.mode === "copy" && target.agent).map((target) => target.agent);
704
+ if (symlinked.length > 0) lines.push(` symlinked: ${symlinked.join(", ")}`);
705
+ if (copied.length > 0) lines.push(` copied: ${copied.join(", ")}`);
706
+ const failed = outcome.targets.filter((target) => target.status === "failed");
707
+ if (failed.length > 0) lines.push(` failed: ${failed.map((target) => target.path).join(", ")}`);
708
+ return lines.join("\n");
709
+ }
700
710
  async function addOne(ctx, connection, parsed, destName, agents, dir, global, copy, state) {
701
711
  const capability = await resolveSkill(connection, parsed);
702
712
  const version = resolveVersion(capability, parsed);
@@ -708,10 +718,9 @@ async function addOne(ctx, connection, parsed, destName, agents, dir, global, co
708
718
  });
709
719
  const canonicalPath = plan.canonical ? displayPath(plan.canonical, process.cwd()) : null;
710
720
  if (ctx.flags.dryRun) {
711
- if (!ctx.flags.force) {
712
- for (const path of [plan.canonical, ...plan.targets.map((target) => target.path)]) if (path && await pathExists(path)) throw targetExistsError(displayPath(path, process.cwd()));
713
- }
714
- ctx.summary(`resolved ${parsed.namespace}/${parsed.name} to version ${version}`);
721
+ ctx.detail(`dry-run: would install ${parsed.namespace}/${parsed.name}@${version}`);
722
+ if (canonicalPath) ctx.detail(` ${canonicalPath}`);
723
+ for (const target of plan.targets) ctx.detail(` ${displayPath(target.path, process.cwd())} (${target.mode})`);
715
724
  return { result: {
716
725
  name: capability.name,
717
726
  namespace: capability.namespace,
@@ -727,19 +736,18 @@ async function addOne(ctx, connection, parsed, destName, agents, dir, global, co
727
736
  error: null
728
737
  } };
729
738
  }
730
- const download = await downloadSkillPackage(connection, capability.id, version);
731
- const pkg = readSkillPackage(download.bytes);
732
- ctx.summary(`resolved ${parsed.namespace}/${parsed.name} to version ${version}`);
739
+ const pkg = readSkillPackage((await downloadSkillPackage(connection, capability.id, version)).bytes);
733
740
  const declaredName = frontmatterName(pkg.manifest);
734
741
  if (declaredName !== void 0 && declaredName !== destName) ctx.summary(`warning: SKILL.md declares name ${JSON.stringify(declaredName)} but the directory is ${JSON.stringify(destName)}`);
735
742
  const outcome = await install(ctx, plan, pkg.entries);
736
- const universal = agents.filter((agent) => !agent.link).map((agent) => agent.id);
737
- const etag = download.etag ? ` (ETag ${download.etag})` : "";
738
- const verb = outcome.failure ? "partially installed" : "installed";
739
- ctx.progress(`${verb} ${capability.namespace}/${capability.name}@${version}${etag}`);
740
- if (canonicalPath) ctx.progress(` canonical ${canonicalPath}`);
741
- if (universal.length > 0) ctx.progress(` universal: ${universal.join(", ")}`);
742
- for (const target of outcome.targets) ctx.progress(target.status === "failed" ? ` failed ${target.path}` : ` ${target.mode} ${target.path}`);
743
+ const verb = outcome.failure ? "Partially installed" : "Installed";
744
+ const note = ctx.io.prompts?.note;
745
+ if (!ctx.flags.json && !ctx.flags.quiet && ctx.io.stderr.isTTY && note) note(installSummary(outcome, canonicalPath), `${verb} ${capability.namespace}/${capability.name}@${version}`);
746
+ else {
747
+ ctx.progress(`${verb.toLowerCase()} ${capability.namespace}/${capability.name}@${version}`);
748
+ if (canonicalPath) ctx.progress(` ${canonicalPath}`);
749
+ for (const target of outcome.targets) ctx.progress(target.status === "failed" ? ` failed ${target.path}` : ` ${target.path} (${target.mode})`);
750
+ }
743
751
  return { result: {
744
752
  name: capability.name,
745
753
  namespace: capability.namespace,
@@ -849,12 +857,13 @@ async function add(ctx, argv) {
849
857
  const dir = stringFlag(argv, "dir");
850
858
  const as = stringFlag(argv, "as");
851
859
  if (as !== void 0 && (as.includes("/") || as.includes("\\") || as === "." || as === "..")) throw usage(`Invalid --as name ${JSON.stringify(as)}`, "Pass a single directory name.");
852
- const global = argv.global === true;
860
+ const requestedGlobal = argv.global === true;
853
861
  const copy = argv.copy === true;
854
- if (dir !== void 0 && global) throw usage("--dir cannot be combined with --global");
862
+ if (dir !== void 0 && requestedGlobal) throw usage("--dir cannot be combined with --global");
855
863
  if (dir !== void 0 && (stringValues(argv.agent).length > 0 || argv.all === true)) throw usage("--dir cannot be combined with --agent or --all");
856
864
  if (as !== void 0) assertNotReserved(as);
857
865
  for (const parsed of parsedPaths) assertNotReserved(as ?? parsed.name);
866
+ const global = dir === void 0 ? await resolveInstallScope(ctx, requestedGlobal) === "global" : false;
858
867
  const agents = dir === void 0 ? await selectAgents(ctx, argv, rawPaths[0] ?? "", global) : [];
859
868
  const connection = await resolveConnection(ctx.flags, ctx.env);
860
869
  const { results, failed } = await runBatch({
@@ -865,7 +874,8 @@ async function add(ctx, argv) {
865
874
  },
866
875
  failure: failureResult
867
876
  });
868
- ctx.writePayload({ results });
877
+ if (ctx.flags.json || !ctx.io.stdout.isTTY) ctx.writePayload({ results });
878
+ if (!ctx.flags.json && !ctx.flags.dryRun && !ctx.flags.quiet && !failed && ctx.io.stderr.isTTY) ctx.io.prompts?.outro?.("Done! Review skills before use; they run with full agent permissions.");
869
879
  return failed ? 1 : 0;
870
880
  }
871
881
  export { add, publish };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hngr",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Hangar CLI: install skills and MCP servers into coding agents, and drive the Management API.",
5
5
  "keywords": [
6
6
  "hangar",