@sporesec/arcana 3.0.3 → 4.0.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 (102) hide show
  1. package/dist/cli.js +25 -298
  2. package/dist/command-defs.d.ts +28 -0
  3. package/dist/command-defs.js +414 -0
  4. package/dist/commands/audit.js +18 -4
  5. package/dist/commands/clean.d.ts +1 -0
  6. package/dist/commands/clean.js +80 -0
  7. package/dist/commands/compress.d.ts +5 -0
  8. package/dist/commands/compress.js +38 -0
  9. package/dist/commands/config.js +40 -26
  10. package/dist/commands/create.js +2 -0
  11. package/dist/commands/curate.d.ts +39 -0
  12. package/dist/commands/curate.js +222 -0
  13. package/dist/commands/diff.js +2 -0
  14. package/dist/commands/doctor.d.ts +1 -0
  15. package/dist/commands/doctor.js +61 -2
  16. package/dist/commands/import-cmd.js +5 -0
  17. package/dist/commands/index.d.ts +5 -0
  18. package/dist/commands/index.js +107 -0
  19. package/dist/commands/info.js +19 -8
  20. package/dist/commands/init.d.ts +3 -0
  21. package/dist/commands/init.js +71 -0
  22. package/dist/commands/install.js +2 -0
  23. package/dist/commands/list.js +8 -0
  24. package/dist/commands/load.d.ts +10 -0
  25. package/dist/commands/load.js +130 -0
  26. package/dist/commands/lock.js +35 -24
  27. package/dist/commands/mcp.d.ts +4 -0
  28. package/dist/commands/mcp.js +87 -0
  29. package/dist/commands/outdated.js +8 -6
  30. package/dist/commands/providers.js +29 -21
  31. package/dist/commands/recommend.js +11 -3
  32. package/dist/commands/remember.d.ts +12 -0
  33. package/dist/commands/remember.js +111 -0
  34. package/dist/commands/scan.d.ts +2 -0
  35. package/dist/commands/scan.js +46 -8
  36. package/dist/commands/search.js +6 -0
  37. package/dist/commands/uninstall.js +36 -0
  38. package/dist/commands/update.js +27 -0
  39. package/dist/commands/validate.js +8 -0
  40. package/dist/commands/verify.js +2 -0
  41. package/dist/compress/engine.d.ts +21 -0
  42. package/dist/compress/engine.js +106 -0
  43. package/dist/compress/index.d.ts +7 -0
  44. package/dist/compress/index.js +10 -0
  45. package/dist/compress/rules/generic.d.ts +1 -0
  46. package/dist/compress/rules/generic.js +9 -0
  47. package/dist/compress/rules/git.d.ts +1 -0
  48. package/dist/compress/rules/git.js +113 -0
  49. package/dist/compress/rules/npm.d.ts +1 -0
  50. package/dist/compress/rules/npm.js +99 -0
  51. package/dist/compress/rules/test-runner.d.ts +1 -0
  52. package/dist/compress/rules/test-runner.js +103 -0
  53. package/dist/compress/rules/tsc.d.ts +1 -0
  54. package/dist/compress/rules/tsc.js +39 -0
  55. package/dist/compress/tracker.d.ts +16 -0
  56. package/dist/compress/tracker.js +45 -0
  57. package/dist/constants.d.ts +12 -0
  58. package/dist/constants.js +29 -0
  59. package/dist/interactive/helpers.js +1 -0
  60. package/dist/interactive/menu.js +6 -1
  61. package/dist/interactive/optimize-flow.js +4 -4
  62. package/dist/mcp/install.d.ts +10 -0
  63. package/dist/mcp/install.js +109 -0
  64. package/dist/mcp/registry.d.ts +11 -0
  65. package/dist/mcp/registry.js +27 -0
  66. package/dist/providers/anthropics.d.ts +4 -0
  67. package/dist/providers/anthropics.js +10 -0
  68. package/dist/registry.js +4 -0
  69. package/dist/session/trim.d.ts +23 -0
  70. package/dist/session/trim.js +132 -0
  71. package/dist/utils/cache.js +2 -2
  72. package/dist/utils/config.d.ts +2 -0
  73. package/dist/utils/config.js +33 -14
  74. package/dist/utils/help.js +16 -8
  75. package/dist/utils/install-core.js +23 -1
  76. package/dist/utils/memory.d.ts +25 -0
  77. package/dist/utils/memory.js +103 -0
  78. package/dist/utils/project-context.js +4 -0
  79. package/dist/utils/scanner.d.ts +22 -1
  80. package/dist/utils/scanner.js +81 -9
  81. package/dist/utils/sessions.d.ts +2 -0
  82. package/dist/utils/sessions.js +36 -0
  83. package/dist/utils/ui.js +5 -0
  84. package/dist/utils/usage.d.ts +17 -0
  85. package/dist/utils/usage.js +83 -0
  86. package/package.json +42 -7
  87. package/dist/command-registry.d.ts +0 -10
  88. package/dist/command-registry.js +0 -65
  89. package/dist/commands/benchmark.d.ts +0 -4
  90. package/dist/commands/benchmark.js +0 -178
  91. package/dist/commands/compact.d.ts +0 -6
  92. package/dist/commands/compact.js +0 -239
  93. package/dist/commands/optimize.d.ts +0 -3
  94. package/dist/commands/optimize.js +0 -356
  95. package/dist/commands/profile.d.ts +0 -3
  96. package/dist/commands/profile.js +0 -274
  97. package/dist/commands/stats.d.ts +0 -3
  98. package/dist/commands/stats.js +0 -210
  99. package/dist/commands/team.d.ts +0 -3
  100. package/dist/commands/team.js +0 -291
  101. package/dist/interactive.d.ts +0 -1
  102. package/dist/interactive.js +0 -841
