javi-forge 1.26.0 → 1.28.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 (46) hide show
  1. package/ci-local/hooks/commit-msg +7 -0
  2. package/ci-local/hooks/pre-commit +8 -0
  3. package/ci-local/hooks/pre-push +8 -0
  4. package/dist/cli/dispatch/ci.js +1 -1
  5. package/dist/cli/dispatch/simple-renderers.js +1 -1
  6. package/dist/cli/dispatch/skills-cmd.js +9 -1
  7. package/dist/cli/help.d.ts +1 -1
  8. package/dist/cli/help.js +12 -0
  9. package/dist/commands/ci.js +5 -1
  10. package/dist/commands/doctor.js +9 -0
  11. package/dist/commands/init/steps/ghagga.d.ts +3 -4
  12. package/dist/commands/init/steps/ghagga.js +5 -15
  13. package/dist/commands/plugin.d.ts +4 -2
  14. package/dist/commands/plugin.js +4 -4
  15. package/dist/commands/skills/analysis.js +31 -2
  16. package/dist/commands/skills/benchmark.js +10 -0
  17. package/dist/commands/skills/constants.d.ts +5 -0
  18. package/dist/commands/skills/constants.js +5 -0
  19. package/dist/commands/skills/parsing.d.ts +18 -3
  20. package/dist/commands/skills/parsing.js +29 -3
  21. package/dist/commands/skills/scoring.d.ts +7 -6
  22. package/dist/commands/skills/scoring.js +31 -1
  23. package/dist/lib/agent-skills.d.ts +1 -0
  24. package/dist/lib/agent-skills.js +155 -1
  25. package/dist/lib/auto-skill-install.d.ts +5 -0
  26. package/dist/lib/auto-skill-install.js +40 -2
  27. package/dist/lib/context.d.ts +22 -0
  28. package/dist/lib/context.js +120 -79
  29. package/dist/lib/plugin.d.ts +1 -0
  30. package/dist/lib/plugin.js +58 -1
  31. package/dist/lib/safe-read.d.ts +62 -0
  32. package/dist/lib/safe-read.js +221 -0
  33. package/dist/lib/security-analysis.d.ts +19 -2
  34. package/dist/lib/security-analysis.js +65 -13
  35. package/dist/lib/skill-install-gate.d.ts +31 -0
  36. package/dist/lib/skill-install-gate.js +30 -0
  37. package/dist/lib/skill-scanner.d.ts +65 -1
  38. package/dist/lib/skill-scanner.js +307 -4
  39. package/dist/types/index.d.ts +18 -0
  40. package/dist/ui/AutoSkills.d.ts +3 -1
  41. package/dist/ui/AutoSkills.js +17 -2
  42. package/dist/ui/Plugin.d.ts +3 -1
  43. package/dist/ui/Plugin.js +4 -4
  44. package/dist/ui/Skills.js +12 -7
  45. package/package.json +9 -5
  46. package/templates/github/ghagga-review.yml +0 -30
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { AGENT_SKILLS_MANIFEST_FILE, PLUGIN_MANIFEST_FILE, PLUGINS_DIR, } from "../constants.js";
4
+ import { evaluateInstallGate } from "./skill-install-gate.js";
5
+ import { formatBatchReport, scanSkillsWithCoverage } from "./skill-scanner.js";
4
6
  // ── Conversion ─────────────────────────────────────────────────────────────
5
7
  /**
6
8
  * Convert a javi-forge PluginManifest to an Agent Skills spec manifest.
@@ -68,7 +70,7 @@ export async function exportPluginAsAgentSkills(name) {
68
70
  * Reads skills.json, converts to plugin.json, copies to plugins dir.
69
71
  */
