huhaa-myskills 0.1.5

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +384 -0
  3. package/docs/GUIDE.md +190 -0
  4. package/docs/PLAN.md +359 -0
  5. package/docs/RULES.md +329 -0
  6. package/docs/RUNBOOK-myskills.md +258 -0
  7. package/docs/SYNC-SKILLS.md +259 -0
  8. package/docs/releases/README.md +47 -0
  9. package/docs/releases/v0.1.0.md +256 -0
  10. package/docs/releases/v0.1.1.md +297 -0
  11. package/docs/releases/v0.1.2.md +207 -0
  12. package/docs/releases/v0.1.3.md +269 -0
  13. package/docs/todo/v0.1.2.md +98 -0
  14. package/docs/todo/v0.1.3.md +40 -0
  15. package/docs/todo/v0.1.4.md +36 -0
  16. package/docs/todo/v0.1.5.md +41 -0
  17. package/docs/todo/v0.1.6.md +43 -0
  18. package/docs/todo/v0.1.7.md +38 -0
  19. package/docs/todo/v0.1.8.md +36 -0
  20. package/docs/todo/v0.1.9.md +54 -0
  21. package/install-and-sync.sh +209 -0
  22. package/package.json +68 -0
  23. package/service/bin/huhaa-myskills.mjs +314 -0
  24. package/service/bin/lib/paths.mjs +57 -0
  25. package/service/bin/lib/port.mjs +23 -0
  26. package/service/config/sources.example.yaml +121 -0
  27. package/service/packages/scanner/src/adapters/file-docs.mjs +158 -0
  28. package/service/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
  29. package/service/packages/scanner/src/adapters/markdown-skill.mjs +219 -0
  30. package/service/packages/scanner/src/adapters/mcp-config.mjs +171 -0
  31. package/service/packages/scanner/src/index.mjs +234 -0
  32. package/service/packages/scanner/src/types.d.ts +88 -0
  33. package/service/packages/scanner/src/utils.mjs +186 -0
  34. package/service/packages/server/src/index.mjs +549 -0
  35. package/service/packages/web/README.md +5 -0
  36. package/service/packages/web/dist/assets/index-CTh5OdBd.js +36 -0
  37. package/service/packages/web/dist/assets/index-CtoiGnQ7.css +1 -0
  38. package/service/packages/web/dist/index.html +13 -0
  39. package/service/scripts/build-web.mjs +29 -0
  40. package/service/scripts/prepare-publish.mjs +44 -0
  41. package/service/scripts/restore-publish.mjs +17 -0
  42. package/service/scripts/sync-skills.sh +427 -0
  43. package/service/scripts/verify.mjs +132 -0