@@ -0,0 +1,27 @@
1
+ export const MCP_REGISTRY = [
2
+ {
3
+ name: "context7",
4
+ description: "Live version-specific documentation from source repos (Upstash)",
5
+ command: "npx",
6
+ args: ["-y", "@upstash/context7-mcp"],
7
+ envKeys: ["CONTEXT7_API_KEY"],
8
+ },
9
+ {
10
+ name: "filesystem",
11
+ description: "File system operations for AI agents (Anthropic)",
12
+ command: "npx",
13
+ args: ["-y", "@anthropic/mcp-filesystem"],
14
+ },
15
+ {
16
+ name: "memory",
17
+ description: "Persistent key-value memory for agents (Anthropic)",
18
+ command: "npx",
19
+ args: ["-y", "@anthropic/mcp-memory"],
20
+ },
21
+ ];
22
+ export function getServerDef(name) {
23
+ return MCP_REGISTRY.find((s) => s.name === name);
24
+ }
25
+ export function listRegistry() {
26
+ return [...MCP_REGISTRY];
27
+ }
@@ -0,0 +1,4 @@
1
+ import { GitHubProvider } from "./github.js";
2
+ export declare class AnthropicsProvider extends GitHubProvider {
3
+ constructor();
4
+ }
@@ -0,0 +1,10 @@
1
+ import { GitHubProvider } from "./github.js";
2
+ export class AnthropicsProvider extends GitHubProvider {
3
+ constructor() {
4
+ super("anthropics", "skills", {
5
+ name: "anthropics",
6
+ displayName: "Anthropic Official",
7
+ branch: "main",
8
+ });
9
+ }
10
+ }
package/dist/registry.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ArcanaProvider } from "./providers/arcana.js";
2
+ import { AnthropicsProvider } from "./providers/anthropics.js";
2
3
  import { GitHubProvider, validateSlug } from "./providers/github.js";
3
4
  import { loadConfig } from "./utils/config.js";
4
5
  import { errorAndExit } from "./utils/ui.js";
