@hasna/configs 0.2.5 → 0.2.7

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.
package/README.md CHANGED
@@ -6,9 +6,10 @@ AI coding agent configuration manager — store, version, apply, and share all y
6
6
 
7
7
  ```bash
8
8
  bun install -g @hasna/configs
9
- configs sync --dir ~/.claude # ingest all Claude Code configs
10
- configs whoami # see what's stored
11
- configs apply claude-claude-md # write a config back to disk
9
+ configs init # first-time setup: sync all known configs + create profile
10
+ configs status # health check: drifted, secrets, templates
11
+ configs pull # re-sync from disk DB
12
+ configs push # apply DB → disk
12
13
  ```
13
14
 
14
15
  ## What It Stores
@@ -84,22 +85,35 @@ configs add <path> ingest a file into the DB
84
85
  configs apply <id> write config to its target_path
85
86
  --dry-run preview without writing
86
87
 
87
- configs diff <id> show diff: stored vs disk
88
+ configs diff [id] show diff: stored vs disk (omit id for all)
89
+ configs compare <a> <b> diff two stored configs against each other
88
90
 
89
- configs sync bulk sync a directory
90
- -d, --dir <dir> directory (default: ~/.claude)
91
- --from-disk read files from disk into DB (default)
92
- --to-disk apply DB configs back to disk
91
+ configs sync sync known AI coding configs from disk
92
+ -a, --agent <agent> only sync this agent
93
+ -p, --project [dir] sync project-scoped configs (CLAUDE.md, .mcp.json)
94
+ --to-disk apply DB disk instead
93
95
  --dry-run preview
96
+ --list show which files would be synced
94
97
 
95
- configs export export as tar.gz bundle
96
- -o, --output <path> output file (default: ./configs-export.tar.gz)
97
- -c, --category <cat> filter by category
98
-
99
- configs import <file> import from tar.gz bundle
100
- --overwrite overwrite existing configs
98
+ configs pull alias for sync (disk → DB)
99
+ configs push alias for sync --to-disk (DB → disk)
101
100
 
102
- configs whoami setup summary (DB path, counts by category)
101
+ configs export export as tar.gz bundle
102
+ configs import <file> import from tar.gz bundle (--overwrite)
103
+ configs backup timestamped export to ~/.configs/backups/
104
+ configs restore <file> import from backup (--overwrite)
105
+
106
+ configs init first-time setup: sync + seed + create profile
107
+ configs status health check: drifted, secrets, templates
108
+ configs whoami setup summary: DB path, counts by category
109
+ configs doctor validate syntax, permissions, missing files, secrets
110
+ configs scan [id] scan for unredacted secrets (--fix to redact)
111
+ configs watch auto-sync on file changes (polls every 3s)
112
+ configs update check for + install latest version
113
+ configs completions output zsh/bash completion script
114
+
115
+ configs mcp install install MCP server (--claude, --codex, --gemini, --all)
116
+ configs mcp uninstall remove MCP server
103
117
  ```
104
118
 
105
119
  ### Profiles
@@ -127,12 +141,26 @@ configs snapshot show <snapshot-id> # view content
127
141
  configs snapshot restore <config> <id> # restore to that version
128
142
  ```
129
143
 
130
- ### Templates
144
+ ### Templates & Secret Redaction
145
+
146
+ Secrets are automatically redacted when ingesting configs. Values matching API keys, tokens, passwords etc. are replaced with `{{VAR_NAME}}` placeholders.
147
+
148
+ ```bash
149
+ configs template vars npmrc # show: {{NPM_AUTH_TOKEN}}
150
+ configs template render npmrc --env --apply # fill from env vars, write to disk
151
+ configs template render npmrc --var NPM_AUTH_TOKEN=xxx --dry-run # preview
152
+ configs scan # check for unredacted secrets
153
+ configs scan --fix # redact any that slipped through
154
+ ```
155
+
156
+ ### Agent Profiles (Token Optimization)
131
157
 
132
- Configs with `{{VAR_NAME}}` placeholders are templates.
158
+ Control which MCP tools are exposed via `CONFIGS_PROFILE` env var:
133
159
 