@@ -0,0 +1,149 @@
1
+ // Hermes plugin adapter — catalogs local plugin manifests and READMEs.
2
+ // It never imports or executes plugin code.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import fg from 'fast-glob';
7
+ import YAML from 'yaml';
8
+ import {
9
+ classifyRoot,
10
+ deriveDescription,
11
+ expandRoots,
12
+ inferBrand,
13
+ inferProduct,
14
+ makePreview,
15
+ readFileSafe,
16
+ sha1Id,
17
+ } from '../utils.mjs';
18
+
19
+ const MANIFEST_NAMES = ['plugin.yaml', 'plugin.yml', 'plugin.json', 'manifest.json', 'package.json'];
20
+ const README_NAMES = ['README.md', 'readme.md'];
21
+
22
+ export async function scanHermesPlugins(opts) {
23
+ const {
24
+ source = 'hermes-plugin',
25
+ editor = 'Hermes Agent',
26
+ roots = [],
27
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
28
+ } = opts;
29
+
30
+ const expanded = await expandRoots(roots || []);
31
+ const pluginDirs = new Set();
32
+ for (const root of expanded) {
33
+ if (!fs.existsSync(root)) continue;
34
+ const found = await fg(['*/plugin.{yaml,yml,json}', '*/manifest.json', '*/package.json', '*/README.md', '*/readme.md'], {
35
+ cwd: root,
36
+ absolute: true,
37
+ onlyFiles: true,
38
+ dot: true,
39
+ followSymbolicLinks: false,
40
+ deep: 2,
41
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
42
+ });
43
+ for (const f of found) pluginDirs.add(path.dirname(f));
44
+ }
45
+
46
+ const items = [];
47
+ for (const dir of pluginDirs) {
48
+ items.push(parsePluginDir({ dir, source, editor, limits }));
49
+ if (items.length >= limits.maxFiles) break;
50
+ }
51
+ return { items, stats: { source, available: true, files: items.length, roots: expanded } };
52
+ }
53
+
54
+ function parsePluginDir({ dir, source, editor, limits }) {
55
+ const manifestPath = firstExisting(dir, MANIFEST_NAMES);
56
+ const readmePath = firstExisting(dir, README_NAMES);
57
+ const rawParts = [];
58
+ let manifest = {};
59
+ let parseError;
60
+ let mtime = new Date(0);
61
+
62
+ if (manifestPath) {
63
+ const r = readFileSafe(manifestPath, limits.maxFileBytes);
64
+ mtime = r.mtime || mtime;
65
+ if (!r.error && !r.truncated) {
66
+ rawParts.push(`# ${path.basename(manifestPath)}\n\n${r.text}`);
67
+ const parsed = parseManifest(manifestPath, r.text);
68
+ if (parsed.ok) manifest = parsed.value;
69
+ else parseError = parsed.error;
70
+ } else {
71
+ parseError = r.error || 'manifest too large';
72
+ }
73
+ }
74
+
75
+ let readmeBody = '';
76
+ if (readmePath) {
77
+ const r = readFileSafe(readmePath, limits.maxFileBytes);
78
+ if (r.mtime > mtime) mtime = r.mtime;
79
+ if (!r.error && !r.truncated) {
80
+ readmeBody = r.text;
81
+ rawParts.push(`# ${path.basename(readmePath)}\n\n${r.text}`);
82
+ }
83
+ }
84
+
85
+ const dirName = path.basename(dir);
86
+ const name = String(manifest.name || manifest.title || dirName).trim();
87
+ const description = typeof manifest.description === 'string'
88
+ ? manifest.description.trim()
89
+ : deriveDescription({}, readmeBody || rawParts.join('\n\n'));
90
+ const raw = rawParts.join('\n\n---\n\n');
91
+ const item = {
92
+ id: sha1Id(dir),
93
+ kind: 'plugin',
94
+ source,
95
+ editor,
96
+ name,
97
+ title: manifest.title && manifest.title !== name ? manifest.title : undefined,
98
+ description,
99
+ category: 'plugin',
100
+ product: inferProduct({ name, category: 'plugin' }),
101
+ tags: collectTags(manifest),
102
+ paths: { abs: dir, rel: dirName, rootKind: classifyRoot(dir) },
103
+ preview: makePreview(raw || description || '', 600),
104
+ raw,
105
+ links: collectLinks(manifest),
106
+ updatedAt: new Date(mtime).toISOString(),
107
+ };
108
+ item.brand = inferBrand({ name: item.name, description: item.description, category: item.category, raw });
109
+ if (!item.tags?.length) delete item.tags;
110
+ if (!item.links?.length) delete item.links;
111
+ if (parseError) item.parseError = parseError;
112
+ return item;
113
+ }
114
+
115
+ function firstExisting(dir, names) {
116
+ for (const name of names) {
117
+ const f = path.join(dir, name);
118
+ if (fs.existsSync(f) && fs.statSync(f).isFile()) return f;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ function parseManifest(abs, text) {
124
+ try {
125
+ if (/\.json$/i.test(abs)) return { ok: true, value: JSON.parse(text) || {} };
126
+ return { ok: true, value: YAML.parse(text) || {} };
127
+ } catch (e) {
128
+ return { ok: false, error: e.message };
129
+ }
130
+ }
131
+
132
+ function collectTags(obj) {
133
+ const candidates = [obj.tags, obj.keywords, obj.categories];
134
+ const out = [];
135
+ for (const v of candidates) {
136
+ if (Array.isArray(v)) out.push(...v.filter(x => typeof x === 'string'));
137
+ else if (typeof v === 'string') out.push(...v.split(',').map(x => x.trim()).filter(Boolean));
138
+ }
139
+ return [...new Set(out)];
140
+ }
141
+
142
+ function collectLinks(obj) {
143
+ const out = [];
144
+ for (const [label, value] of Object.entries({ homepage: obj.homepage, repository: obj.repository, url: obj.url })) {
145
+ if (typeof value === 'string') out.push({ label, url: value });
146
+ else if (value && typeof value.url === 'string') out.push({ label, url: value.url });
147
+ }
148
+ return out;
149
+ }
@@ -0,0 +1,219 @@
1
+ // markdown-skill adapter — shared logic for any source whose items are
2
+ // SKILL.md files with YAML frontmatter (Hermes, Claude Code, gstack, …).
3
+ //
4
+ // Usage:
5
+ // import { scanMarkdownSkills } from './adapters/markdown-skill.mjs';
6
+ // const items = await scanMarkdownSkills({
7
+ // source: 'hermes',
8
+ // roots: ['~/.hermes/skills'],
9
+ // fileGlob: 'SKILL.md',
10
+ // limits,
11
+ // });
12
+
13
+ import path from 'node:path';
14
+ import fg from 'fast-glob';
15
+ import {
16
+ expandRoots,
17
+ classifyRoot,
18
+ readFileSafe,
19
+ parseFrontmatter,
20
+ inferBrand,
21
+ inferProduct,
22
+ deriveDescription,
23
+ makePreview,
24
+ sha1Id,
25
+ } from '../utils.mjs';
26
+
27
+ /**
28
+ * @param {object} opts
29
+ * @param {string} opts.source — IR source tag
30
+ * @param {string[]} opts.roots — base dirs (with ~ / globs)
31
+ * @param {string} [opts.fileGlob='SKILL.md'] — relative glob from each root
32
+ * @param {object} [opts.limits] — { maxFiles, maxFileBytes }
33
+ * @returns {Promise<{items:import('../types').SkillItem[], stats:object}>}
34
+ */
35
+ export async function scanMarkdownSkills(opts) {
36
+ const {
37
+ source,
38
+ roots,
39
+ fileGlob = '**/SKILL.md',
40
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
41
+ } = opts;
42
+
43
+ const expanded = await expandRoots(roots);
44
+ if (!expanded.length) {
45
+ return { items: [], stats: { source, available: false, files: 0 } };
46
+ }
47
+
48
+ // collect file paths first
49
+ const files = [];
50
+ for (const root of expanded) {
51
+ const found = await fg(fileGlob, {
52
+ cwd: root,
53
+ absolute: true,
54
+ onlyFiles: true,
55
+ // gstack and similar plugins stash skills under hidden dirs like
56
+ // `.agents/skills/...`, `.cursor/skills/...`. Default dot:false dropped
57
+ // ~80% of claude-code skills. We let dotfiles in but still nuke noise
58
+ // dirs (`.git`, `node_modules`, `dist`) via the ignore list.
59
+ dot: true,
60
+ followSymbolicLinks: true,
61
+ deep: 10,
62
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
63
+ });
64
+ for (const f of found) {
65
+ files.push({ abs: f, root });
66
+ if (files.length >= limits.maxFiles) break;
67
+ }
68
+ if (files.length >= limits.maxFiles) break;
69
+ }
70
+
71
+ // parse each
72
+ const items = files.map(({ abs, root }) => parseSkillFile({ abs, root, source, limits }));
73
+ return {
74
+ items,
75
+ stats: { source, available: true, files: items.length, roots: expanded },
76
+ };
77
+ }
78
+
79
+ function parseSkillFile({ abs, root, source, limits }) {
80
+ const { text, truncated, mtime, error } = readFileSafe(abs, limits.maxFileBytes);
81
+ const id = sha1Id(abs);
82
+ const rel = path.relative(root, abs);
83
+
84
+ // category from the directory chain between root and the file
85
+ // e.g. ~/.hermes/skills/devops/new-api-deployment/SKILL.md
86
+ // → relDir = "devops/new-api-deployment"
87
+ // → category = "devops"
88
+ const relDir = path.dirname(rel);
89
+ const category = relDir && relDir !== '.' ? relDir.split('/')[0] : undefined;
90
+
91
+ // The skill's machine-name is the parent directory (Hermes / Claude Code
92
+ // convention is one SKILL.md per dir).
93
+ const dirName = path.basename(path.dirname(abs));
94
+
95
+ if (error) {
96
+ return baseItem({ abs, id, source, dirName, category, mtime, raw: '', parseError: error });
97
+ }
98
+ if (truncated) {
99
+ return baseItem({
100
+ abs, id, source, dirName, category, mtime, raw: '',
101
+ parseError: `file > ${limits.maxFileBytes} bytes, skipped`,
102
+ });
103
+ }
104
+
105
+ const { data: fm, body, parseError } = parseFrontmatter(text);
106
+
107
+ const name = (fm.name || dirName).toString().trim();
108
+ const title = (fm.title || fm.name || dirName).toString().trim();
109
+ const description = deriveDescription(fm, body);
110
+
111
+ // triggers — accept several field names used in the wild
112
+ const triggers = collectTriggers(fm);
113
+
114
+ // tags — accept array or comma-separated string
115
+ const tags = collectTags(fm);
116
+
117
+ // links — fm.links: [{label, url}] or fm.url: string
118
+ const links = collectLinks(fm);
119
+
120
+ const product = inferProduct({ name, category });
121
+ const item = {
122
+ id,
123
+ kind: 'skill',
124
+ source,
125
+ editor: editorForSource(source),
126
+ name,
127
+ title: title !== name ? title : undefined,
128
+ description,
129
+ category,
130
+ triggers: triggers.length ? triggers : undefined,
131
+ tags: tags.length ? tags : undefined,
132
+ paths: {
133
+ abs,
134
+ rel,
135
+ rootKind: classifyRoot(abs),
136
+ },
137
+ preview: makePreview(body),
138
+ raw: text,
139
+ links: links.length ? links : undefined,
140
+ updatedAt: new Date(mtime).toISOString(),
141
+ product,
142
+ };
143
+ item.brand = inferBrand({
144
+ name: item.name,
145
+ description: item.description,
146
+ category: item.category,
147
+ raw: body,
148
+ });
149
+ if (parseError) item.parseError = parseError;
150
+ return item;
151
+ }
152
+
153
+ function baseItem({ abs, id, source, dirName, category, mtime, raw, parseError }) {
154
+ return {
155
+ id,
156
+ kind: 'skill',
157
+ source,
158
+ editor: editorForSource(source),
159
+ name: dirName,
160
+ description: undefined,
161
+ category,
162
+ paths: { abs, rootKind: classifyRoot(abs) },
163
+ preview: '',
164
+ raw,
165
+ updatedAt: new Date(mtime).toISOString(),
166
+ parseError,
167
+ };
168
+ }
169
+
170
+ function editorForSource(source) {
171
+ const map = {
172
+ hermes: 'Hermes Agent',
173
+ 'claude-code': 'Claude Code',
174
+ codex: 'Codex',
175
+ cursor: 'Cursor',
176
+ 'mcp-config': 'MCP',
177
+ project: 'Project Docs',
178
+ };
179
+ return map[source] || source;
180
+ }
181
+
182
+ function collectTriggers(fm) {
183
+ const out = [];
184
+ const candidates = [fm.triggers, fm.aliases, fm.when_to_use];
185
+ for (const c of candidates) {
186
+ if (!c) continue;
187
+ if (Array.isArray(c)) {
188
+ out.push(...c.filter(x => typeof x === 'string').map(x => x.trim()));
189
+ } else if (typeof c === 'string') {
190
+ // when_to_use is usually a long sentence — keep it as a single trigger
191
+ out.push(c.trim());
192
+ }
193
+ }
194
+ // dedup, preserve order
195
+ return [...new Set(out)];
196
+ }
197
+
198
+ function collectTags(fm) {
199
+ const v = fm.tags;
200
+ if (!v) return [];
201
+ if (Array.isArray(v)) return v.filter(x => typeof x === 'string');
202
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
203
+ return [];
204
+ }
205
+
206
+ function collectLinks(fm) {
207
+ const out = [];
208
+ if (Array.isArray(fm.links)) {
209
+ for (const l of fm.links) {
210
+ if (l && typeof l === 'object' && l.url) {
211
+ out.push({ label: l.label || l.url, url: l.url });
212
+ }
213
+ }
214
+ }
215
+ if (typeof fm.url === 'string') {
216
+ out.push({ label: 'docs', url: fm.url });
217
+ }
218
+ return out;
219
+ }
@@ -0,0 +1,171 @@
1
+ // MCP config adapter — reads local config files and exposes MCP servers as safe IR.
2
+ // Values that may contain credentials are redacted before entering `raw`.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import YAML from 'yaml';
7
+ import {
8
+ classifyRoot,
9
+ expandTilde,
10
+ makePreview,
11
+ readFileSafe,
12
+ sha1Id,
13
+ } from '../utils.mjs';
14
+
15
+ const SECRET_KEY_RE = /(token|key|secret|password|passwd|credential|authorization|auth|cookie)/i;
16
+
17
+ export async function scanMcpConfigs(opts) {
18
+ const {
19
+ source = 'mcp-config',
20
+ editor = 'MCP',
21
+ files = [],
22
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
23
+ } = opts;
24
+
25
+ const items = [];
26
+ for (const f of files || []) {
27
+ const abs = path.resolve(expandTilde(f));
28
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
29
+ items.push(...parseConfigFile({ abs, source, editor, limits }));
30
+ if (items.length >= limits.maxFiles) break;
31
+ }
32
+ return { items, stats: { source, available: true, files: items.length } };
33
+ }
34
+
35
+ function parseConfigFile({ abs, source, editor, limits }) {
36
+ const { text, truncated, mtime, error } = readFileSafe(abs, limits.maxFileBytes);
37
+ const rootKind = classifyRoot(abs);
38
+ const fileName = path.basename(abs);
39
+ if (error || truncated) {
40
+ return [baseItem({ abs, source, editor, name: fileName, description: error || 'file too large', raw: '', mtime, rootKind })];
41
+ }
42
+
43
+ const parsed = parseAny(abs, text);
44
+ if (!parsed.ok) {
45
+ return [baseItem({ abs, source, editor, name: fileName, description: `MCP/config file: ${fileName}`, raw: redactText(text), mtime, rootKind, parseError: parsed.error })];
46
+ }
47
+
48
+ const servers = extractMcpServers(parsed.value);
49
+ if (!servers.length) {
50
+ return [baseItem({ abs, source, editor, name: fileName, description: `Config file: ${fileName}`, raw: safeStringify(parsed.value), mtime, rootKind })];
51
+ }
52
+
53
+ return servers.map(([serverName, cfg]) => {
54
+ const safeCfg = redact(cfg);
55
+ const command = typeof cfg?.command === 'string' ? cfg.command : undefined;
56
+ const url = typeof cfg?.url === 'string' ? redactText(cfg.url) : undefined;
57
+ const description = command
58
+ ? `MCP server · command: ${command}`
59
+ : url
60
+ ? `MCP server · url: ${url}`
61
+ : 'MCP server';
62
+ return {
63
+ id: sha1Id(`${abs}#${serverName}`),
64
+ kind: 'mcp',
65
+ source,
66
+ editor,
67
+ name: serverName,
68
+ description,
69
+ category: 'mcp',
70
+ product: serverName,
71
+ tags: ['mcp'],
72
+ paths: { abs, rel: fileName, rootKind },
73
+ preview: makePreview(safeStringify(safeCfg), 600),
74
+ raw: safeStringify({ [serverName]: safeCfg }),
75
+ updatedAt: new Date(mtime).toISOString(),
76
+ };
77
+ });
78
+ }
79
+
80
+ function baseItem({ abs, source, editor, name, description, raw, mtime, rootKind, parseError }) {
81
+ const item = {
82
+ id: sha1Id(abs),
83
+ kind: 'config',
84
+ source,
85
+ editor,
86
+ name,
87
+ description,
88
+ category: 'config',
89
+ product: name,
90
+ paths: { abs, rel: path.basename(abs), rootKind },
91
+ preview: makePreview(raw, 600),
92
+ raw,
93
+ updatedAt: new Date(mtime).toISOString(),
94
+ };
95
+ if (parseError) item.parseError = parseError;
96
+ return item;
97
+ }
98
+
99
+ function parseAny(abs, text) {
100
+ try {
101
+ if (/\.json$/i.test(abs)) return { ok: true, value: JSON.parse(text) };
102
+ if (/\.ya?ml$/i.test(abs)) return { ok: true, value: YAML.parse(text) || {} };
103
+ if (/\.toml$/i.test(abs)) return { ok: true, value: parseTomlMcpSubset(text) };
104
+ return { ok: true, value: YAML.parse(text) || {} };
105
+ } catch (e) {
106
+ return { ok: false, error: e.message };
107
+ }
108
+ }
109
+
110
+ function extractMcpServers(obj) {
111
+ if (!obj || typeof obj !== 'object') return [];
112
+ const candidate = obj.mcpServers || obj.mcp_servers || obj.servers || obj.mcp?.servers;
113
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
114
+ return Object.entries(candidate);
115
+ }
116
+
117
+ function parseTomlMcpSubset(text) {
118
+ const out = { mcp_servers: {} };
119
+ let current = null;
120
+ for (const rawLine of text.split(/\r?\n/)) {
121
+ const line = rawLine.trim();
122
+ if (!line || line.startsWith('#')) continue;
123
+ const sec = line.match(/^\[mcp_servers\.([^\]]+)\]$/);
124
+ if (sec) {
125
+ current = sec[1].replace(/^['"]|['"]$/g, '');
126
+ out.mcp_servers[current] = {};
127
+ continue;
128
+ }
129
+ if (!current) continue;
130
+ const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
131
+ if (!kv) continue;
132
+ const key = kv[1];
133
+ out.mcp_servers[current][key] = parseTomlValue(kv[2]);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function parseTomlValue(v) {
139
+ const s = v.trim();
140
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) return s.slice(1, -1);
141
+ if (s.startsWith('[') && s.endsWith(']')) {
142
+ return s.slice(1, -1).split(',').map(x => parseTomlValue(x.trim())).filter(x => x !== '');
143
+ }
144
+ if (s === 'true') return true;
145
+ if (s === 'false') return false;
146
+ return s;
147
+ }
148
+
149
+ function redact(value, key = '') {
150
+ if (SECRET_KEY_RE.test(key)) return '[REDACTED]';
151
+ if (Array.isArray(value)) return value.map(v => redact(v, key));
152
+ if (value && typeof value === 'object') {
153
+ const out = {};
154
+ for (const [k, v] of Object.entries(value)) out[k] = redact(v, k);
155
+ return out;
156
+ }
157
+ if (typeof value === 'string') return redactText(value);
158
+ return value;
159
+ }
160
+
161
+ function redactText(text) {
162
+ return String(text || '')
163
+ .replace(/(sk-[A-Za-z0-9_-]{12,})/g, '[REDACTED]')
164
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi, '$1[REDACTED]')
165
+ .replace(/([?&](?:token|key|secret|password|auth)=)[^&\s]+/gi, '$1[REDACTED]')
166
+ .replace(/((?:TOKEN|KEY|SECRET|PASSWORD|AUTH)[A-Z0-9_]*\s*[=:]\s*)[^\s"']+/g, '$1[REDACTED]');
167
+ }
168
+
169
+ function safeStringify(obj) {
170
+ return JSON.stringify(redact(obj), null, 2);
171
+ }