70
72
  export async function importAgentSkillsPackage(sourceDir, options = {}) {
71
- const { dryRun = false } = options;
73
+ const { dryRun = false, force = false } = options;
72
74
  const skillsPath = path.join(sourceDir, AGENT_SKILLS_MANIFEST_FILE);
73
75
  if (!(await fs.pathExists(skillsPath))) {
74
76
  return { success: false, error: "skills.json not found" };
@@ -88,11 +90,130 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
88
90
  error: "skills.json missing required fields (name, version, description)",
89
91
  };
90
92
  }
93
+ // R1-002: `name` becomes the import destination (`destDir = path.join(
94
+ // PLUGINS_DIR, name)`) and is handed to `fs.remove` + `fs.copy` below with
95
+ // NO path validation — a hostile skills.json name (e.g. `"../../.bashrc"`,
96
+ // an absolute path, or a separator-bearing name) would delete/copy
97
+ // ARBITRARY paths outside PLUGINS_DIR. The gate validates declared
98
+ // `skills[].path` escapes but never the name that determines the
99
+ // write/delete destination. Same-trust note: `name` is attacker-influenced
100
+ // when installing from a registry package, not a typed-in label — refuse
101
+ // BEFORE any `fs.remove`/`fs.copy`/`destDir` use, so an existing install
102
+ // is preserved (style-consistent with the gate's manifest-integrity
103
+ // refusals, block-level, force never lifts).
91
104
  const pluginName = agentManifest.name;
105
+ // R1-F2-N2: `name` is attacker-influenced JSON — it need not be a string at
106
+ // all (`{"name": 123}` is truthy, so it sails past the required-fields
107
+ // check above). Guard the type BEFORE the `.trim()` check: a non-string
108
+ // name refuses cleanly with the same manifest-integrity message instead of
109
+ // throwing a TypeError out of `.trim()` (which propagated to the UI as a
110
+ // "Fatal error").
111
+ if (typeof pluginName !== "string") {
112
+ return {
113
+ success: false,
114
+ error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
115
+ };
116
+ }
117
+ if (pluginName.trim() === "" ||
118
+ pluginName === "." ||
119
+ pluginName.includes("..") ||
120
+ pluginName.includes("/") ||
121
+ pluginName.includes("\\") ||
122
+ path.isAbsolute(pluginName)) {
123
+ return {
124
+ success: false,
125
+ error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
126
+ };
127
+ }
128
+ // JD-006: import requires a non-empty, well-formed skills array. Each entry
129
+ // must carry a `name` and a `path` that resolves INSIDE sourceDir (normalized
130
+ // + realpath containment — "../../x" or an absolute path refuses; the gate
131
+ // never reads outside the staged clone, JD-003).
132
+ if (!Array.isArray(agentManifest.skills) ||
133
+ agentManifest.skills.length === 0) {
134
+ return {
135
+ success: false,
136
+ error: "skills.json must declare a non-empty skills array (every skill-shaped file must be declared)",
137
+ };
138
+ }
139
+ const sourceRootAbs = path.resolve(sourceDir);
140
+ const sourceRootReal = await fs.realpath(sourceRootAbs);
141
+ const declaredPaths = [];
142
+ for (const entry of agentManifest.skills) {
143
+ if (!entry || typeof entry.name !== "string" || !entry.name) {
144
+ return {
145
+ success: false,
146
+ error: "skills.json skills entry missing name",
147
+ };
148
+ }
149
+ if (typeof entry.path !== "string" || !entry.path) {
150
+ return {
151
+ success: false,
152
+ error: `skills.json skills entry "${entry.name}" missing path`,
153
+ };
154
+ }
155
+ const contained = await skillPathContained(sourceRootAbs, sourceRootReal, entry.path);
156
+ if (!contained.ok) {
157
+ return {
158
+ success: false,
159
+ error: `skills.json skills entry "${entry.name}" path escapes package root (${contained.reason}) — refusing`,
160
+ };
161
+ }
162
+ declaredPaths.push(entry.path);
163
+ }
92
164
  if (dryRun) {
93
165
  return { success: true, name: pluginName };
94
166
  }