134
160
  ```bash
135
- configs template vars my-zshrc-template # show required variables
161
+ CONFIGS_PROFILE=minimal configs-mcp # 3 tools: get_status, get_config, sync_known
162
+ CONFIGS_PROFILE=standard configs-mcp # 11 tools: CRUD + sync + profiles
163
+ CONFIGS_PROFILE=full configs-mcp # 13 tools (default)
136
164
  ```
137
165
 
138
166
  ## MCP Server
package/dist/mcp/index.js CHANGED
@@ -1005,7 +1005,14 @@ var TOOL_DOCS = {
1005
1005
  search_tools: "Search tool descriptions. Params: query. Returns matching tool names and descriptions.",
1006
1006
  describe_tools: "Get full descriptions for tools. Params: names? (array). Returns tool docs."
1007
1007
  };
1008
- var LEAN_TOOLS = [
1008
+ var PROFILES = {
1009
+ minimal: ["get_status", "get_config", "sync_known"],
1010
+ standard: ["list_configs", "get_config", "create_config", "update_config", "apply_config", "sync_known", "get_status", "list_profiles", "apply_profile", "search_tools", "describe_tools"],
1011
+ full: []
1012
+ };
1013
+ var activeProfile = process.env["CONFIGS_PROFILE"] || "full";
1014
+ var profileFilter = PROFILES[activeProfile];
1015
+ var ALL_LEAN_TOOLS = [
1009
1016
  { name: "list_configs", inputSchema: { type: "object", properties: { category: { type: "string" }, agent: { type: "string" }, kind: { type: "string" }, search: { type: "string" } } } },
1010
1017
  { name: "get_config", inputSchema: { type: "object", properties: { id_or_slug: { type: "string" } }, required: ["id_or_slug"] } },
1011
1018
  { name: "create_config", inputSchema: { type: "object", properties: { name: { type: "string" }, content: { type: "string" }, category: { type: "string" }, agent: { type: "string" }, target_path: { type: "string" }, kind: { type: "string" }, format: { type: "string" }, tags: { type: "array", items: { type: "string" } }, description: { type: "string" }, is_template: { type: "boolean" } }, required: ["name", "content", "category"] } },
@@ -1027,6 +1034,7 @@ function err(msg) {
1027
1034
  return { content: [{ type: "text", text: JSON.stringify({ error: msg }) }], isError: true };
1028
1035
  }
1029
1036
  var server = new Server({ name: "configs", version: "0.1.0" }, { capabilities: { tools: {} } });
1037
+ var LEAN_TOOLS = profileFilter && profileFilter.length > 0 ? ALL_LEAN_TOOLS.filter((t) => profileFilter.includes(t.name)) : ALL_LEAN_TOOLS;
1030
1038
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: LEAN_TOOLS }));
1031
1039
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
1032
1040
  const { name, arguments: args = {} } = req.params;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":";;;;;AAkPA,wBAAgD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":";;;;;AA0QA,wBAAgD"}
@@ -2083,64 +2083,188 @@ async function applyConfigs(configs, opts = {}) {
2083
2083
  return results;
2084
2084
  }
2085
2085
 
2086
- // src/lib/sync-dir.ts
2087
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
2088
- import { join as join3, relative } from "path";
2086
+ // src/lib/sync.ts
2087
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
2088
+ import { extname, join as join3 } from "path";
2089
2089
  import { homedir as homedir3 } from "os";
2090
2090
 
