quiver-cli 1.1.0 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +9 -3
  2. package/dist/cli.js +126 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -160,9 +160,15 @@ This is the basis for `sync` and `check`.
160
160
  poisoning), shown as a readable before/after.
161
161
 
162
162
  The first successful introspection records a baseline; subsequent `check` runs
163
- diff against it. Servers that fail introspection (e.g. requiring interactive
164
- OAuth) are reported as skipped. stdio servers run foreign code and are only
165
- introspected with `--introspect-stdio`.
163
+ diff against it. stdio servers run foreign code and are only introspected with
164
+ `--introspect-stdio`.
165
+
166
+ **OAuth-protected servers** (e.g. Linear): `check` reuses opencode's MCP
167
+ credentials (`~/.local/share/opencode/mcp-auth.json`, read-only — quiver never
168
+ refreshes or rewrites them). Authenticate once with
169
+ `opencode mcp auth <name>`, then re-run `quiver-cli check` to record the tool
170
+ snapshot. Without a valid token the server is skipped with an actionable hint,
171
+ and `quiver-cli list` shows why the tool count is missing.
166
172
 
167
173
  Pass `--offline` to skip MCP re-introspection entirely and check only digests
168
174
  and provider shims — no network, no foreign code, useful for a fast local
package/dist/cli.js CHANGED
@@ -2569,7 +2569,8 @@ var init_list = __esm({
2569
2569
  transport: entry.transport,
2570
2570
  enabled: !disabled.has(name),
2571
2571
  detail: serverDetail2.get(name) ?? null,
2572
- toolCount: entry.tools ? Object.keys(entry.tools).length : null
2572
+ toolCount: entry.tools ? Object.keys(entry.tools).length : null,
2573
+ authRequired: entry.authRequired ?? false
2573
2574
  })),