95
167
  const destDir = path.join(PLUGINS_DIR, pluginName);
168
+ // ── SkillGuard runtime gate (D8, JD-001/JD-003/JD-006/JD-007) ──────────
169
+ // Runs BEFORE the existing-install remove and fs.copy: a refusal preserves
170
+ // an existing install and installs nothing. dryRun early-returns above, so
171
+ // no scan happens on dry-run. Scanner/eval errors deny unconditionally (D7).
172
+ try {
173
+ const coverage = await scanSkillsWithCoverage(sourceDir, declaredPaths);
174
+ // Manifest-integrity refusals — block-level, force NEVER lifts
175
+ // (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl. node_modules).
176
+ // A walk with I/O errors cannot certify the copied footprint — refuse
177
+ // first, because the broken subtree may hide symlinks or undeclared
178
+ // files (JD-013).
179
+ if (coverage.errors.length > 0) {
180
+ return {
181
+ success: false,
182
+ error: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
183
+ };
184
+ }
185
+ if (coverage.symlinks.length > 0) {
186
+ return {
187
+ success: false,
188
+ error: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
189
+ };
190
+ }
191
+ if (coverage.undeclared.length > 0) {
192
+ return {
193
+ success: false,
194
+ error: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
195
+ };
196
+ }
197
+ const gate = evaluateInstallGate(coverage.declared, { force });
198
+ if (!gate.allowed) {
199
+ const blocked = gate.rejected.filter((r) => r.verdict === "block").length;
200
+ const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
201
+ return {
202
+ success: false,
203
+ // Lead line names the rejected count; the batch report renders
204
+ // the FULL declared set so the header/rows reflect every scanned
205
+ // skill (D6, JD-014).
206
+ error: `skillguard: install refused — ${gate.rejected.length} rejected (${blocked} blocked, ${unscannable} unscannable)\n${formatBatchReport(coverage.declared)}`,
207
+ };
208
+ }
209
+ }
210
+ catch (scanError) {
211
+ const msg = scanError instanceof Error ? scanError.message : String(scanError);
212
+ return {
213
+ success: false,
214
+ error: `skillguard scan failed — ${msg}`,
215
+ };
216
+ }
96
217
  // Remove existing version if present
97
218
  if (await fs.pathExists(destDir)) {
98
219
  await fs.remove(destDir);
@@ -117,6 +238,39 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
117
238
  });
118
239
  return { success: true, name: pluginName };
119
240
  }
241
+ /**
242
+ * Verify a declared skill entry path stays inside the package root — both
243
+ * lexically (`../../x`, absolute paths) and by realpath, so an in-tree symlink
244
+ * cannot redirect the import read outside the staged clone (JD-003/JD-006).
245
+ * Realpath resolution is best-effort: a missing declared dir has no realpath
246
+ * yet, in which case lexical containment is the whole guard (the coverage walk
247
+ * will later report it as a missing/unscannable declared skill).
248
+ */
249
+ async function skillPathContained(rootAbs, rootReal, entryPath) {
250
+ const entryAbs = path.resolve(rootAbs, entryPath);
251
+ const rel = path.relative(rootAbs, entryAbs);
252
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
253
+ return {
254
+ ok: false,
255
+ reason: `path "${entryPath}" resolves outside the package root`,
256
+ };
257
+ }
258
+ try {
259
+ const entryReal = await fs.realpath(entryAbs);
260
+ const relReal = path.relative(rootReal, entryReal);
261
+ if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
262
+ return {
263
+ ok: false,
264
+ reason: `path "${entryPath}" resolves outside the package root (realpath)`,
265
+ };
266
+ }
267
+ }
268
+ catch {
269
+ // Declared dir does not exist yet — lexical containment stands; the
270
+ // coverage walk reports it as a missing declared skill later.
271
+ }
272
+ return { ok: true };
273
+ }
120
274
  // ── Aggregation ──────────────────────────────────────────────────────────