2091
- // src/lib/sync.ts
2092
- import { extname, join as join2 } from "path";
2093
- import { homedir as homedir2 } from "os";
2094
- function detectCategory(filePath) {
2095
- const p = filePath.toLowerCase().replace(homedir2(), "~");
2096
- if (p.includes("/.claude/rules/") || p.endsWith("claude.md") || p.endsWith("agents.md") || p.endsWith("gemini.md"))
2097
- return "rules";
2098
- if (p.includes("/.claude/") || p.includes("/.codex/") || p.includes("/.gemini/") || p.includes("/.cursor/"))
2099
- return "agent";
2100
- if (p.includes(".mcp.json") || p.includes("mcp"))
2101
- return "mcp";
2102
- if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc") || p.includes(".bash_profile"))
2103
- return "shell";
2104
- if (p.includes(".gitconfig") || p.includes(".gitignore"))
2105
- return "git";
2106
- if (p.includes(".npmrc") || p.includes("tsconfig") || p.includes("bunfig"))
2107
- return "tools";
2108
- if (p.includes(".secrets"))
2109
- return "secrets_schema";
2110
- return "tools";
2091
+ // src/lib/redact.ts
2092
+ var SECRET_KEY_PATTERN = /^(.*_?API_?KEY|.*_?TOKEN|.*_?SECRET|.*_?PASSWORD|.*_?PASSWD|.*_?CREDENTIAL|.*_?AUTH(?:_TOKEN|_KEY|ORIZATION)?|.*_?PRIVATE_?KEY|.*_?ACCESS_?KEY|.*_?CLIENT_?SECRET|.*_?SIGNING_?KEY|.*_?ENCRYPTION_?KEY|.*_AUTH_TOKEN)$/i;
2093
+ var VALUE_PATTERNS = [
2094
+ { re: /npm_[A-Za-z0-9]{36,}/, reason: "npm token" },
2095
+ { re: /gh[pousr]_[A-Za-z0-9_]{36,}/, reason: "GitHub token" },
2096
+ { re: /sk-ant-[A-Za-z0-9\-_]{40,}/, reason: "Anthropic API key" },
2097
+ { re: /sk-[A-Za-z0-9]{48,}/, reason: "OpenAI API key" },
2098
+ { re: /xoxb-[0-9]+-[A-Za-z0-9\-]+/, reason: "Slack bot token" },
2099
+ { re: /AIza[0-9A-Za-z\-_]{35}/, reason: "Google API key" },
2100
+ { re: /ey[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\./, reason: "JWT token" },
2101
+ { re: /AKIA[0-9A-Z]{16}/, reason: "AWS access key" }
2102
+ ];
2103
+ var MIN_SECRET_VALUE_LEN = 8;
2104
+ function redactShell(content) {
2105
+ const redacted = [];
2106
+ const lines = content.split(`
2107
+ `);
2108
+ const out = [];
2109
+ for (let i = 0;i < lines.length; i++) {
2110
+ const line = lines[i];
2111
+ const m = line.match(/^(\s*(?:export\s+)?)([A-Z][A-Z0-9_]*)(\s*=\s*)(['"]?)(.+?)\4\s*$/);
2112
+ if (m) {
2113
+ const [, prefix, key, eq, quote, value] = m;
2114
+ if (shouldRedactKeyValue(key, value)) {
2115
+ const reason = reasonFor(key, value);
2116
+ redacted.push({ varName: key, line: i + 1, reason });
2117
+ out.push(`${prefix}${key}${eq}${quote}{{${key}}}${quote}`);
2118
+ continue;
2119
+ }
2120
+ }
2121
+ out.push(line);
2122
+ }
2123
+ return { content: out.join(`
2124
+ `), redacted, isTemplate: redacted.length > 0 };
2111
2125
  }
2112
- function detectAgent(filePath) {
2113
- const p = filePath.toLowerCase().replace(homedir2(), "~");
2114
- if (p.includes("/.claude/") || p.endsWith("claude.md"))
2115
- return "claude";
2116
- if (p.includes("/.codex/") || p.endsWith("agents.md"))
2117
- return "codex";
2118
- if (p.includes("/.gemini/") || p.endsWith("gemini.md"))
2119
- return "gemini";
2120
- if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc"))
2121
- return "zsh";
2122
- if (p.includes(".gitconfig") || p.includes(".gitignore"))
2123
- return "git";
2124
- if (p.includes(".npmrc"))
2125
- return "npm";
2126
- return "global";
2126
+ function redactJson(content) {
2127
+ const redacted = [];
2128
+ const lines = content.split(`
2129
+ `);
2130
+ const out = [];
2131
+ for (let i = 0;i < lines.length; i++) {
2132
+ const line = lines[i];
2133
+ const m = line.match(/^(\s*"([^"]+)"\s*:\s*)"([^"]+)"(,?)(\s*)$/);
2134
+ if (m) {
2135
+ const [, prefix, key, value, comma, trail] = m;
2136
+ if (shouldRedactKeyValue(key, value)) {
2137
+ const varName = key.toUpperCase().replace(/[^A-Z0-9]/g, "_");
2138
+ redacted.push({ varName, line: i + 1, reason: reasonFor(key, value) });
2139
+ out.push(`${prefix}"{{${varName}}}"${comma}${trail}`);
2140
+ continue;
2141
+ }
2142
+ }
2143
+ let newLine = line;
2144
+ for (const { re, reason } of VALUE_PATTERNS) {
2145
+ newLine = newLine.replace(re, (match2) => {
2146
+ const varName = `REDACTED_${reason.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
2147
+ redacted.push({ varName, line: i + 1, reason });
2148
+ return `{{${varName}}}`;
2149
+ });
2150
+ }
2151
+ out.push(newLine);
2152
+ }
2153
+ return { content: out.join(`
2154
+ `), redacted, isTemplate: redacted.length > 0 };
2127
2155
  }
2128
- function detectFormat(filePath) {
2129
- const ext = extname(filePath).toLowerCase();
2130
- if (ext === ".json")
2131
- return "json";
2132
- if (ext === ".toml")
2133
- return "toml";
2134
- if (ext === ".yaml" || ext === ".yml")
2135
- return "yaml";
2136
- if (ext === ".md" || ext === ".markdown")
2137
- return "markdown";
2138
- if (ext === ".ini" || ext === ".cfg")
2139
- return "ini";
2140
- return "text";
2156
+ function redactToml(content) {
2157
+ const redacted = [];
2158
+ const lines = content.split(`
2159
+ `);
2160
+ const out = [];
2161
+ for (let i = 0;i < lines.length; i++) {
2162
+ const line = lines[i];
2163
+ const m = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9_\-]*)(\s*=\s*)(['"]?)(.+?)\4\s*$/);
2164
+ if (m) {
2165
+ const [, indent, key, eq, quote, value] = m;
2166
+ if (shouldRedactKeyValue(key, value)) {
2167
+ const varName = key.toUpperCase().replace(/[^A-Z0-9]/g, "_");
2168
+ redacted.push({ varName, line: i + 1, reason: reasonFor(key, value) });
2169
+ out.push(`${indent}${key}${eq}${quote}{{${varName}}}${quote}`);
2170
+ continue;
2171
+ }
2172
+ }
2173
+ out.push(line);
2174
+ }
2175
+ return { content: out.join(`
2176
+ `), redacted, isTemplate: redacted.length > 0 };
2177
+ }
2178
+ function redactIni(content) {
2179
+ const redacted = [];
2180
+ const lines = content.split(`
2181
+ `);
2182
+ const out = [];
2183
+ for (let i = 0;i < lines.length; i++) {
2184
+ const line = lines[i];
2185
+ const authM = line.match(/^(\/\/[^:]+:_authToken=)(.+)$/);
2186
+ if (authM && !authM[2].startsWith("{{")) {
2187
+ redacted.push({ varName: "NPM_AUTH_TOKEN", line: i + 1, reason: "npm auth token" });
2188
+ out.push(`${authM[1]}{{NPM_AUTH_TOKEN}}`);
2189
+ continue;
2190
+ }
2191
+ const m = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9_\-]*)(\s*=\s*)(.+?)\s*$/);
2192
+ if (m) {
2193
+ const [, indent, key, eq, value] = m;
2194
+ if (shouldRedactKeyValue(key, value)) {
2195
+ const varName = key.toUpperCase().replace(/[^A-Z0-9]/g, "_");
2196
+ redacted.push({ varName, line: i + 1, reason: reasonFor(key, value) });
2197
+ out.push(`${indent}${key}${eq}{{${varName}}}`);
2198
+ continue;
2199
+ }
2200
+ }
2201
+ out.push(line);
2202
+ }
2203
+ return { content: out.join(`
2204
+ `), redacted, isTemplate: redacted.length > 0 };
2205
+ }
2206
+ function redactGeneric(content) {
2207
+ const redacted = [];
2208
+ const lines = content.split(`
2209
+ `);
2210
+ const out = [];
2211
+ for (let i = 0;i < lines.length; i++) {
2212
+ let line = lines[i];
2213
+ for (const { re, reason } of VALUE_PATTERNS) {
2214
+ line = line.replace(re, (match2) => {
2215
+ const varName = reason.toUpperCase().replace(/[^A-Z0-9]/g, "_");
2216
+ redacted.push({ varName, line: i + 1, reason });
2217
+ return `{{${varName}}}`;
2218
+ });
2219
+ }
2220
+ out.push(line);
2221
+ }
2222
+ return { content: out.join(`
2223
+ `), redacted, isTemplate: redacted.length > 0 };
2224
+ }
2225
+ function shouldRedactKeyValue(key, value) {
2226
+ if (!value || value.startsWith("{{"))
2227
+ return false;
2228
+ if (value.length < MIN_SECRET_VALUE_LEN)
2229
+ return false;
2230
+ if (/^(true|false|yes|no|on|off|null|undefined|\d+)$/i.test(value))
2231
+ return false;
2232
+ if (SECRET_KEY_PATTERN.test(key))
2233
+ return true;
2234
+ for (const { re } of VALUE_PATTERNS) {
2235
+ if (re.test(value))
2236
+ return true;
2237
+ }
2238
+ return false;
2239
+ }
2240
+ function reasonFor(key, value) {
2241
+ if (SECRET_KEY_PATTERN.test(key))
2242
+ return `secret key name: ${key}`;
2243
+ for (const { re, reason } of VALUE_PATTERNS) {
2244
+ if (re.test(value))
2245
+ return reason;
2246
+ }
2247
+ return "secret value pattern";
2248
+ }
2249
+ function redactContent(content, format) {
2250
+ switch (format) {
2251
+ case "shell":
2252
+ return redactShell(content);
2253
+ case "json":
2254
+ return redactJson(content);
2255
+ case "toml":
2256
+ return redactToml(content);
2257
+ case "ini":
2258
+ return redactIni(content);
2259
+ default:
2260
+ return redactGeneric(content);
2261
+ }
2141
2262
  }
2142
2263
 
2143
2264
  // src/lib/sync-dir.ts
2265
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
2266
+ import { join as join2, relative } from "path";
2267
+ import { homedir as homedir2 } from "os";
2144
2268
  var SKIP = [".db", ".db-shm", ".db-wal", ".log", ".lock", ".DS_Store", "node_modules", ".git"];
2145
2269
  function shouldSkip(p) {
2146
2270
  return SKIP.some((s) => p.includes(s));
@@ -2150,9 +2274,9 @@ async function syncFromDir(dir, opts = {}) {
2150
2274
  const absDir = expandPath(dir);
2151
2275
  if (!existsSync3(absDir))
2152
2276
  return { added: 0, updated: 0, unchanged: 0, skipped: [`Not found: ${absDir}`] };
2153
- const files = opts.recursive !== false ? walkDir(absDir) : readdirSync(absDir).map((f) => join3(absDir, f)).filter((f) => statSync(f).isFile());
2277
+ const files = opts.recursive !== false ? walkDir(absDir) : readdirSync(absDir).map((f) => join2(absDir, f)).filter((f) => statSync(f).isFile());
2154
2278
  const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
2155
- const home = homedir3();
2279
+ const home = homedir2();
2156
2280
  const allConfigs = listConfigs(undefined, d);
2157
2281
  for (const file of files) {
2158
2282
  if (shouldSkip(file)) {
@@ -2186,7 +2310,7 @@ async function syncFromDir(dir, opts = {}) {
2186
2310
  }
2187
2311
  async function syncToDir(dir, opts = {}) {
2188
2312
  const d = opts.db || getDatabase();
2189
- const home = homedir3();
2313
+ const home = homedir2();
2190
2314
  const absDir = expandPath(dir);
2191
2315
  const normalized = dir.startsWith("~/") ? dir : absDir.replace(home, "~");
2192
2316
  const configs = listConfigs(undefined, d).filter((c) => c.target_path && (c.target_path.startsWith(normalized) || c.target_path.startsWith(absDir)));
@@ -2205,7 +2329,7 @@ async function syncToDir(dir, opts = {}) {
2205
2329
  }
2206
2330
  function walkDir(dir, files = []) {
2207
2331
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
2208
- const full = join3(dir, entry.name);
2332
+ const full = join2(dir, entry.name);
2209
2333
  if (shouldSkip(full))
2210
2334
  continue;
2211
2335
  if (entry.isDirectory())
@@ -2216,8 +2340,161 @@ function walkDir(dir, files = []) {
2216
2340
  return files;
2217
2341
  }
2218
2342
 
2343
+ // src/lib/sync.ts
2344
+ var KNOWN_CONFIGS = [
2345
+ { path: "~/.claude/CLAUDE.md", name: "claude-claude-md", category: "rules", agent: "claude", format: "markdown" },
2346
+ { path: "~/.claude/settings.json", name: "claude-settings", category: "agent", agent: "claude", format: "json" },
2347
+ { path: "~/.claude/settings.local.json", name: "claude-settings-local", category: "agent", agent: "claude", format: "json" },
2348
+ { path: "~/.claude/keybindings.json", name: "claude-keybindings", category: "agent", agent: "claude", format: "json" },
2349
+ { path: "~/.claude/rules", name: "claude-rules", category: "rules", agent: "claude", rulesDir: "~/.claude/rules" },
2350
+ { path: "~/.codex/config.toml", name: "codex-config", category: "agent", agent: "codex", format: "toml" },
2351
+ { path: "~/.codex/AGENTS.md", name: "codex-agents-md", category: "rules", agent: "codex", format: "markdown" },
2352
+ { path: "~/.gemini/settings.json", name: "gemini-settings", category: "agent", agent: "gemini", format: "json" },
2353
+ { path: "~/.gemini/GEMINI.md", name: "gemini-gemini-md", category: "rules", agent: "gemini", format: "markdown" },
2354
+ { path: "~/.claude.json", name: "claude-json", category: "mcp", agent: "claude", format: "json", description: "Claude Code global config (includes MCP server entries)" },
2355
+ { path: "~/.zshrc", name: "zshrc", category: "shell", agent: "zsh" },
2356
+ { path: "~/.zprofile", name: "zprofile", category: "shell", agent: "zsh" },
2357
+ { path: "~/.bashrc", name: "bashrc", category: "shell", agent: "zsh" },
2358
+ { path: "~/.bash_profile", name: "bash-profile", category: "shell", agent: "zsh" },
2359
+ { path: "~/.gitconfig", name: "gitconfig", category: "git", agent: "git", format: "ini" },
2360
+ { path: "~/.gitignore_global", name: "gitignore-global", category: "git", agent: "git" },
2361
+ { path: "~/.npmrc", name: "npmrc", category: "tools", agent: "npm", format: "ini" },
2362
+ { path: "~/.bunfig.toml", name: "bunfig", category: "tools", agent: "global", format: "toml" }
2363
+ ];
2364
+ async function syncKnown(opts = {}) {
2365
+ const d = opts.db || getDatabase();
2366
+ const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
2367
+ const home = homedir3();
2368
+ let targets = KNOWN_CONFIGS;
2369
+ if (opts.agent)
2370
+ targets = targets.filter((k) => k.agent === opts.agent);
2371
+ if (opts.category)
2372
+ targets = targets.filter((k) => k.category === opts.category);
2373
+ const allConfigs = listConfigs(undefined, d);
2374
+ for (const known of targets) {
2375
+ if (known.rulesDir) {
2376
+ const absDir = expandPath(known.rulesDir);
2377
+ if (!existsSync4(absDir)) {
2378
+ result.skipped.push(known.rulesDir);
2379
+ continue;
2380
+ }
2381
+ const mdFiles = readdirSync2(absDir).filter((f) => f.endsWith(".md"));
2382
+ for (const f of mdFiles) {
2383
+ const abs2 = join3(absDir, f);
2384
+ const targetPath = abs2.replace(home, "~");
2385
+ const raw2 = readFileSync3(abs2, "utf-8");
2386
+ const { content, isTemplate } = redactContent(raw2, "markdown");
2387
+ const name = `claude-rules-${f}`;
2388
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
2389
+ const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === slug);
2390
+ if (!existing) {
2391
+ if (!opts.dryRun)
2392
+ createConfig({ name, category: "rules", agent: "claude", format: "markdown", content, target_path: targetPath, is_template: isTemplate }, d);
2393
+ result.added++;
2394
+ } else if (existing.content !== content) {
2395
+ if (!opts.dryRun)
2396
+ updateConfig(existing.id, { content, is_template: isTemplate }, d);
2397
+ result.updated++;
2398
+ } else {
2399
+ result.unchanged++;
2400
+ }
2401
+ }
2402
+ continue;
2403
+ }
2404
+ const abs = expandPath(known.path);
2405
+ if (!existsSync4(abs)) {
2406
+ result.skipped.push(known.path);
2407
+ continue;
2408
+ }
2409
+ try {
2410
+ const rawContent = readFileSync3(abs, "utf-8");
2411
+ if (rawContent.length > 500000) {
2412
+ result.skipped.push(known.path + " (too large)");
2413
+ continue;
2414
+ }
2415
+ const fmt = known.format ?? detectFormat(abs);
2416
+ const { content, isTemplate } = redactContent(rawContent, fmt);
2417
+ const targetPath = abs.replace(home, "~");
2418
+ const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === known.name);
2419
+ if (!existing) {
2420
+ if (!opts.dryRun) {
2421
+ createConfig({
2422
+ name: known.name,
2423
+ category: known.category,
2424
+ agent: known.agent,
2425
+ format: fmt,
2426
+ content,
2427
+ target_path: known.kind === "reference" ? null : targetPath,
2428
+ kind: known.kind ?? "file",
2429
+ description: known.description,
2430
+ is_template: isTemplate
2431
+ }, d);
2432
+ }
2433
+ result.added++;
2434
+ } else if (existing.content !== content) {
2435
+ if (!opts.dryRun)
2436
+ updateConfig(existing.id, { content, is_template: isTemplate }, d);
2437
+ result.updated++;
2438
+ } else {
2439
+ result.unchanged++;
2440
+ }
2441
+ } catch {
2442
+ result.skipped.push(known.path);
2443
+ }
2444
+ }
2445
+ return result;
2446
+ }
2447
+ function detectCategory(filePath) {
2448
+ const p = filePath.toLowerCase().replace(homedir3(), "~");
2449
+ if (p.includes("/.claude/rules/") || p.endsWith("claude.md") || p.endsWith("agents.md") || p.endsWith("gemini.md"))
2450
+ return "rules";
2451
+ if (p.includes("/.claude/") || p.includes("/.codex/") || p.includes("/.gemini/") || p.includes("/.cursor/"))
2452
+ return "agent";
2453
+ if (p.includes(".mcp.json") || p.includes("mcp"))
2454
+ return "mcp";
2455
+ if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc") || p.includes(".bash_profile"))
2456
+ return "shell";
2457
+ if (p.includes(".gitconfig") || p.includes(".gitignore"))
2458
+ return "git";
2459
+ if (p.includes(".npmrc") || p.includes("tsconfig") || p.includes("bunfig"))
2460
+ return "tools";
2461
+ if (p.includes(".secrets"))
2462
+ return "secrets_schema";
2463
+ return "tools";
2464
+ }
2465
+ function detectAgent(filePath) {
2466
+ const p = filePath.toLowerCase().replace(homedir3(), "~");
2467
+ if (p.includes("/.claude/") || p.endsWith("claude.md"))
2468
+ return "claude";
2469
+ if (p.includes("/.codex/") || p.endsWith("agents.md"))
2470
+ return "codex";
2471
+ if (p.includes("/.gemini/") || p.endsWith("gemini.md"))
2472
+ return "gemini";
2473
+ if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc"))
2474
+ return "zsh";
2475
+ if (p.includes(".gitconfig") || p.includes(".gitignore"))
2476
+ return "git";
2477
+ if (p.includes(".npmrc"))
2478
+ return "npm";
2479
+ return "global";
2480
+ }
2481
+ function detectFormat(filePath) {
2482
+ const ext = extname(filePath).toLowerCase();
2483
+ if (ext === ".json")
2484
+ return "json";
2485
+ if (ext === ".toml")
2486
+ return "toml";
2487
+ if (ext === ".yaml" || ext === ".yml")
2488
+ return "yaml";
2489
+ if (ext === ".md" || ext === ".markdown")
2490
+ return "markdown";
2491
+ if (ext === ".ini" || ext === ".cfg")
2492
+ return "ini";
2493
+ return "text";
2494
+ }
2495
+
2219
2496
  // src/server/index.ts
2220
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
2497
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
2221
2498
  import { join as join4, extname as extname2 } from "path";
2222
2499
  var PORT = Number(process.env["CONFIGS_PORT"] ?? 3457);
2223
2500
  function pickFields(obj, fields) {
@@ -2229,6 +2506,30 @@ function pickFields(obj, fields) {
2229
2506
  var app = new Hono2;
2230
2507
  app.use("*", cors());
2231
2508
  app.get("/api/stats", (c) => c.json(getConfigStats()));
2509
+ app.get("/api/status", (c) => {
2510
+ const stats = getConfigStats();
2511
+ const allConfigs = listConfigs({ kind: "file" });
2512
+ let templates = 0;
2513
+ for (const cfg of allConfigs) {
2514
+ if (cfg.is_template)
2515
+ templates++;
2516
+ }
2517
+ return c.json({
2518
+ total: stats["total"] || 0,
2519
+ by_category: Object.fromEntries(Object.entries(stats).filter(([k]) => k !== "total")),
2520
+ templates,
2521
+ db_path: process.env["CONFIGS_DB_PATH"] || "~/.configs/configs.db"
2522
+ });
2523
+ });
2524
+ app.post("/api/sync-known", async (c) => {
2525
+ try {
2526
+ const body = await c.req.json().catch(() => ({}));
2527
+ const result = await syncKnown({ agent: body.agent, category: body.category, dryRun: body.dry_run });
2528
+ return c.json(result);
2529
+ } catch (e) {
2530
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 422);
2531
+ }
2532
+ });
2232
2533
  app.get("/api/configs", (c) => {
2233
2534
  const { category, agent, kind, search, fields } = c.req.query();
2234
2535
  const configs = listConfigs({
@@ -2388,7 +2689,7 @@ function findDashboardDir() {
2388
2689
  join4(import.meta.dir, "../../../dashboard/dist")
2389
2690
  ];
2390
2691
  for (const dir of candidates) {
2391
- if (existsSync4(join4(dir, "index.html")))
2692
+ if (existsSync5(join4(dir, "index.html")))
2392
2693
  return dir;
2393
2694
  }
2394
2695
  return null;
@@ -2399,11 +2700,11 @@ if (dashDir) {
2399
2700
  const url = new URL(c.req.url);
2400
2701
  let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
2401
2702
  let absPath = join4(dashDir, filePath);
2402
- if (!existsSync4(absPath))
2703
+ if (!existsSync5(absPath))
2403
2704
  absPath = join4(dashDir, "index.html");
2404
- if (!existsSync4(absPath))
2705
+ if (!existsSync5(absPath))
2405
2706
  return c.json({ error: "Not found" }, 404);
2406
- const content = readFileSync3(absPath);
2707
+ const content = readFileSync4(absPath);
2407
2708
  const ext = extname2(absPath);
2408
2709
  return new Response(content, {
2409
2710
  headers: { "Content-Type": MIME[ext] || "application/octet-stream" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/configs",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "AI coding agent configuration manager — store, version, apply, and share all your AI coding configs. CLI + MCP + REST API + Dashboard.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",