@@ -21,6 +22,9 @@ function createProvider(name, type, url) {
21
22
  if (name === "arcana") {
22
23
  provider = new ArcanaProvider();
23
24
  }
25
+ else if (name === "anthropics") {
26
+ provider = new AnthropicsProvider();
27
+ }
24
28
  else if (type === "github") {
25
29
  const { owner, repo } = parseProviderSlug(url);
26
30
  try {
@@ -0,0 +1,23 @@
1
+ export interface TrimResult {
2
+ originalLines: number;
3
+ trimmedLines: number;
4
+ originalBytes: number;
5
+ trimmedBytes: number;
6
+ savedBytes: number;
7
+ savedPct: number;
8
+ toolResultsTrimmed: number;
9
+ base64Removed: number;
10
+ }
11
+ /**
12
+ * Analyze a session JSONL for trimmable content.
13
+ * Returns stats without modifying the file.
14
+ */
15
+ export declare function analyzeSession(filePath: string): TrimResult;
16
+ /**
17
+ * Trim a session JSONL and write trimmed copy.
18
+ * NEVER modifies the original file.
19
+ */
20
+ export declare function trimSession(filePath: string): {
21
+ destPath: string;
22
+ result: TrimResult;
23
+ } | null;
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { atomicWriteSync } from "../utils/atomic.js";
5
+ /** Threshold for tool result bodies (chars). Results larger than this get stubbed. */
6
+ const RESULT_THRESHOLD = 500;
7
+ /**
8
+ * Analyze a session JSONL for trimmable content.
9
+ * Returns stats without modifying the file.
10
+ */
11
+ export function analyzeSession(filePath) {
12
+ if (!existsSync(filePath)) {
13
+ return {
14
+ originalLines: 0,
15
+ trimmedLines: 0,
16
+ originalBytes: 0,
17
+ trimmedBytes: 0,
18
+ savedBytes: 0,
19
+ savedPct: 0,
20
+ toolResultsTrimmed: 0,
21
+ base64Removed: 0,
22
+ };
23
+ }
24
+ const content = readFileSync(filePath, "utf-8");
25
+ const lines = content.split("\n").filter((l) => l.trim());
26
+ let toolResultsTrimmed = 0;
27
+ let base64Removed = 0;
28
+ let trimmedSize = 0;
29
+ for (const line of lines) {
30
+ try {
31
+ const msg = JSON.parse(line);
32
+ const role = msg.role;
33
+ // Tool results: stub if too large
34
+ if (role === "tool" || msg.type === "tool_result") {
35
+ const content = JSON.stringify(msg);
36
+ if (content.length > RESULT_THRESHOLD) {
37
+ toolResultsTrimmed++;
38
+ trimmedSize += 100; // stub size
39
+ continue;
40
+ }
41
+ }
42
+ // Base64 encoded content
43
+ const lineStr = JSON.stringify(msg);
44
+ if (lineStr.includes("base64,") || lineStr.includes('"type":"image"')) {
45
+ base64Removed++;
46
+ trimmedSize += 50; // stub size
47
+ continue;
48
+ }
49
+ trimmedSize += line.length;
50
+ }
51
+ catch {
52
+ trimmedSize += line.length;
53
+ }
54
+ }
55
+ const originalBytes = content.length;
56
+ const savedBytes = originalBytes - trimmedSize;
57
+ const savedPct = originalBytes > 0 ? Math.round((savedBytes / originalBytes) * 100) : 0;
58
+ return {
59
+ originalLines: lines.length,
60
+ trimmedLines: lines.length - toolResultsTrimmed - base64Removed,
61
+ originalBytes,
62
+ trimmedBytes: trimmedSize,
63
+ savedBytes,
64
+ savedPct,
65
+ toolResultsTrimmed,
66
+ base64Removed,
67
+ };
68
+ }
69
+ /**
70
+ * Trim a session JSONL and write trimmed copy.
71
+ * NEVER modifies the original file.
72
+ */
73
+ export function trimSession(filePath) {
74
+ if (!existsSync(filePath))
75
+ return null;
76
+ const content = readFileSync(filePath, "utf-8");
77
+ const lines = content.split("\n").filter((l) => l.trim());
78
+ const trimmedLines = [];
79
+ let toolResultsTrimmed = 0;
80
+ let base64Removed = 0;
81
+ for (const line of lines) {
82
+ try {
83
+ const msg = JSON.parse(line);
84
+ const role = msg.role;
85
+ // Tool results: stub if too large
86
+ if (role === "tool" || msg.type === "tool_result") {
87
+ const msgStr = JSON.stringify(msg);
88
+ if (msgStr.length > RESULT_THRESHOLD) {
89
+ toolResultsTrimmed++;
90
+ // Replace with stub preserving structure
91
+ const stubbed = { ...msg, content: `[trimmed: ${Math.round(msgStr.length / 1024)}KB]` };
92
+ trimmedLines.push(JSON.stringify(stubbed));
93
+ continue;
94
+ }
95
+ }
96
+ // Base64 encoded content
97
+ const lineStr = JSON.stringify(msg);
98
+ if (lineStr.includes("base64,") || lineStr.includes('"type":"image"')) {
99
+ base64Removed++;
100
+ const stubbed = { ...msg, content: "[base64 image removed]" };
101
+ trimmedLines.push(JSON.stringify(stubbed));
102
+ continue;
103
+ }
104
+ trimmedLines.push(line);
105
+ }
106
+ catch {
107
+ trimmedLines.push(line);
108
+ }
109
+ }
110
+ // Write to ~/.arcana/trimmed/
111
+ const trimmedDir = join(homedir(), ".arcana", "trimmed");
112
+ if (!existsSync(trimmedDir))
113
+ mkdirSync(trimmedDir, { recursive: true });
114
+ const trimmedContent = trimmedLines.join("\n") + "\n";
115
+ const destPath = join(trimmedDir, `trimmed-${Date.now()}.jsonl`);
116
+ atomicWriteSync(destPath, trimmedContent);
117
+ const originalBytes = content.length;
118
+ const trimmedBytes = trimmedContent.length;
119
+ return {
120
+ destPath,
121
+ result: {
122
+ originalLines: lines.length,
123
+ trimmedLines: trimmedLines.length,
124
+ originalBytes,
125
+ trimmedBytes,
126
+ savedBytes: originalBytes - trimmedBytes,
127
+ savedPct: originalBytes > 0 ? Math.round(((originalBytes - trimmedBytes) / originalBytes) * 100) : 0,
128
+ toolResultsTrimmed,
129
+ base64Removed,
130
+ },
131
+ };
132
+ }
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, statSync } from "node:
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { atomicWriteSync } from "./atomic.js";
5
+ import { CACHE_MAX_AGE_MS } from "../constants.js";
5
6
  const CACHE_DIR = join(homedir(), ".arcana", "cache");
6
- const DEFAULT_TTL = 60 * 60 * 1000; // 1 hour
7
7
  function ensureCacheDir() {
8
8
  if (!existsSync(CACHE_DIR)) {
9
9
  mkdirSync(CACHE_DIR, { recursive: true });
@@ -12,7 +12,7 @@ function ensureCacheDir() {
12
12
  function cacheFile(key) {
13
13
  return join(CACHE_DIR, `${key}.json`);
14
14
  }
15
- export function readCache(key, maxAgeMs = DEFAULT_TTL) {
15
+ export function readCache(key, maxAgeMs = CACHE_MAX_AGE_MS) {
16
16
  const file = cacheFile(key);
17
17
  if (!existsSync(file))
18
18
  return null;
@@ -3,5 +3,7 @@ import type { ArcanaConfig, ProviderConfig } from "../types.js";
3
3
  export declare function validateConfig(config: ArcanaConfig): string[];
4
4
  export declare function loadConfig(): ArcanaConfig;
5
5
  export declare function saveConfig(config: ArcanaConfig): void;
6
+ /** Clear the config cache. Call from tests to ensure isolated state. */
7
+ export declare function clearConfigCache(): void;
6
8
  export declare function addProvider(provider: ProviderConfig): void;
7
9
  export declare function removeProvider(name: string): boolean;
@@ -4,6 +4,8 @@ import { homedir } from "node:os";
4
4
  import { ui } from "./ui.js";
5
5
  import { atomicWriteSync } from "./atomic.js";
6
6
  const CONFIG_PATH = join(homedir(), ".arcana", "config.json");
7
+ /** Module-level config cache. Avoids repeated disk reads during a single CLI invocation. */
8
+ let _cache = null;
7
9
  /** Matches owner/repo slug format (e.g. "medy-gribkov/arcana") */
8
10
  const SLUG_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
9
11
  const DEFAULT_CONFIG = {
@@ -16,6 +18,12 @@ const DEFAULT_CONFIG = {
16
18
  url: "medy-gribkov/arcana",
17
19
  enabled: true,
18
20
  },
21
+ {
22
+ name: "anthropics",
23
+ type: "github",
24
+ url: "anthropics/skills",
25
+ enabled: true,
26
+ },
19
27
  ],
20
28
  };
21
29
  function cloneConfig(config) {
@@ -42,23 +50,29 @@ export function validateConfig(config) {
42
50
  return warnings;
43
51
  }
44
52
  export function loadConfig() {
53
+ if (_cache)
54
+ return cloneConfig(_cache);
55
+ let config;
45
56
  if (!existsSync(CONFIG_PATH)) {
46
- return applyEnvOverrides(cloneConfig(DEFAULT_CONFIG));
47
- }
48
- try {
49
- const raw = readFileSync(CONFIG_PATH, "utf-8");
50
- const loaded = JSON.parse(raw);
51
- const config = {
52
- ...DEFAULT_CONFIG,
53
- ...loaded,
54
- providers: loaded.providers ?? DEFAULT_CONFIG.providers.map((p) => ({ ...p })),
55
- };
56
- return applyEnvOverrides(config);
57
+ config = applyEnvOverrides(cloneConfig(DEFAULT_CONFIG));
57
58
  }
58
- catch {
59
- console.error(ui.warn(" Warning: Config file is corrupted, using defaults"));
60
- return applyEnvOverrides(cloneConfig(DEFAULT_CONFIG));
59
+ else {
60
+ try {
61
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
62
+ const loaded = JSON.parse(raw);
63
+ config = applyEnvOverrides({
64
+ ...DEFAULT_CONFIG,
65
+ ...loaded,
66
+ providers: loaded.providers ?? DEFAULT_CONFIG.providers.map((p) => ({ ...p })),
67
+ });
68
+ }
69
+ catch {
70
+ console.error(ui.warn(" Warning: Config file is corrupted, using defaults"));
71
+ config = applyEnvOverrides(cloneConfig(DEFAULT_CONFIG));
72
+ }
61
73
  }
74
+ _cache = config;
75
+ return cloneConfig(config);
62
76
  }
63
77
  function applyEnvOverrides(base) {
64
78
  const config = { ...base, providers: base.providers };
@@ -92,6 +106,11 @@ export function saveConfig(config) {
92
106
  mkdirSync(dir, { recursive: true });
93
107
  }
94
108
  atomicWriteSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", 0o600);
109
+ _cache = null; // Invalidate cache on write
110
+ }
111
+ /** Clear the config cache. Call from tests to ensure isolated state. */
112
+ export function clearConfigCache() {
113
+ _cache = null;
95
114
  }
96
115
  export function addProvider(provider) {
97
116
  const config = loadConfig();
@@ -4,7 +4,8 @@ import { homedir } from "node:os";
4
4
  import * as p from "@clack/prompts";
5
5
  import chalk from "chalk";
6
6
  import { ui } from "./ui.js";
7
- import { getGroupedCommands } from "../command-registry.js";
7
+ import { getGroupedCommands } from "../command-defs.js";
8
+ /* v8 ignore start */
8
9
  const noColor = !!(process.env.NO_COLOR || process.env.TERM === "dumb");
9
10
  function amberShade(hex, text) {
10
11
  if (noColor)
@@ -33,27 +34,31 @@ export function renderBanner() {
33
34
  }
34
35
  return BANNER_LINES.map((line, i) => ` ${amberShade(AMBER_HEXES[i], line)}`).join("\n");
35
36
  }
37
+ /* v8 ignore stop */
36
38
  // Help groups: subset of registry for --help display (keeps output scannable)
37
39
  const HELP_GROUPS = {
38
40
  "GETTING STARTED": ["init", "doctor"],
39
- SKILLS: ["list", "search", "info", "install", "update", "uninstall", "recommend"],
40
- DEVELOPMENT: ["create", "validate", "audit"],
41
- CONFIGURATION: ["config", "providers", "clean", "stats"],
41
+ SKILLS: ["list", "search", "install", "update", "uninstall", "recommend"],
42
+ "CONTEXT INTELLIGENCE": ["curate", "compress", "remember", "recall", "mcp"],
43
+ SECURITY: ["scan", "verify", "lock"],
44
+ CONFIGURATION: ["config", "providers", "clean"],
42
45
  };
43
46
  const EXAMPLES = [
44
- "$ arcana install code-reviewer typescript golang",
45
- '$ arcana search "testing"',
46
- "$ arcana init --tool claude",
47
+ "$ arcana install --all && arcana curate",
48
+ '$ arcana remember "always use pnpm"',
49
+ "$ arcana compress git status --json",
50
+ "$ arcana init",
47
51
  ];
48
52
  function padRight(str, width) {
49
53
  return str + " ".repeat(Math.max(0, width - str.length));
50
54
  }
55
+ /* v8 ignore start */
51
56
  export function buildCustomHelp(version) {
52
57
  const lines = [];
53
58
  lines.push("");
54
59
  lines.push(renderBanner());
55
60
  lines.push("");
56
- lines.push(` ${ui.bold("Supercharge any AI coding agent.")}${" ".repeat(20)}${ui.dim(`v${version}`)}`);
61
+ lines.push(` ${ui.bold("Context intelligence for AI coding agents.")}${" ".repeat(11)}${ui.dim(`v${version}`)}`);
57
62
  lines.push("");
58
63
  lines.push(` ${ui.dim("USAGE")}`);
59
64
  lines.push(" arcana <command> [options]");
@@ -81,6 +86,7 @@ export function buildCustomHelp(version) {
81
86
  lines.push("");
82
87
  return lines.join("\n");
83
88
  }
89
+ /* v8 ignore stop */
84
90
  const FIRST_RUN_FLAG = join(homedir(), ".arcana", ".initialized");
85
91
  export function isFirstRun() {
86
92
  return !existsSync(FIRST_RUN_FLAG);
@@ -92,6 +98,7 @@ export function markInitialized() {
92
98
  }
93
99
  writeFileSync(FIRST_RUN_FLAG, new Date().toISOString(), "utf-8");
94
100
  }
101
+ /* v8 ignore start */
95
102
  export function showWelcome(version) {
96
103
  console.log();
97
104
  console.log(renderBanner());
@@ -102,3 +109,4 @@ export function showWelcome(version) {
102
109
  p.log.info("They install on-demand and only load when relevant, not all at once.");
103
110
  console.log();
104
111
  }
112
+ /* v8 ignore stop */
@@ -4,7 +4,7 @@ import { scanSkillContent } from "./scanner.js";
4
4
  import { updateLockEntry } from "./integrity.js";
5
5
  import { checkConflicts } from "./conflict-check.js";
6
6
  import { detectProjectContext } from "./project-context.js";
7
- import { LARGE_SKILL_KB_THRESHOLD, TOKENS_PER_KB } from "../constants.js";
7
+ import { LARGE_SKILL_KB_THRESHOLD, TOKENS_PER_KB, SKILL_NAME_REGEX } from "../constants.js";
8
8
  /** Scan fetched files for security threats. Returns true if install should proceed. */
9
9
  export function preInstallScan(_skillName, files, force) {
10
10
  const skillMd = files.find((f) => f.path.endsWith("SKILL.md"));
@@ -41,6 +41,13 @@ export function preInstallConflictCheck(skillName, remote, files, force) {
41
41
  * fetch -> security scan -> conflict check -> write files -> write meta -> update lock
42
42
  */
43
43
  export async function installOneCore(skillName, provider, opts) {
44
+ if (!SKILL_NAME_REGEX.test(skillName)) {
45
+ return {
46
+ success: false,
47
+ skillName,
48
+ error: `Invalid skill name "${skillName}". Must match: lowercase alphanumeric with hyphens, 1-64 chars.`,
49
+ };
50
+ }
44
51
  const files = await provider.fetch(skillName);
45
52
  // Security scan
46
53
  const scan = preInstallScan(skillName, files, opts.force);
@@ -81,6 +88,21 @@ export async function installOneCore(skillName, provider, opts) {
81
88
  sizeBytes,
82
89
  });
83
90
  updateLockEntry(skillName, version, provider.name, files);
91
+ // Auto-regenerate skill index and active curation
92
+ try {
93
+ const { regenerateIndex } = await import("../commands/index.js");
94
+ regenerateIndex();
95
+ }
96
+ catch {
97
+ /* non-critical, index regeneration is best-effort */
98
+ }
99
+ try {
100
+ const { regenerateActive } = await import("../commands/curate.js");
101
+ regenerateActive();
102
+ }
103
+ catch {
104
+ /* non-critical, active curation is best-effort */
105
+ }
84
106
  const sizeKB = sizeBytes / 1024;
85
107
  return { success: true, skillName, files, sizeKB, conflictWarnings };
86
108
  }
@@ -0,0 +1,25 @@
1
+ export interface Memory {
2
+ id: string;
3
+ content: string;
4
+ tags: string[];
5
+ project?: string;
6
+ created: string;
7
+ }
8
+ /** Add a memory. Extracts tags from content if not provided. */
9
+ export declare function addMemory(content: string, opts?: {
10
+ tags?: string[];
11
+ project?: string;
12
+ }): Memory;
13
+ /** Search memories by query (substring + tag match). */
14
+ export declare function searchMemories(query: string, opts?: {
15
+ project?: string;
16
+ }): Memory[];
17
+ /** List all memories, optionally filtered by project. */
18
+ export declare function listMemories(opts?: {
19
+ project?: string;
20
+ limit?: number;
21
+ }): Memory[];
22
+ /** Remove a memory by ID. */
23
+ export declare function removeMemory(id: string): boolean;
24
+ /** Get memories relevant to the current project for injection into _active.md */
25
+ export declare function getProjectMemories(project?: string): Memory[];
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
2
+ import { join, basename } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { atomicWriteSync } from "./atomic.js";
5
+ import { randomBytes } from "node:crypto";
6
+ const MAX_MEMORIES = 200;
7
+ function memoriesPath() {
8
+ return join(homedir(), ".arcana", "memories.json");
9
+ }
10
+ function readMemories() {
11
+ const p = memoriesPath();
12
+ if (!existsSync(p))
13
+ return [];
14
+ try {
15
+ const data = JSON.parse(readFileSync(p, "utf-8"));
16
+ return Array.isArray(data) ? data : [];
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ }
22
+ function writeMemories(memories) {
23
+ const dir = join(homedir(), ".arcana");
24
+ if (!existsSync(dir))
25
+ mkdirSync(dir, { recursive: true });
26
+ atomicWriteSync(memoriesPath(), JSON.stringify(memories, null, 2));
27
+ }
28
+ function generateId() {
29
+ return randomBytes(4).toString("hex");
30
+ }
31
+ /** Add a memory. Extracts tags from content if not provided. */
32
+ export function addMemory(content, opts) {
33
+ const memories = readMemories();
34
+ const tags = opts?.tags ?? [];
35
+ // Auto-extract simple tags from content if none provided
36
+ if (tags.length === 0) {
37
+ const words = content.toLowerCase().split(/\s+/);
38
+ const keywords = words.filter((w) => w.length > 3 &&
39
+ !["always", "never", "should", "this", "that", "with", "from", "have", "will", "when", "then", "than"].includes(w));
40
+ tags.push(...keywords.slice(0, 3));
41
+ }
42
+ const project = opts?.project ?? basename(process.cwd());
43
+ const memory = {
44
+ id: generateId(),
45
+ content,
46
+ tags,
47
+ project,
48
+ created: new Date().toISOString(),
49
+ };
50
+ memories.push(memory);
51
+ // Cap at max
52
+ while (memories.length > MAX_MEMORIES)
53
+ memories.shift();
54
+ writeMemories(memories);
55
+ return memory;
56
+ }
57
+ /** Search memories by query (substring + tag match). */
58
+ export function searchMemories(query, opts) {
59
+ const memories = readMemories();
60
+ const q = query.toLowerCase();
61
+ return memories
62
+ .filter((m) => {
63
+ // Project filter
64
+ if (opts?.project && m.project !== opts.project)
65
+ return false;
66
+ // Content match
67
+ if (m.content.toLowerCase().includes(q))
68
+ return true;
69
+ // Tag match
70
+ if (m.tags.some((t) => t.toLowerCase().includes(q)))
71
+ return true;
72
+ return false;
73
+ })
74
+ .sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
75
+ }
76
+ /** List all memories, optionally filtered by project. */
77
+ export function listMemories(opts) {
78
+ const memories = readMemories();
79
+ let filtered = memories;
80
+ if (opts?.project) {
81
+ filtered = filtered.filter((m) => m.project === opts.project);
82
+ }
83
+ filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
84
+ if (opts?.limit) {
85
+ filtered = filtered.slice(0, opts.limit);
86
+ }
87
+ return filtered;
88
+ }
89
+ /** Remove a memory by ID. */
90
+ export function removeMemory(id) {
91
+ const memories = readMemories();
92
+ const idx = memories.findIndex((m) => m.id === id);
93
+ if (idx === -1)
94
+ return false;
95
+ memories.splice(idx, 1);
96
+ writeMemories(memories);
97
+ return true;
98
+ }
99
+ /** Get memories relevant to the current project for injection into _active.md */
100
+ export function getProjectMemories(project) {
101
+ const proj = project ?? basename(process.cwd());
102
+ return listMemories({ project: proj, limit: 10 });
103
+ }
@@ -129,6 +129,10 @@ function extractNpmTags(cwd) {
129
129
  if (existsSync(join(cwd, "tsconfig.json")) || allDeps.typescript) {
130
130
  tags.add("typescript");
131
131
  }
132
+ // Detect package manager
133
+ if (existsSync(join(cwd, "pnpm-lock.yaml")) || existsSync(join(cwd, "pnpm-workspace.yaml"))) {
134
+ tags.add("pnpm");
135
+ }
132
136
  return [...tags];
133
137
  }
134
138
  function extractGoTags(cwd) {
@@ -11,11 +11,32 @@ export interface ScanIssue {
11
11
  line: number;
12
12
  context: string;
13
13
  }
14
+ export interface ScanOptions {
15
+ /** When true, scan all lines including BAD/DON'T blocks (no scope filtering). */
16
+ strict?: boolean;
17
+ }
18
+ /**
19
+ * Scan SKILL.md content for security threats.
20
+ * Returns an array of issues sorted by severity (critical first).
21
+ * By default, findings inside BAD/DON'T example blocks are suppressed.
22
+ * Use strict mode to scan everything.
23
+ */
24
+ export interface ScanResult {
25
+ issues: ScanIssue[];
26
+ suppressed: ScanIssue[];
27
+ }
28
+ /**
29
+ * Scan with full result including suppressed findings.
30
+ * Used by scan command when --verbose is needed.
31
+ */
32
+ export declare function scanSkillContentFull(content: string, options?: ScanOptions): ScanResult;
14
33
  /**
15
34
  * Scan SKILL.md content for security threats.
16
35
  * Returns an array of issues sorted by severity (critical first).
36
+ * By default, findings inside BAD/DON'T example blocks are suppressed.
37
+ * Use strict mode to scan everything.
17
38
  */
18
- export declare function scanSkillContent(content: string): ScanIssue[];
39
+ export declare function scanSkillContent(content: string, options?: ScanOptions): ScanIssue[];
19
40
  /**
20
41
  * Quick check: does this content have any critical issues?
21
42
  */