121
275
  /**
122
276
  * Aggregate multiple installed plugins into a single Agent Skills spec manifest.
@@ -1,3 +1,4 @@
1
+ import type { SkillScanResult } from "./skill-scanner.js";
1
2
  import type { StackDetectionResult } from "./stack-detector.js";
2
3
  export interface SkillInstallResult {
3
4
  /** Skills that were successfully installed (copied) */
@@ -6,6 +7,8 @@ export interface SkillInstallResult {
6
7
  skipped: string[];
7
8
  /** Skills that were recommended but not found in the source */
8
9
  notFound: string[];
10
+ /** Skills refused by the skillguard gate (block / unscannable w/o force) */
11
+ blocked: SkillScanResult[];
9
12
  /** The full detection result for reporting */
10
13
  detection: StackDetectionResult;
11
14
  }
@@ -18,6 +21,8 @@ export interface AutoInstallOptions {
18
21
  skillsTargetDir?: string;
19
22
  /** If true, skip actually copying files */
20
23
  dryRun?: boolean;
24
+ /** Bypass the gate for unscannable sources ONLY — block always refuses (D2) */
25
+ force?: boolean;
21
26
  }
22
27
  /**
23
28
  * Scan a project, detect its stack, and install matching AI skills.
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { DEFAULT_SKILLS_DIR } from "../commands/skills/constants.js";
4
+ import { evaluateInstallGate } from "./skill-install-gate.js";
5
+ import { scanSkillFile } from "./skill-scanner.js";
4
6
  import { detectProjectStack } from "./stack-detector.js";
5
7
  // ── Core ───────────────────────────────────────────────────────────────────
6
8
  /**
@@ -14,7 +16,7 @@ import { detectProjectStack } from "./stack-detector.js";
14
16
  * source to target (useful for project-local skill installation).
15
17
  */
