@peppermint-mcp/wizard 0.3.1 → 0.4.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 (2) hide show
  1. package/dist/cli.js +130 -32
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -197,6 +197,12 @@ function saveCredentials(creds) {
197
197
  mode: 384
198
198
  });
199
199
  }
200
+ function clearCredentials() {
201
+ const path = getCredentialsPath();
202
+ if (existsSync3(path)) {
203
+ writeFileSync(path, "{}", { encoding: "utf-8", mode: 384 });
204
+ }
205
+ }
200
206
 
201
207
  // src/auth/localhost-oauth.ts
202
208
  import { createServer } from "http";
@@ -461,6 +467,9 @@ Would create: ${filePath}`;
461
467
  `Config merge produced invalid JSON: ${errors.map((e) => jsonc3.printParseErrorCode(e.error)).join(", ")}`
462
468
  );
463
469
  }
470
+ if (content === updated) {
471
+ return null;
472
+ }
464
473
  if (dryRun) {
465
474
  return `Would write to ${filePath}:
466
475
  ${updated}`;
@@ -524,6 +533,9 @@ async function installClaudeDesktop(serverUrl, apiKey, dryRun) {
524
533
  serverConfig,
525
534
  dryRun
526
535
  });
536
+ if (result === null) {
537
+ return { success: true, message: "Already up to date", needsRestart: false };
538
+ }
527
539
  return {
528
540
  success: true,
529
541
  message: dryRun ? result : `Wrote config to ${configPath}`,
@@ -572,6 +584,9 @@ async function installCursor(serverUrl, apiKey, dryRun) {
572
584
  serverConfig,
573
585
  dryRun
574
586
  });
587
+ if (result === null) {
588
+ return { success: true, message: "Already up to date", needsRestart: false };
589
+ }
575
590
  return {
576
591
  success: true,
577
592
  message: dryRun ? result : `Wrote config to ${configPath}`,
@@ -736,13 +751,17 @@ function checkHostConfig(hostId, configPath) {
736
751
  }
737
752
 
738
753
  // src/skills/index.ts
754
+ import { createHash as createHash2 } from "crypto";
739
755
  import {
740
756
  copyFileSync as copyFileSync2,
741
757
  existsSync as existsSync7,
742
758
  mkdirSync as mkdirSync4,
759
+ readFileSync as readFileSync7,
743
760
  readdirSync,
761
+ renameSync as renameSync2,
744
762
  rmSync,
745
- statSync
763
+ statSync,
764
+ writeFileSync as writeFileSync4
746
765
  } from "fs";
747
766
  import { homedir as homedir7 } from "os";
748
767
  import { dirname as dirname3, join as join7, resolve } from "path";
@@ -753,12 +772,26 @@ var LEGACY_SKILLS = [
753
772
  "peppermint-capture",
754
773
  "peppermint-ask-twin"
755
774
  ];
775
+ var HASH_FILE = ".wizard-hash";
756
776
  function getSkillsBundlePath() {
757
777
  return resolve(__dirname, "..", "skills-bundle", "peppermint");
758
778
  }
759
779
  function getTargetPath() {
760
780
  return join7(homedir7(), ".claude", "skills", "peppermint");
761
781
  }
782
+ function hashDir(dir) {
783
+ const hash = createHash2("sha256");
784
+ const entries = readdirSync(dir).sort();
785
+ for (const entry of entries) {
786
+ const fullPath = join7(dir, entry);
787
+ if (statSync(fullPath).isDirectory()) {
788
+ hash.update(hashDir(fullPath));
789
+ } else {
790
+ hash.update(readFileSync7(fullPath));
791
+ }
792
+ }
793
+ return hash.digest("hex");
794
+ }
762
795
  function copyDirRecursive(src, dest) {
763
796
  mkdirSync4(dest, { recursive: true });
764
797
  for (const entry of readdirSync(src)) {
@@ -788,27 +821,41 @@ function removeLegacySkills(dryRun) {
788
821
  function installSkills(dryRun) {
789
822
  const bundlePath = getSkillsBundlePath();
790
823
  const targetPath = getTargetPath();
824
+ const hashFile = join7(targetPath, HASH_FILE);
791
825
  const alreadyExists = existsSync7(join7(targetPath, "SKILL.md"));
792
826
  if (!existsSync7(bundlePath)) {
793
- return { installed: false, updated: false, targetPath, error: "Skills bundle not found in package" };
827
+ return { installed: false, updated: false, skipped: false, backedUp: false, targetPath, error: "Skills bundle not found in package" };
828
+ }
829
+ const bundleHash = hashDir(bundlePath);
830
+ if (alreadyExists && existsSync7(hashFile)) {
831
+ const installedHash = readFileSync7(hashFile, "utf-8").trim();
832
+ if (installedHash === bundleHash) {
833
+ return { installed: true, updated: false, skipped: true, backedUp: false, targetPath };
834
+ }
794
835
  }
795
836
  if (dryRun) {
796
- return { installed: true, updated: alreadyExists, targetPath };
837
+ return { installed: true, updated: alreadyExists, skipped: false, backedUp: false, targetPath };
838
+ }
839
+ let backedUp = false;
840
+ if (alreadyExists && !existsSync7(hashFile)) {
841
+ const backupPath = `${targetPath}.bak.${Date.now()}`;
842
+ renameSync2(targetPath, backupPath);
843
+ backedUp = true;
844
+ } else if (alreadyExists) {
845
+ rmSync(targetPath, { recursive: true, force: true });
797
846
  }
798
847
  try {
799
- if (existsSync7(targetPath)) {
800
- rmSync(targetPath, { recursive: true, force: true });
801
- }
802
848
  copyDirRecursive(bundlePath, targetPath);
803
- return { installed: true, updated: alreadyExists, targetPath };
849
+ writeFileSync4(join7(targetPath, HASH_FILE), bundleHash, "utf-8");
850
+ return { installed: true, updated: alreadyExists, skipped: false, backedUp, targetPath };
804
851
  } catch (err) {
805
852
  const message = err instanceof Error ? err.message : "Failed to install skills";
806
- return { installed: false, updated: false, targetPath, error: message };
853
+ return { installed: false, updated: false, skipped: false, backedUp: false, targetPath, error: message };
807
854
  }
808
855
  }
809
856
 
810
857
  // src/skills/permissions.ts
811
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
858
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
812
859
  import { homedir as homedir8 } from "os";
813
860
  import { dirname as dirname4, join as join8 } from "path";
814
861
  import * as jsonc5 from "jsonc-parser";
@@ -830,7 +877,7 @@ function installPermissions(dryRun) {
830
877
  try {
831
878
  let content = "";
832
879
  if (existsSync8(settingsPath)) {
833
- content = readFileSync7(settingsPath, "utf-8");
880
+ content = readFileSync8(settingsPath, "utf-8");
834
881
  }
835
882
  const parsed = content ? jsonc5.parse(content) : {};
836
883
  const existingAllow = parsed?.permissions?.allow || [];
@@ -850,7 +897,7 @@ function installPermissions(dryRun) {
850
897
  if (!existsSync8(dir)) {
851
898
  mkdirSync5(dir, { recursive: true });
852
899
  }
853
- writeFileSync4(settingsPath, updated, "utf-8");
900
+ writeFileSync5(settingsPath, updated, "utf-8");
854
901
  return { added: toAdd };
855
902
  } catch (err) {
856
903
  const message = err instanceof Error ? err.message : "Failed to update permissions";
@@ -1009,9 +1056,12 @@ async function addCommand(options) {
1009
1056
  p.log.info(` ${pc.green("\u2713")} Removed legacy skills: ${pc.dim(removed.join(", "))}`);
1010
1057
  }
1011
1058
  const skillResult = installSkills(options.dryRun);
1012
- if (skillResult.installed) {
1059
+ if (skillResult.skipped) {
1060
+ p.log.info(` ${pc.green("\u2713")} Peppermint skill ${pc.dim("up to date")}`);
1061
+ } else if (skillResult.installed) {
1013
1062
  const verb = skillResult.updated ? "Updated" : "Installed";
1014
- p.log.info(` ${pc.green("\u2713")} ${verb} Peppermint skill ${pc.dim(skillResult.targetPath)}`);
1063
+ const backup = skillResult.backedUp ? pc.dim(" (previous version backed up)") : "";
1064
+ p.log.info(` ${pc.green("\u2713")} ${verb} Peppermint skill${backup} ${pc.dim(skillResult.targetPath)}`);
1015
1065
  } else if (skillResult.error) {
1016
1066
  p.log.info(` ${pc.red("\u2717")} Skill install failed: ${pc.dim(skillResult.error)}`);
1017
1067
  }
@@ -1073,34 +1123,82 @@ async function doctorCommand(options) {
1073
1123
  p.outro("Health check complete");
1074
1124
  }
1075
1125
  async function removeCommand(options) {
1076
- p.intro(pc.green("\u{1F33F} Peppermint MCP Wizard \u2014 Remove"));
1126
+ const nonInteractive = options.yes || !process.stdin.isTTY;
1127
+ if (!process.stdin.isTTY && !nonInteractive) {
1128
+ console.error("Error: peppermint-mcp-wizard requires an interactive terminal.");
1129
+ console.error("For non-interactive removes, use: --yes");
1130
+ process.exit(1);
1131
+ }
1132
+ if (!nonInteractive) {
1133
+ p.intro(pc.green("\u{1F33F} Peppermint MCP Wizard \u2014 Remove"));
1134
+ }
1077
1135
  const hosts = await detectHosts();
1078
1136
  const installed = hosts.filter((h) => h.alreadyInstalled);
1079
1137
  if (installed.length === 0) {
1080
- p.log.info("Peppermint is not installed in any detected hosts.");
1138
+ const msg = "Peppermint is not installed in any detected hosts.";
1139
+ nonInteractive ? console.log(msg) : p.log.info(msg);
1081
1140
  process.exit(0);
1082
1141
  }
1083
- const selected = await p.multiselect({
1084
- message: "Remove Peppermint MCP from which hosts?",
1085
- options: installed.map((h) => ({
1086
- value: h.id,
1087
- label: h.name
1088
- })),
1089
- initialValues: installed.map((h) => h.id)
1090
- });
1091
- if (p.isCancel(selected)) {
1092
- p.cancel("Cancelled.");
1093
- process.exit(0);
1142
+ let selectedHosts;
1143
+ if (nonInteractive) {
1144
+ selectedHosts = installed;
1145
+ } else {
1146
+ const selected = await p.multiselect({
1147
+ message: "Remove Peppermint MCP from which hosts?",
1148
+ options: installed.map((h) => ({
1149
+ value: h.id,
1150
+ label: h.name
1151
+ })),
1152
+ initialValues: installed.map((h) => h.id)
1153
+ });
1154
+ if (p.isCancel(selected)) {
1155
+ p.cancel("Cancelled.");
1156
+ process.exit(0);
1157
+ }
1158
+ selectedHosts = hosts.filter(
1159
+ (h) => selected.includes(h.id)
1160
+ );
1094
1161
  }
1095
- const selectedHosts = hosts.filter(
1096
- (h) => selected.includes(h.id)
1097
- );
1098
1162
  for (const host of selectedHosts) {
1099
1163
  const result = await removeHost(host, options.dryRun);
1100
1164
  const icon = result.success ? pc.green("\u2713") : pc.red("\u2717");
1101
- p.log.info(`${icon} ${host.name} ${pc.dim(result.message)}`);
1165
+ nonInteractive ? console.log(`${result.success ? "\u2713" : "\u2717"} ${host.name} ${result.message}`) : p.log.info(`${icon} ${host.name} ${pc.dim(result.message)}`);
1166
+ }
1167
+ if (options.revokeToken) {
1168
+ const base = serverBase(options.server);
1169
+ const creds = loadCredentials(base);
1170
+ if (creds && !options.dryRun) {
1171
+ try {
1172
+ const res = await fetch(`${base}/auth/api-keys`, {
1173
+ headers: { Authorization: `Bearer ${creds.api_key}` }
1174
+ });
1175
+ if (res.ok) {
1176
+ const keys = await res.json();
1177
+ for (const key of keys) {
1178
+ if (key.name === "mcp-wizard") {
1179
+ await fetch(`${base}/auth/api-keys/${key.id}`, {
1180
+ method: "DELETE",
1181
+ headers: { Authorization: `Bearer ${creds.api_key}` }
1182
+ });
1183
+ }
1184
+ }
1185
+ }
1186
+ clearCredentials();
1187
+ const msg = "Revoked API key and cleared credentials";
1188
+ nonInteractive ? console.log(`\u2713 ${msg}`) : p.log.info(`${pc.green("\u2713")} ${msg}`);
1189
+ } catch {
1190
+ const msg = "Failed to revoke API key (credentials cleared locally)";
1191
+ clearCredentials();
1192
+ nonInteractive ? console.log(`\u26A0 ${msg}`) : p.log.info(`${pc.yellow("\u26A0")} ${msg}`);
1193
+ }
1194
+ } else if (creds && options.dryRun) {
1195
+ const msg = "Would revoke API key and clear credentials";
1196
+ nonInteractive ? console.log(msg) : p.log.info(msg);
1197
+ }
1198
+ }
1199
+ if (!nonInteractive) {
1200
+ p.outro("Removal complete");
1102
1201
  }
1103
- p.outro("Removal complete");
1104
1202
  }
1105
1203
  var program = new Command().name("peppermint-mcp-wizard").description("One-command installer for Peppermint MCP").version("0.1.0");
1106
1204
  program.command("add", { isDefault: true }).description("Detect hosts, authenticate, install MCP config").option("--server <url>", "MCP server URL", DEFAULT_SERVER).option("--dry-run", "Print changes without writing", false).option("--no-verify", "Skip post-install verification").option("--host <id...>", "Install to specific hosts (claude-code, claude-desktop, cursor, codex)").option("--yes", "Skip all prompts (non-interactive)", false).option("--auth-token <token>", "API key or token for auth (skips browser OAuth)").action((opts) => addCommand({
@@ -1113,5 +1211,5 @@ program.command("add", { isDefault: true }).description("Detect hosts, authentic
1113
1211
  }));
1114
1212
  program.command("list").description("List detected AI hosts and their Peppermint status").option("--server <url>", "MCP server URL", DEFAULT_SERVER).action((opts) => listCommand({ server: opts.server }));
1115
1213
  program.command("doctor").description("Run health checks on existing installation").option("--server <url>", "MCP server URL", DEFAULT_SERVER).action((opts) => doctorCommand({ server: opts.server }));
1116
- program.command("remove").description("Remove Peppermint MCP from selected hosts").option("--server <url>", "MCP server URL", DEFAULT_SERVER).option("--dry-run", "Print changes without writing", false).action((opts) => removeCommand({ server: opts.server, dryRun: opts.dryRun }));
1214
+ program.command("remove").description("Remove Peppermint MCP from selected hosts").option("--server <url>", "MCP server URL", DEFAULT_SERVER).option("--dry-run", "Print changes without writing", false).option("--yes", "Skip all prompts (non-interactive)", false).option("--revoke-token", "Revoke API key on the server", true).option("--no-revoke-token", "Keep API key for future re-installs").action((opts) => removeCommand({ server: opts.server, dryRun: opts.dryRun, yes: opts.yes, revokeToken: opts.revokeToken }));
1117
1215
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peppermint-mcp/wizard",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "One-command installer for Peppermint MCP across AI coding hosts",
5
5
  "type": "module",
6
6
  "bin": {