2574
2575
  plugins: plugins.map(({ name, entry }) => ({
2575
2576
  name,
@@ -2609,6 +2610,7 @@ var init_list = __esm({
2609
2610
  }
2610
2611
  }
2611
2612
  let missingTools = false;
2613
+ const needsAuth = [];
2612
2614
  if (mcp.length) {
2613
2615
  const nameW = Math.max(...mcp.map((e) => e.name.length));
2614
2616
  const toolW = Math.max(
@@ -2620,7 +2622,10 @@ var init_list = __esm({
2620
2622
  lines.push("", ` ${c.bold("mcp servers")}`);
2621
2623
  for (const { name, entry } of mcp) {
2622
2624
  const count = entry.tools ? Object.keys(entry.tools).length : null;
2623
- if (count === null) missingTools = true;
2625
+ if (count === null) {
2626
+ if (entry.authRequired) needsAuth.push(name);
2627
+ else missingTools = true;
2628
+ }
2624
2629
  const tools = padCell(
2625
2630
  `${count ?? "?"} tools`,
2626
2631
  toolW,
@@ -2647,6 +2652,13 @@ var init_list = __esm({
2647
2652
  `${skills.length} skills \xB7 ${commands.length} commands \xB7 ${mcp.length} MCP servers \xB7 ${plugins.length} plugins`
2648
2653
  )} ${c.dim(`providers: ${providers2}`)}`
2649
2654
  );
2655
+ for (const name of needsAuth) {
2656
+ lines.push(
2657
+ ` ${c.yellow(`${name} requires OAuth`)} ${c.dim(
2658
+ `\u2014 run 'opencode mcp auth ${name}', then 'quiver-cli check'`
2659
+ )}`
2660
+ );
2661
+ }
2650
2662
  if (missingTools) {
2651
2663
  lines.push(` ${c.dim("run 'quiver-cli check' to populate tool counts")}`);
2652
2664
  }
@@ -2698,7 +2710,7 @@ var init_diff = __esm({
2698
2710
  });
2699
2711
 
2700
2712
  // src/mcp/introspect.ts
2701
- var CONNECT_TIMEOUT_MS, withTimeout, introspect, errMsg;
2713
+ var CONNECT_TIMEOUT_MS, withTimeout, introspect, errMsg, isAuthError;
2702
2714
  var init_introspect = __esm({
2703
2715
  "src/mcp/introspect.ts"() {
2704
2716
  "use strict";
@@ -2714,13 +2726,20 @@ var init_introspect = __esm({
2714
2726
  clearTimeout(timer);
2715
2727
  }
2716
2728
  };
2717
- introspect = async (server, { allowStdio }) => {
2729
+ introspect = async (server, { allowStdio, authToken }) => {
2718
2730
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
2719
2731
  let transport;
2720
2732
  try {
2721
2733
  if (server.transport === "http") {
2722
2734
  const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
2723
- const requestInit = server.headers ? { headers: server.headers } : {};
2735
+ const headers2 = { ...server.headers ?? {} };
2736
+ const hasAuthHeader = Object.keys(headers2).some(
2737
+ (k) => k.toLowerCase() === "authorization"
2738
+ );
2739
+ if (authToken && !hasAuthHeader) {
2740
+ headers2["Authorization"] = `Bearer ${authToken}`;
2741
+ }
2742
+ const requestInit = Object.keys(headers2).length ? { headers: headers2 } : {};
2724
2743
  transport = new StreamableHTTPClientTransport(new URL(server.url), {
2725
2744
  requestInit
2726
2745
  });
@@ -2755,6 +2774,9 @@ var init_introspect = __esm({
2755
2774
  }));
2756
2775
  return { ok: true, tools };
2757
2776
  } catch (e) {
2777
+ if (await isAuthError(e)) {
2778
+ return { ok: false, reason: errMsg(e), authRequired: true };
2779
+ }
2758
2780
  return { ok: false, reason: errMsg(e) };
2759
2781
  } finally {
2760
2782
  try {
@@ -2764,6 +2786,55 @@ var init_introspect = __esm({
2764
2786
  }
2765
2787
  };
2766
2788
  errMsg = (e) => e instanceof Error ? e.message : String(e);
2789
+ isAuthError = async (e) => {
2790
+ try {
2791
+ const { UnauthorizedError } = await import("@modelcontextprotocol/sdk/client/auth.js");
2792
+ if (e instanceof UnauthorizedError) return true;
2793
+ } catch {
2794
+ }
2795
+ if (typeof e === "object" && e !== null && e.code === 401) {
2796
+ return true;
2797
+ }
2798
+ return /\b401\b|unauthorized|invalid_token/i.test(errMsg(e));
2799
+ };
2800
+ }
2801
+ });
2802
+
2803
+ // src/mcp/opencode-auth.ts
2804
+ import { readFileSync as readFileSync10 } from "fs";
2805
+ import { homedir as homedir2 } from "os";
2806
+ import { resolve as resolve18 } from "path";
2807
+ var EXPIRY_SKEW_MS, authFilePath, normalizeUrl, findOpencodeToken;
2808
+ var init_opencode_auth = __esm({
2809
+ "src/mcp/opencode-auth.ts"() {
2810
+ "use strict";
2811
+ EXPIRY_SKEW_MS = 3e4;
2812
+ authFilePath = () => {
2813
+ const base = process.env["XDG_DATA_HOME"] || resolve18(homedir2(), ".local", "share");
2814
+ return resolve18(base, "opencode", "mcp-auth.json");
2815
+ };
2816
+ normalizeUrl = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
2817
+ findOpencodeToken = (name, url) => {
2818
+ let data;
2819
+ try {
2820
+ data = JSON.parse(readFileSync10(authFilePath(), "utf8"));
2821
+ } catch {
2822
+ return { status: "none" };
2823
+ }
2824
+ if (typeof data !== "object" || data === null) return { status: "none" };
2825
+ const entries = data;
2826
+ const target = normalizeUrl(url);
2827
+ const entry = Object.values(entries).find(
2828
+ (e) => e?.serverUrl && normalizeUrl(e.serverUrl) === target
2829
+ ) ?? entries[name];
2830
+ const tokens = entry?.tokens;
2831
+ if (!tokens?.accessToken) return { status: "none" };
2832
+ if (typeof tokens.expiresAt === "number") {
2833
+ const expiresMs = tokens.expiresAt > 1e12 ? tokens.expiresAt : tokens.expiresAt * 1e3;
2834
+ if (expiresMs - EXPIRY_SKEW_MS <= Date.now()) return { status: "expired" };
2835
+ }
2836
+ return { status: "ok", accessToken: tokens.accessToken };
2837
+ };
2767
2838
  }
2768
2839
  });
2769
2840
 
@@ -2789,13 +2860,14 @@ var init_snapshot = __esm({
2789
2860
  // src/commands/check.ts
2790
2861
  var check_exports = {};
2791
2862
  __export(check_exports, {
2863
+ authHint: () => authHint,
2792
2864
  check: () => check,
2793
2865
  hasCommand: () => hasCommand,
2794
2866
  summarize: () => summarize
2795
2867
  });
2796
2868
  import { accessSync as accessSync2, constants as constants2 } from "fs";
2797
- import { delimiter, resolve as resolve18 } from "path";
2798
- var check, report2, driftLines, list2, recommend, summarize, hasCommand, truncate2, fail;
2869
+ import { delimiter, resolve as resolve19 } from "path";
2870
+ var check, report2, driftLines, list2, recommend, summarize, authHint, hasCommand, truncate2, fail;
2799
2871
  var init_check = __esm({
2800
2872
  "src/commands/check.ts"() {
2801
2873
  "use strict";
@@ -2804,6 +2876,7 @@ var init_check = __esm({
2804
2876
  init_schema();
2805
2877
  init_diff();
2806
2878
  init_introspect();
2879
+ init_opencode_auth();
2807
2880
  init_snapshot();
2808
2881
  init_local_config();
2809
2882
  init_write();
@@ -2867,13 +2940,27 @@ var init_check = __esm({
2867
2940
  }
2868
2941
  checked.mcp += 1;
2869
2942
  const server = interpolateEnvVars(catMcp.server);
2870
- const res = await introspect(server, { allowStdio: options.introspectStdio });
2943
+ const mcpEntry = entry;
2944
+ const cred = server.transport === "http" ? findOpencodeToken(p.name, server.url) : { status: "none" };
2945
+ const res = await introspect(server, {
2946
+ allowStdio: options.introspectStdio,
2947
+ authToken: cred.status === "ok" ? cred.accessToken : void 0
2948
+ });
2871
2949
  if (!res.ok) {
2872
- mcpReports.push({ id, status: "skipped", reason: res.reason });
2950
+ if (res.authRequired && !mcpEntry.authRequired) {
2951
+ mcpEntry.authRequired = true;
2952
+ lockChanged = true;
2953
+ }
2954
+ const reason = res.authRequired ? authHint(cred.status, p.name) : res.reason;
2955
+ mcpReports.push({
2956
+ id,
2957
+ status: "skipped",
2958
+ reason,
2959
+ ...res.authRequired ? { authRequired: true } : {}
2960
+ });
2873
2961
  continue;
2874
2962
  }
2875
2963
  const current = toSnapshot(res.tools);
2876
- const mcpEntry = entry;
2877
2964
  if (!mcpEntry.tools) {
2878
2965
  mcpEntry.tools = current;
2879
2966
  mcpEntry.toolsFetchedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2942,7 +3029,15 @@ var init_check = __esm({
2942
3029
  - ${shimProblems.join("\n - ")}`
2943
3030
  );
2944
3031
  }
2945
- const skipped = mcpReports.filter((r) => r.status === "skipped");
3032
+ const authSkipped = mcpReports.filter(
3033
+ (r) => r.status === "skipped" && r.authRequired
3034
+ );
3035
+ for (const r of authSkipped) {
3036
+ await warn(`${r.id}: ${r.reason}`);
3037
+ }
3038
+ const skipped = mcpReports.filter(
3039
+ (r) => r.status === "skipped" && !r.authRequired
3040
+ );
2946
3041
  if (skipped.length) {
2947
3042
  const names = skipped.map((r) => parseEntryId(r.id)?.name ?? r.id);
2948
3043
  await info(
@@ -3036,13 +3131,19 @@ var init_check = __esm({
3036
3131
  if (c.plugins) parts.push(plural(c.plugins, "plugin"));
3037
3132
  return parts.length ? parts.join(", ") : "nothing";
3038
3133
  };
3134
+ authHint = (cred, name) => {
3135
+ const reauth = `run 'opencode mcp auth ${name}', then 'quiver-cli check'`;
3136
+ if (cred === "expired") return `OAuth token expired \u2014 re-${reauth}`;
3137
+ if (cred === "ok") return `OAuth token rejected \u2014 re-${reauth}`;
3138
+ return `requires OAuth \u2014 ${reauth}`;
3139
+ };
3039
3140
  hasCommand = (command) => {
3040
3141
  if (!/^[A-Za-z0-9._-]+$/.test(command)) return false;
3041
3142
  const extensions = process.platform === "win32" ? (process.env["PATHEXT"] ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
3042
3143
  for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
3043
3144
  for (const extension of extensions) {
3044
3145
  try {
3045
- accessSync2(resolve18(dir, command + extension), constants2.X_OK);
3146
+ accessSync2(resolve19(dir, command + extension), constants2.X_OK);
3046
3147
  return true;
3047
3148
  } catch {
3048
3149
  }
@@ -3064,12 +3165,12 @@ import { execFileSync as execFileSync3 } from "child_process";
3064
3165
  import {
3065
3166
  existsSync as existsSync14,
3066
3167
  mkdtempSync as mkdtempSync2,
3067
- readFileSync as readFileSync10,
3168
+ readFileSync as readFileSync11,
3068
3169
  rmSync as rmSync7,
3069
3170
  writeFileSync as writeFileSync8
3070
3171
  } from "fs";
3071
3172
  import { tmpdir } from "os";
3072
- import { join, resolve as resolve19 } from "path";
3173
+ import { join, resolve as resolve20 } from "path";
3073
3174
  var UPSTREAMS_FILE, upstreamsPath, loadUpstreams, writeUpstreams, fetchLatestCommit, fetchUpstreamDir, short, evaluateOrigin;
3074
3175
  var init_upstreams = __esm({
3075
3176
  "src/catalog/upstreams.ts"() {
@@ -3077,11 +3178,11 @@ var init_upstreams = __esm({
3077
3178
  init_auth();
3078
3179
  init_auth();
3079
3180
  UPSTREAMS_FILE = "upstreams.json";
3080
- upstreamsPath = (catalog) => resolve19(catalog.root, UPSTREAMS_FILE);
3181
+ upstreamsPath = (catalog) => resolve20(catalog.root, UPSTREAMS_FILE);
3081
3182
  loadUpstreams = (catalog) => {
3082
3183
  const path = upstreamsPath(catalog);
3083
3184
  if (!existsSync14(path)) return {};
3084
- return JSON.parse(readFileSync10(path, "utf8"));
3185
+ return JSON.parse(readFileSync11(path, "utf8"));
3085
3186
  };
3086
3187
  writeUpstreams = (catalog, map) => {
3087
3188
  writeFileSync8(upstreamsPath(catalog), JSON.stringify(map, null, 2) + "\n");
@@ -3149,8 +3250,8 @@ var init_upstreams = __esm({
3149
3250
  const msg = err instanceof Error && "stderr" in err ? String(err.stderr).trim().split("\n").pop() : err instanceof Error ? err.message : "git clone failed";
3150
3251
  return { ok: false, reason: msg || "git clone failed" };
3151
3252
  }
3152
- const dir = resolve19(tmp, origin.path);
3153
- if (!existsSync14(resolve19(dir, "SKILL.md"))) {
3253
+ const dir = resolve20(tmp, origin.path);
3254
+ if (!existsSync14(resolve20(dir, "SKILL.md"))) {
3154
3255
  cleanup();
3155
3256
  return { ok: false, reason: `no SKILL.md at ${origin.path} in ${origin.repo}` };
3156
3257
  }
@@ -3443,9 +3544,9 @@ __export(notifier_exports, {
3443
3544
  installHint: () => installHint,
3444
3545
  notifierSuppressed: () => notifierSuppressed
3445
3546
  });
3446
- import { existsSync as existsSync15, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
3447
- import { homedir as homedir2 } from "os";
3448
- import { dirname as dirname6, resolve as resolve20 } from "path";
3547
+ import { existsSync as existsSync15, mkdirSync as mkdirSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
3548
+ import { homedir as homedir3 } from "os";
3549
+ import { dirname as dirname6, resolve as resolve21 } from "path";
3449
3550
  var REGISTRY_URL, CHECK_TTL_MS, FETCH_TIMEOUT_MS, INSTALL_HINT, cacheFilePath, installHint, getCurrentVersion, compareSemver, readCache, writeCache, fetchLatestVersion, checkForUpdate, notifierSuppressed;
3450
3551
  var init_notifier = __esm({
3451
3552
  "src/version/notifier.ts"() {
@@ -3456,14 +3557,14 @@ var init_notifier = __esm({
3456
3557
  FETCH_TIMEOUT_MS = 2e3;
3457
3558
  INSTALL_HINT = "pnpm add -g quiver-cli";
3458
3559
  cacheFilePath = () => {
3459
- const base = process.env["XDG_CACHE_HOME"] || resolve20(homedir2(), ".cache");
3460
- return resolve20(base, "quiver", "update-check.json");
3560
+ const base = process.env["XDG_CACHE_HOME"] || resolve21(homedir3(), ".cache");
3561
+ return resolve21(base, "quiver", "update-check.json");
3461
3562
  };
3462
3563
  installHint = () => INSTALL_HINT;
3463
3564
  getCurrentVersion = () => {
3464
3565
  try {
3465
3566
  const pkg = JSON.parse(
3466
- readFileSync11(resolve20(packageRoot, "package.json"), "utf8")
3567
+ readFileSync12(resolve21(packageRoot, "package.json"), "utf8")
3467
3568
  );
3468
3569
  return pkg.version;
3469
3570
  } catch {
@@ -3491,7 +3592,7 @@ var init_notifier = __esm({
3491
3592
  const path = cacheFilePath();
3492
3593
  if (!existsSync15(path)) return null;
3493
3594
  try {
3494
- return JSON.parse(readFileSync11(path, "utf8"));
3595
+ return JSON.parse(readFileSync12(path, "utf8"));
3495
3596
  } catch {
3496
3597
  return null;
3497
3598
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quiver-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Compose selected skills, commands, plugins and MCP servers from a central catalog into any repo as native configs for opencode, Claude Code and Codex - with lockfile-based drift awareness.",
5
5
  "type": "module",
6
6
  "bin": {