16
18
  export async function autoInstallSkills(options) {
17
- const { projectDir, skillsSourceDir = DEFAULT_SKILLS_DIR, skillsTargetDir = DEFAULT_SKILLS_DIR, dryRun = false, } = options;
19
+ const { projectDir, skillsSourceDir = DEFAULT_SKILLS_DIR, skillsTargetDir = DEFAULT_SKILLS_DIR, dryRun = false, force = false, } = options;
18
20
  // 1. Detect project stack
19
21
  const detection = await detectProjectStack(projectDir);
20
22
  if (detection.recommendedSkills.length === 0) {
@@ -22,6 +24,7 @@ export async function autoInstallSkills(options) {
22
24
  installed: [],
23
25
  skipped: [],
24
26
  notFound: [],
27
+ blocked: [],
25
28
  detection,
26
29
  };
27
30
  }
@@ -29,6 +32,10 @@ export async function autoInstallSkills(options) {
29
32
  const skipped = [];
30
33
  const notFound = [];
31
34
  const sameDir = path.resolve(skillsSourceDir) === path.resolve(skillsTargetDir);
35
+ // 2. Classify: notFound / sameDir-skipped / target-skipped / copyable.
36
+ // Predicates unchanged from pre-gate behavior; sameDir short-circuits
37
+ // BEFORE any scanning (D4).
38
+ const copyable = [];
32
39
  for (const skillName of detection.recommendedSkills) {
33
40
  const sourcePath = path.join(skillsSourceDir, skillName);
34
41
  const targetPath = path.join(skillsTargetDir, skillName);
@@ -49,6 +56,32 @@ export async function autoInstallSkills(options) {
49
56
  skipped.push(skillName);
50
57
  continue;
51
58
  }
59
+ copyable.push(skillName);
60
+ }
61
+ // 3. SkillGuard gate (D4, JD-009): scan-gate every copyable source via its
62
+ // folder-root SKILL.md. dryRun still scans (read-only) but copies nothing.
63
+ // A scan THROW rejects the whole function — nothing copied, matching the
64
+ // UI error path (AutoSkills.tsx:41-44).
65
+ const scans = [];
66
+ for (const skillName of copyable) {
67
+ const sourceSkillMd = path.join(skillsSourceDir, skillName, "SKILL.md");
68
+ scans.push(await scanSkillFile(sourceSkillMd));
69
+ }
70
+ const gate = evaluateInstallGate(scans, { force });
71
+ if (!gate.allowed) {
72
+ // Any block (or unscannable without force) ⇒ copy NOTHING.
73
+ return {
74
+ installed: [],
75
+ skipped,
76
+ notFound,
77
+ blocked: gate.rejected,
78
+ detection,
79
+ };
80
+ }
81
+ // 4. Copy every copyable skill — nothing was refused.
82
+ for (const skillName of copyable) {
83
+ const sourcePath = path.join(skillsSourceDir, skillName);
84
+ const targetPath = path.join(skillsTargetDir, skillName);
52
85
  // Copy skill to target
53
86
  if (!dryRun) {
54
87
  await fs.ensureDir(targetPath);
@@ -59,7 +92,7 @@ export async function autoInstallSkills(options) {
59
92
  }
60
93
  installed.push(skillName);
61
94
  }
62
- return { installed, skipped, notFound, detection };
95
+ return { installed, skipped, notFound, blocked: [], detection };
63
96
  }
64
97
  /**
65
98
  * Get a human-readable summary of auto-install results.
@@ -86,6 +119,11 @@ export function formatAutoInstallSummary(result) {
86
119
  if (result.notFound.length > 0) {
87
120
  lines.push(` Not found: ${result.notFound.join(", ")}`);
88
121
  }
122
+ if (result.blocked.length > 0) {
123
+ lines.push(` Blocked: ${result.blocked
124
+ .map((b) => `${b.skillName} [${b.verdict.toUpperCase()}]`)
125
+ .join(", ")}`);
126
+ }
89
127
  return lines.join("\n");
90
128
  }
91
129
  //# sourceMappingURL=auto-skill-install.js.map
@@ -1,9 +1,28 @@
1
1
  import type { InitOptions, StackContextEntry } from "../types/index.js";
2
2
  export declare function buildIndexMd(projectName: string, stackCtx: StackContextEntry, ciProvider: string, memory: string): string;
3
3
  export declare function buildSummaryMd(projectName: string, stack: string, ciProvider: string, memory: string, modules: string[], dependencies?: string[]): string;
4
+ /**
5
+ * Dependency detection outcome. `warnings` explains every manifest that could
6
+ * not be read or parsed, so a failure is distinguishable from "no dependencies"
7
+ * instead of being swallowed by a blanket catch.
8
+ */
9
+ export interface DependencyDetection {
10
+ dependencies: string[];
11
+ warnings: string[];
12
+ }
13
+ /**
14
+ * Detect top-level dependencies from project manifest files, reporting why a
15
+ * manifest was skipped. Returns up to 10 dependency names (key deps only).
16
+ */
17
+ export declare function detectDependenciesDetailed(projectDir: string, stack: string): Promise<DependencyDetection>;
4
18
  /**
5
19
  * Detect top-level dependencies from project manifest files.
6
20
  * Returns up to 10 dependency names (key deps only, not devDeps).
21
+ *
22
+ * List-returning convenience over `detectDependenciesDetailed`. It DISCARDS the
23
+ * read/parse warnings — an unreadable manifest is indistinguishable from an
24
+ * honest empty list through this function. Callers that must tell those two
25
+ * apart (like `refreshContextDir`) call `detectDependenciesDetailed` directly.
7
26
  */
8
27
  export declare function detectDependencies(projectDir: string, stack: string): Promise<string[]>;
9
28
  /**
@@ -23,5 +42,8 @@ export declare function refreshContextDir(projectDir: string): Promise<{
23
42
  index: string;
24
43
  summary: string;
25
44
  updated: boolean;
45
+ /** Manifest read/parse warnings — surfaced so an unreadable manifest is not
46
+ * silently reported as a project with no dependencies. */
47
+ warnings: string[];
26
48
  } | null>;
27
49
  //# sourceMappingURL=context.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { STACK_CONTEXT_MAP } from "../constants.js";
4
+ import { describeSafeReadFailure, safeReadFile } from "./safe-read.js";
4
5
  // =============================================================================
5
6
  // Internal helpers
6
7
  // =============================================================================
@@ -53,95 +54,135 @@ ${stack}-based project scaffolded with javi-forge.
53
54
  // =============================================================================
54
55
  // Dependency detection
55
56
  // =============================================================================
57
+ const MAX_DEPS = 10;
56
58
  /**
57
- * Detect top-level dependencies from project manifest files.
58
- * Returns up to 10 dependency names (key deps only, not devDeps).
59
+ * Byte ceiling for a dependency manifest. A package.json or go.mod past this is
60
+ * not a manifest we can learn anything useful from it is generated noise.
59
61
  */
60
- export async function detectDependencies(projectDir, stack) {
61
- const MAX_DEPS = 10;
62
- try {
63
- switch (stack) {
64
- case "node": {
65
- const pkgPath = path.join(projectDir, "package.json");
66
- if (!(await fs.pathExists(pkgPath)))
67
- return [];
68
- const pkg = await fs.readJson(pkgPath).catch(() => ({}));
69
- const deps = Object.keys(pkg.dependencies ?? {});
70
- return deps.slice(0, MAX_DEPS);
62
+ const MAX_MANIFEST_BYTES = 512 * 1024;
63
+ /** Read a manifest under a byte budget; returns null and a warning on failure. */
64
+ async function readManifest(manifestPath, warnings) {
65
+ const read = await safeReadFile(manifestPath, {
66
+ hardRejectOverBytes: MAX_MANIFEST_BYTES,
67
+ });
68
+ if (!read.ok) {
69
+ warnings.push(`${path.basename(manifestPath)}: ${describeSafeReadFailure(read)}`);
70
+ return null;
71
+ }
72
+ if (read.truncated) {
73
+ warnings.push(`${path.basename(manifestPath)}: truncated at ${read.bytesRead} of ${read.totalBytes} bytes`);
74
+ }
75
+ return read.content;
76
+ }
77
+ /**
78
+ * Detect top-level dependencies from project manifest files, reporting why a
79
+ * manifest was skipped. Returns up to 10 dependency names (key deps only).
80
+ */
81
+ export async function detectDependenciesDetailed(projectDir, stack) {
82
+ const warnings = [];
83
+ switch (stack) {
84
+ case "node": {
85
+ const pkgPath = path.join(projectDir, "package.json");
86
+ if (!(await fs.pathExists(pkgPath)))
87
+ return { dependencies: [], warnings };
88
+ const content = await readManifest(pkgPath, warnings);
89
+ if (content === null)
90
+ return { dependencies: [], warnings };
91
+ let pkg;
92
+ try {
93
+ pkg = JSON.parse(content);
71
94
  }
72
- case "python": {
73
- const pyprojectPath = path.join(projectDir, "pyproject.toml");
74
- if (await fs.pathExists(pyprojectPath)) {
75
- const content = await fs.readFile(pyprojectPath, "utf-8");
76
- const match = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
77
- if (match?.[1]) {
78
- const deps = match[1]
79
- .split("\n")
80
- .map((l) => l.replace(/[",]/g, "").trim())
81
- .filter((l) => l.length > 0 && !l.startsWith("#"))
82
- .map((l) => l.split(/[>=<~!]/)[0].trim())
83
- .filter(Boolean);
84
- return deps.slice(0, MAX_DEPS);
85
- }
86
- }
87
- const reqPath = path.join(projectDir, "requirements.txt");
88
- if (await fs.pathExists(reqPath)) {
89
- const content = await fs.readFile(reqPath, "utf-8");
90
- const deps = content
95
+ catch (err) {
96
+ warnings.push(`package.json: invalid JSON (${err instanceof Error ? err.message : String(err)})`);
97
+ return { dependencies: [], warnings };
98
+ }
99
+ const deps = Object.keys(pkg?.dependencies ?? {});
100
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
101
+ }
102
+ case "python": {
103
+ const pyprojectPath = path.join(projectDir, "pyproject.toml");
104
+ if (await fs.pathExists(pyprojectPath)) {
105
+ const content = await readManifest(pyprojectPath, warnings);
106
+ const match = content?.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
107
+ if (match?.[1]) {
108
+ const deps = match[1]
91
109
  .split("\n")
92
- .map((l) => l.trim())
93
- .filter((l) => l.length > 0 && !l.startsWith("#") && !l.startsWith("-"))
110
+ .map((l) => l.replace(/[",]/g, "").trim())
111
+ .filter((l) => l.length > 0 && !l.startsWith("#"))
94
112
  .map((l) => l.split(/[>=<~!]/)[0].trim())
95
113
  .filter(Boolean);
96
- return deps.slice(0, MAX_DEPS);
114
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
97
115
  }
98
- return [];
99
116
  }
100
- case "go": {
101
- const goModPath = path.join(projectDir, "go.mod");
102
- if (!(await fs.pathExists(goModPath)))
103
- return [];
104
- const content = await fs.readFile(goModPath, "utf-8");
105
- const requireBlock = content.match(/require\s*\(([\s\S]*?)\)/);
106
- if (requireBlock?.[1]) {
107
- const deps = requireBlock[1]
108
- .split("\n")
109
- .map((l) => l.trim())
110
- .filter((l) => l.length > 0 && !l.startsWith("//"))
111
- .map((l) => {
112
- const parts = l.split(/\s+/);
113
- const mod = parts[0] ?? "";
114
- return mod.split("/").pop() ?? mod;
115
- })
116
- .filter(Boolean);
117
- return deps.slice(0, MAX_DEPS);
118
- }
119
- return [];
117
+ const reqPath = path.join(projectDir, "requirements.txt");
118
+ if (await fs.pathExists(reqPath)) {
119
+ const content = await readManifest(reqPath, warnings);
120
+ if (content === null)
121
+ return { dependencies: [], warnings };
122
+ const deps = content
123
+ .split("\n")
124
+ .map((l) => l.trim())
125
+ .filter((l) => l.length > 0 && !l.startsWith("#") && !l.startsWith("-"))
126
+ .map((l) => l.split(/[>=<~!]/)[0].trim())
127
+ .filter(Boolean);
128
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
120
129
  }
121
- case "rust": {
122
- const cargoPath = path.join(projectDir, "Cargo.toml");
123
- if (!(await fs.pathExists(cargoPath)))
124
- return [];
125
- const content = await fs.readFile(cargoPath, "utf-8");
126
- const depsSection = content.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/);
127
- if (depsSection?.[1]) {
128
- const deps = depsSection[1]
129
- .split("\n")
130
- .map((l) => l.trim())
131
- .filter((l) => l.length > 0 && !l.startsWith("#"))
132
- .map((l) => l.split(/\s*=/)[0].trim())
133
- .filter(Boolean);
134
- return deps.slice(0, MAX_DEPS);
135
- }
136
- return [];
130
+ return { dependencies: [], warnings };
131
+ }
132
+ case "go": {
133
+ const goModPath = path.join(projectDir, "go.mod");
134
+ if (!(await fs.pathExists(goModPath)))
135
+ return { dependencies: [], warnings };
136
+ const content = await readManifest(goModPath, warnings);
137
+ const requireBlock = content?.match(/require\s*\(([\s\S]*?)\)/);
138
+ if (requireBlock?.[1]) {
139
+ const deps = requireBlock[1]
140
+ .split("\n")
141
+ .map((l) => l.trim())
142
+ .filter((l) => l.length > 0 && !l.startsWith("//"))
143
+ .map((l) => {
144
+ const parts = l.split(/\s+/);
145
+ const mod = parts[0] ?? "";
146
+ return mod.split("/").pop() ?? mod;
147
+ })
148
+ .filter(Boolean);
149
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
137
150
  }
138
- default:
139
- return [];
151
+ return { dependencies: [], warnings };
140
152
  }
153
+ case "rust": {
154
+ const cargoPath = path.join(projectDir, "Cargo.toml");
155
+ if (!(await fs.pathExists(cargoPath)))
156
+ return { dependencies: [], warnings };
157
+ const content = await readManifest(cargoPath, warnings);
158
+ const depsSection = content?.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/);
159
+ if (depsSection?.[1]) {
160
+ const deps = depsSection[1]
161
+ .split("\n")
162
+ .map((l) => l.trim())
163
+ .filter((l) => l.length > 0 && !l.startsWith("#"))
164
+ .map((l) => l.split(/\s*=/)[0].trim())
165
+ .filter(Boolean);
166
+ return { dependencies: deps.slice(0, MAX_DEPS), warnings };
167
+ }
168
+ return { dependencies: [], warnings };
169
+ }
170
+ default:
171
+ return { dependencies: [], warnings };
141
172
  }
142
- catch {
143
- return [];
144
- }
173
+ }
174
+ /**
175
+ * Detect top-level dependencies from project manifest files.
176
+ * Returns up to 10 dependency names (key deps only, not devDeps).
177
+ *
178
+ * List-returning convenience over `detectDependenciesDetailed`. It DISCARDS the
179
+ * read/parse warnings — an unreadable manifest is indistinguishable from an
180
+ * honest empty list through this function. Callers that must tell those two
181
+ * apart (like `refreshContextDir`) call `detectDependenciesDetailed` directly.
182
+ */
183
+ export async function detectDependencies(projectDir, stack) {
184
+ const { dependencies } = await detectDependenciesDetailed(projectDir, stack);
185
+ return dependencies;
145
186
  }
146
187
  // =============================================================================
147
188
  // Public API
@@ -190,7 +231,7 @@ export async function refreshContextDir(projectDir) {
190
231
  return null;
191
232
  }
192
233
  const stackCtx = getStackContext(manifest.stack);
193
- const dependencies = await detectDependencies(projectDir, manifest.stack);
234
+ const { dependencies, warnings } = await detectDependenciesDetailed(projectDir, manifest.stack);
194
235
  const index = buildIndexMd(manifest.projectName, stackCtx, manifest.ciProvider, manifest.memory);
195
236
  const summary = buildSummaryMd(manifest.projectName, manifest.stack, manifest.ciProvider, manifest.memory, manifest.modules, dependencies);
196
237
  // Write updated files
@@ -199,6 +240,6 @@ export async function refreshContextDir(projectDir) {
199
240
  // Update manifest timestamp
200
241
  manifest.updatedAt = new Date().toISOString();
201
242
  await fs.writeJson(manifestPath, manifest, { spaces: 2 });
202
- return { index, summary, updated: true };
243
+ return { index, summary, updated: true, warnings };
203
244
  }
204
245
  //# sourceMappingURL=context.js.map
@@ -9,6 +9,7 @@ export declare function validatePlugin(pluginDir: string): Promise<PluginValidat
9
9
  */
10
10
  export declare function installPlugin(source: string, options?: {
11
11
  dryRun?: boolean;
12
+ force?: boolean;
12
13
  }): Promise<{
13
14
  success: boolean;
14
15
  name?: string;