skilldrop-cli 0.16.31 → 0.16.33
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/bin/skilldrop.js +133 -18
- package/contracts/skill.schema.json +3 -3
- package/package.json +1 -1
- package/packs/converters/skills/file-to-markdown/SKILL.md +8 -4
- package/packs/converters/skills/file-to-markdown/manifest.json +11 -2
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +118 -8
- package/packs/converters/skills/md-to-docx/examples/q3-support-review.md +7 -0
- package/packs/converters/skills/md-to-xlsx/examples/vendor-review.md +6 -1
- package/packs/converters/skills/mermaid-render/manifest.json +11 -1
- package/packs/dev-team/skills/codebase-tour/evals/files/3/.github/workflows/ci.yml +2 -2
- package/packs/dev-team/skills/codebase-tour/evals/files/3/go.mod +2 -2
- package/packs/dev-team/skills/dependency-upgrade-review/SKILL.md +52 -34
- package/packs/dev-team/skills/dependency-upgrade-review/evals/evals.json +33 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/.github/workflows/ci.yml +39 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/.github/workflows/integration.yml +20 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/PR.md +39 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/go.mod +17 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/internal/notify/client.go +35 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/internal/payments/client.go +66 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/internal/payments/client_integration_test.go +38 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/internal/payments/client_test.go +53 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/internal/payments/settle.go +30 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/pr.diff +42 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/vendor-notes/httpkit-CHANGELOG.md +43 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/.github/workflows/ci.yml +67 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/.lintwright.json +7 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/.nvmrc +1 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/PR.md +110 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/package.json +25 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/pr.diff +146 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/src/due-date.ts +16 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/src/totals.ts +11 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/tests/due-date.test.ts +17 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/tests/totals.test.ts +12 -0
- package/packs/dev-team/skills/dependency-upgrade-review/examples/ledgerline-tidyparse.md +23 -14
- package/packs/dev-team/skills/dependency-upgrade-review/manifest.json +17 -5
- package/packs/dev-team/skills/dependency-upgrade-review/reference.md +36 -1
- package/packs/dev-team/skills/dependency-upgrade-review/scripts/upgrade_diff.py +22 -4
- package/packs/dev-team/skills/sonar-review/manifest.json +12 -0
- package/packs/infra-as-code/skills/terraform-module/manifest.json +11 -0
- package/packs/infra-as-code/skills/terraform-plan-review/manifest.json +8 -1
- package/packs/solution-architect/skills/figma-diagrams/manifest.json +12 -1
package/bin/skilldrop.js
CHANGED
|
@@ -66,7 +66,7 @@ Usage:
|
|
|
66
66
|
skilldrop uninstall --agent <name...> remove subagents
|
|
67
67
|
skilldrop uninstall --loop <name...> remove loops (stage skills are left in place)
|
|
68
68
|
skilldrop validate [--from <src>] structural check of a catalog (for catalog authors)
|
|
69
|
-
skilldrop scan [<skill...>] [--from <src>] supply-chain scan — flag network/exec/credential
|
|
69
|
+
skilldrop scan [<skill...>] [--agent] [--from <src>] supply-chain scan of skills and agents — flag network/exec/credential
|
|
70
70
|
patterns in scripts and injection-shaped instructions
|
|
71
71
|
in SKILL.md before you trust a catalog (RFC-0022)
|
|
72
72
|
skilldrop new-skill <name> --pack <p> scaffold a skill in the catalog you're in (for catalog authors;
|
|
@@ -440,11 +440,11 @@ function gate(cat, names) {
|
|
|
440
440
|
|
|
441
441
|
/* RFC-0050: the worst scan finding per skill, for a policy's scan.max. Eval fixtures and
|
|
442
442
|
example inputs (⚪) never count. */
|
|
443
|
-
function worstFindings(cat, names) {
|
|
443
|
+
function worstFindings(cat, names, scanner = scanSkill) {
|
|
444
444
|
const out = {};
|
|
445
445
|
for (const s of names) {
|
|
446
446
|
let w;
|
|
447
|
-
for (const f of
|
|
447
|
+
for (const f of scanner(cat, s)) if (!f.fixture && (!w || gov.SEVERITY[f.sev] > gov.SEVERITY[w])) w = f.sev;
|
|
448
448
|
if (w) out[s] = w;
|
|
449
449
|
}
|
|
450
450
|
return out;
|
|
@@ -455,7 +455,8 @@ function enforcePolicy(flags, cat, names, kind) {
|
|
|
455
455
|
const policy = gov.load(flags);
|
|
456
456
|
gov.prepare(policy); // an audit log that can't be written stops the install before it starts
|
|
457
457
|
if (!policy || !names.length) return policy;
|
|
458
|
-
const worst =
|
|
458
|
+
const worst = policy.scan && policy.scan.max !== undefined && (kind === "skill" || kind === "agent")
|
|
459
|
+
? worstFindings(cat, names, kind === "agent" ? scanAgent : scanSkill) : {};
|
|
459
460
|
const problems = gov.check(policy, { source: cat.source, pinned: cat.pinned, names, worst, kind });
|
|
460
461
|
if (problems.length) {
|
|
461
462
|
for (const pr of problems) console.error(`refused ${pr.name}: ${pr.why}`);
|
|
@@ -614,10 +615,73 @@ function permissionsOf(cat, s) {
|
|
|
614
615
|
if (cat.shape === "apm") return null;
|
|
615
616
|
try { return manifestOf(cat, s).permissions || null; } catch (e) { return null; }
|
|
616
617
|
}
|
|
618
|
+
/* RFC-0054: what a skill's instructions tell the agent to run. A skill with no scripts still
|
|
619
|
+
reaches the shell and the network through the agent, so its fenced shell blocks are read the
|
|
620
|
+
way a reviewer would: the program each command starts, and the hosts it names. Shell builtins,
|
|
621
|
+
keywords and the text tools every agent already has are left out, as are interpreters running
|
|
622
|
+
the skill's own scripts/ (those are covered by the script rules). Prose outside a fenced shell
|
|
623
|
+
block is not parsed: an instruction to run something belongs in one, where a reader sees it. */
|
|
624
|
+
const SHELL_LANGS = new Set(["bash", "sh", "shell", "console", "zsh"]);
|
|
625
|
+
const NOT_A_PROGRAM = new Set(("cd echo export set unset true false test printf read source exit return then fi do done " +
|
|
626
|
+
"for if elif else while until case esac in function local shift wait time eval exec alias type command " +
|
|
627
|
+
"mkdir ls cat cp mv rm touch head tail grep egrep sed awk sort uniq wc tr cut xargs tee diff find jq less more " +
|
|
628
|
+
"pwd basename dirname date sleep env which chmod ln realpath mktemp yes seq").split(" "));
|
|
629
|
+
const INTERPRETERS = new Set(["python", "python3", "node", "bash", "sh", "zsh", "deno", "bun"]);
|
|
630
|
+
function instructedRuns(dir) {
|
|
631
|
+
const files = ["SKILL.md", "reference.md"].map((f) => path.join(dir, f)).filter((f) => fs.existsSync(f));
|
|
632
|
+
const refs = path.join(dir, "references");
|
|
633
|
+
if (fs.existsSync(refs)) for (const f of fs.readdirSync(refs)) if (f.endsWith(".md")) files.push(path.join(refs, f));
|
|
634
|
+
const commands = new Map(), hosts = new Map();
|
|
635
|
+
for (const f of files) {
|
|
636
|
+
const lines = fs.readFileSync(f, "utf8").split(/\r?\n/);
|
|
637
|
+
let lang = null;
|
|
638
|
+
lines.forEach((raw, i) => {
|
|
639
|
+
const fence = raw.match(/^\s*```+\s*([\w-]*)/);
|
|
640
|
+
if (fence) { lang = lang === null ? fence[1].toLowerCase() : null; return; }
|
|
641
|
+
if (lang === null || !SHELL_LANGS.has(lang)) return;
|
|
642
|
+
const where = { file: path.relative(dir, f), line: i + 1, text: raw.trim().slice(0, 120) };
|
|
643
|
+
const line = raw.replace(/(^|\s)#.*$/, "").replace(/^\s*\$\s+/, "").trim();
|
|
644
|
+
if (!line) return;
|
|
645
|
+
for (const h of line.matchAll(/https?:\/\/([a-z0-9][a-z0-9.-]*\.[a-z]{2,})/gi))
|
|
646
|
+
if (!hosts.has(h[1].toLowerCase())) hosts.set(h[1].toLowerCase(), where);
|
|
647
|
+
// Quoted text is an argument (a grep pattern, a message), never a command, so blank it
|
|
648
|
+
// before splitting on | ; && — otherwise `grep 'a|b'` reads as a command named b.
|
|
649
|
+
const bare = line.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (q) => (/scripts\/|CLAUDE_SKILL_DIR/.test(q) ? q.slice(1, -1) : " X "));
|
|
650
|
+
for (let part of bare.replace(/\$\(/g, " ; ").replace(/[`()]/g, " ").split(/&&|\|\||\||;/)) {
|
|
651
|
+
let w = part.trim().split(/\s+/).filter(Boolean);
|
|
652
|
+
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
|
|
653
|
+
if (w[0] === "sudo" || w[0] === "nohup") w.shift();
|
|
654
|
+
const c = w[0];
|
|
655
|
+
if (!c || !/^[a-z][\w.+-]*$/.test(c) || NOT_A_PROGRAM.has(c)) continue;
|
|
656
|
+
if (INTERPRETERS.has(c) && w.slice(1).some((x) => /(^|\/)scripts\//.test(x) || /CLAUDE_SKILL_DIR/.test(x))) continue;
|
|
657
|
+
const prog = INTERPRETERS.has(c) && w[1] === "-m" && /^[a-z][\w.-]*$/.test(w[2] || "") ? w[2] : c; // python3 -m pip -> pip
|
|
658
|
+
if (!commands.has(prog)) commands.set(prog, where);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
return { commands, hosts };
|
|
663
|
+
}
|
|
664
|
+
|
|
617
665
|
function permissionGaps(cat, s, findings) {
|
|
618
666
|
const dir = skillDir(cat, s);
|
|
619
|
-
if (!dir
|
|
667
|
+
if (!dir) return [];
|
|
668
|
+
const scripted = fs.existsSync(path.join(dir, "scripts"));
|
|
620
669
|
const perm = permissionsOf(cat, s);
|
|
670
|
+
const told = cat.shape === "apm" ? { commands: new Map(), hosts: new Map() } : instructedRuns(dir);
|
|
671
|
+
const instructionGaps = () => {
|
|
672
|
+
const out = [];
|
|
673
|
+
const cmds = (perm && perm.commands) || [], net = (perm && perm.network) || [];
|
|
674
|
+
for (const [c, w] of told.commands)
|
|
675
|
+
if (!cmds.includes("*") && !cmds.includes(c))
|
|
676
|
+
out.push({ sev: "🟨", id: "undeclared-instructed-command", owasp: ["AST03", "AST10"], ...w,
|
|
677
|
+
why: `the instructions tell the agent to run \`${c}\`, but the manifest's permissions.commands doesn't list it` });
|
|
678
|
+
for (const [h, w] of told.hosts)
|
|
679
|
+
if (!net.includes(h))
|
|
680
|
+
out.push({ sev: "🟨", id: "undeclared-instructed-network", owasp: ["AST03", "AST10"], ...w,
|
|
681
|
+
why: `the instructions tell the agent to reach ${h}, but the manifest's permissions.network doesn't list it` });
|
|
682
|
+
return out;
|
|
683
|
+
};
|
|
684
|
+
if (!scripted) return instructionGaps();
|
|
621
685
|
if (!perm)
|
|
622
686
|
return [{ sev: "🟨", id: "no-permissions", owasp: ["AST03", "AST10"], file: "manifest.json", line: 0,
|
|
623
687
|
why: "ships scripts but declares no permissions (network, commands, files) — nothing to check them against",
|
|
@@ -633,7 +697,7 @@ function permissionGaps(cat, s, findings) {
|
|
|
633
697
|
if (f.id === "broad-fs" && perm.files !== "anywhere")
|
|
634
698
|
gap("undeclared-files", f, `writes outside its own paths, but the manifest declares files: "${perm.files}"`);
|
|
635
699
|
}
|
|
636
|
-
return gaps;
|
|
700
|
+
return gaps.concat(instructionGaps());
|
|
637
701
|
}
|
|
638
702
|
function permissionSummary(perm) {
|
|
639
703
|
if (!perm) return "no permissions declared";
|
|
@@ -643,35 +707,82 @@ function permissionSummary(perm) {
|
|
|
643
707
|
return `${net} · ${cmds} · ${files}`;
|
|
644
708
|
}
|
|
645
709
|
|
|
646
|
-
|
|
710
|
+
/* RFC-0054: an agent is a system prompt the tool adopts whole, with the tools its frontmatter
|
|
711
|
+
grants, so it gets the prose rules a SKILL.md gets plus the checks only an agent needs. */
|
|
712
|
+
function scanAgent(cat, a) {
|
|
713
|
+
const file = agentPath(cat, a);
|
|
714
|
+
const findings = [];
|
|
715
|
+
if (!file || !fs.existsSync(file)) return findings;
|
|
716
|
+
const rel = path.basename(file);
|
|
717
|
+
const text = fs.readFileSync(file, "utf8");
|
|
718
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
719
|
+
if (line.length > 400) return;
|
|
720
|
+
for (const r of PROSE_RULES)
|
|
721
|
+
if (r.re.test(line)) findings.push({ sev: r.sev, id: r.id, why: r.why, owasp: r.owasp, file: rel, line: i + 1, text: line.trim().slice(0, 120) });
|
|
722
|
+
});
|
|
723
|
+
const fmEnd = text.startsWith("---") ? text.indexOf("\n---", 3) : -1;
|
|
724
|
+
const fm = fmEnd > 0 ? text.slice(0, fmEnd) : "";
|
|
725
|
+
const lineOf = (re) => { const i = fm.split("\n").findIndex((l) => re.test(l)); return i < 0 ? 1 : i + 1; };
|
|
726
|
+
const at = (re) => ({ file: rel, line: lineOf(re), text: (fm.split("\n")[lineOf(re) - 1] || "").trim().slice(0, 120) });
|
|
727
|
+
const tools = (fm.match(/^tools:\s*(.*)$/m) || [null, null])[1];
|
|
728
|
+
const list = tools === null ? null : tools.replace(/[[\]"']/g, "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
729
|
+
if (list === null)
|
|
730
|
+
findings.push({ sev: "🟧", id: "agent-all-tools", owasp: ["AST03", "LLM06"], ...at(/^name:/),
|
|
731
|
+
why: "has no `tools:` list, so it inherits every tool the session has, including any MCP server's" });
|
|
732
|
+
else {
|
|
733
|
+
const reads = list.filter((t) => /^(WebFetch|WebSearch)$/.test(t) || /^mcp__/.test(t));
|
|
734
|
+
const acts = list.filter((t) => /^(Bash|Write|Edit|NotebookEdit)$/.test(t));
|
|
735
|
+
if (reads.length && acts.length)
|
|
736
|
+
findings.push({ sev: "🟧", id: "agent-untrusted-and-act", owasp: ["AST03", "LLM01", "LLM06"], ...at(/^tools:/),
|
|
737
|
+
why: `reads content from outside (${reads.join(", ")}) and can act on it (${acts.join(", ")}) in the same context: an injected instruction in what it reads can run` });
|
|
738
|
+
}
|
|
739
|
+
if (/^permissionMode:\s*(bypassPermissions|acceptEdits)/m.test(fm))
|
|
740
|
+
findings.push({ sev: "🟥", id: "agent-skips-approval", owasp: ["AST03", "LLM06"], ...at(/^permissionMode:/),
|
|
741
|
+
why: "sets a permission mode that skips the user's approval for its actions" });
|
|
742
|
+
if (/^hooks:/m.test(fm))
|
|
743
|
+
findings.push({ sev: "🟧", id: "agent-hooks", owasp: ["AST03", "AST01"], ...at(/^hooks:/),
|
|
744
|
+
why: "defines hooks: commands that run on events, outside the conversation the user reads" });
|
|
745
|
+
const order = { "🟥": 0, "🟧": 1, "🟨": 2, "⚪": 3 };
|
|
746
|
+
return findings.sort((x, y) => order[x.sev] - order[y.sev]);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function printScan(cat, names, { compact = false, scanner = scanSkill, kind = "" } = {}) {
|
|
647
750
|
let total = 0, worst = null;
|
|
648
751
|
for (const s of names) {
|
|
649
|
-
const f =
|
|
752
|
+
const f = scanner(cat, s);
|
|
650
753
|
if (!f.length) continue;
|
|
651
754
|
total += f.length;
|
|
652
755
|
if (!worst || f[0].sev === "🟥") worst = worst === "🟥" ? worst : f[0].sev;
|
|
653
|
-
console.log(`\n${s}`);
|
|
756
|
+
console.log(`\n${kind === "agent" ? "agent " : ""}${s}`);
|
|
654
757
|
for (const x of (compact ? f.slice(0, 3) : f))
|
|
655
758
|
console.log(` ${x.sev} ${x.id} — ${x.why} [${x.owasp.join(", ")}]\n ${x.file}:${x.line} ${x.text}`);
|
|
656
|
-
if (compact && f.length > 3) console.log(` … ${f.length - 3} more (skilldrop scan ${s} --from ${cat.source})`);
|
|
759
|
+
if (compact && f.length > 3) console.log(` … ${f.length - 3} more (skilldrop scan ${kind === "agent" ? "--agent " : ""}${s} --from ${cat.source})`);
|
|
657
760
|
}
|
|
658
761
|
return { total, worst };
|
|
659
762
|
}
|
|
660
763
|
|
|
661
764
|
function scan(args) {
|
|
662
765
|
const cat = resolveCatalog(args.flags.from);
|
|
663
|
-
|
|
766
|
+
// `scan` alone covers every skill and agent; `scan <skills>` the named skills;
|
|
767
|
+
// `scan --agent [names]` agents only (--agents works too) (an agent may share a skill's name).
|
|
768
|
+
const agentsOnly = !!(args.flags.agent || args.flags.agents);
|
|
769
|
+
const names = agentsOnly ? [] : (args._.length ? args._.slice() : skillsIn(cat));
|
|
770
|
+
const agents = agentsOnly ? (args._.length ? args._.slice() : agentsIn(cat)) : (args._.length ? [] : agentsIn(cat));
|
|
664
771
|
for (const s of names) if (!skillExists(cat, s)) die(`unknown skill '${s}' in catalog '${cat.source}'`);
|
|
772
|
+
for (const a of agents) if (!agentsIn(cat).includes(a)) die(`unknown agent '${a}' in catalog '${cat.source}'`);
|
|
665
773
|
|
|
666
774
|
if (args.flags.json) {
|
|
667
775
|
const out = names.map((s) => ({ skill: s, findings: scanSkill(cat, s) })).filter((r) => r.findings.length);
|
|
776
|
+
const aout = agents.map((a) => ({ agent: a, findings: scanAgent(cat, a) })).filter((r) => r.findings.length);
|
|
668
777
|
return emitJSON({
|
|
669
|
-
catalog: cat.source, scanned: names.length,
|
|
670
|
-
flagged: out.length
|
|
778
|
+
catalog: cat.source, scanned: names.length, agentsScanned: agents.length,
|
|
779
|
+
flagged: out.length + aout.length,
|
|
780
|
+
findingCount: out.reduce((n, r) => n + r.findings.length, 0) + aout.reduce((n, r) => n + r.findings.length, 0),
|
|
781
|
+
skills: out, agents: aout,
|
|
671
782
|
});
|
|
672
783
|
}
|
|
673
|
-
console.log(`Scanning ${names.length} skill(s) in catalog '${cat.source}' for supply-chain patterns…`);
|
|
674
|
-
const
|
|
784
|
+
console.log(`Scanning ${names.length} skill(s)${agents.length ? ` and ${agents.length} agent(s)` : ""} in catalog '${cat.source}' for supply-chain patterns…`);
|
|
785
|
+
const total = printScan(cat, names).total + printScan(cat, agents, { scanner: scanAgent, kind: "agent" }).total;
|
|
675
786
|
if (!total) {
|
|
676
787
|
console.log("\nNo flagged patterns found.");
|
|
677
788
|
} else {
|
|
@@ -1373,9 +1484,9 @@ function install(args) {
|
|
|
1373
1484
|
console.log(`\nWARNING: third-party catalog '${cat.source}'. Skills are instructions your AI agent will follow — review each installed SKILL.md under ${dest} before first use. Install copied files only; nothing was executed.`);
|
|
1374
1485
|
// Supply-chain scan (RFC-0022): surface risky patterns at the moment of trust. Reports only —
|
|
1375
1486
|
// the files are already copied, and a match is a prompt to read, not a verdict.
|
|
1376
|
-
const scripted = names.filter((s) => fs.existsSync(path.join(skillDir(cat, s), "scripts")));
|
|
1487
|
+
const scripted = names.filter((s) => fs.existsSync(path.join(skillDir(cat, s), "scripts")) || permissionsOf(cat, s));
|
|
1377
1488
|
if (scripted.length) {
|
|
1378
|
-
console.log(`\nWhat
|
|
1489
|
+
console.log(`\nWhat each skill declares it runs and reaches (RFC-0039, RFC-0054):`);
|
|
1379
1490
|
for (const s of scripted) console.log(` ${s}: ${permissionSummary(permissionsOf(cat, s))}`);
|
|
1380
1491
|
}
|
|
1381
1492
|
const { total } = printScan(cat, names, { compact: true });
|
|
@@ -2255,8 +2366,12 @@ function installAgents(args) {
|
|
|
2255
2366
|
if (ide === "copilot") console.log("Invoke with: copilot --agent <name>");
|
|
2256
2367
|
if (ide === "codex") console.log("sandbox_mode is left unset, so each agent inherits the parent session's permissions.");
|
|
2257
2368
|
if (ide === "antigravity") console.log("Emitted with `subagent: true` so invoke_subagent can reach them.");
|
|
2258
|
-
if (cat.source !== BUNDLED)
|
|
2369
|
+
if (cat.source !== BUNDLED) {
|
|
2259
2370
|
console.log(`\nWARNING: third-party catalog '${cat.source}'. An agent is a system prompt your tool will adopt — read it before first use. Install copied files only; nothing was executed.`);
|
|
2371
|
+
const { total } = printScan(cat, names, { compact: true, scanner: scanAgent, kind: "agent" });
|
|
2372
|
+
console.log(total ? `\n${total} pattern(s) worth reading before you trust these agents. Full detail: skilldrop scan --agent --from ${cat.source}`
|
|
2373
|
+
: "\nSupply-chain scan: no flagged patterns.");
|
|
2374
|
+
}
|
|
2260
2375
|
}
|
|
2261
2376
|
|
|
2262
2377
|
function uninstallAgents(args) {
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
"type": "object",
|
|
43
43
|
"additionalProperties": false,
|
|
44
44
|
"required": ["network", "commands", "files"],
|
|
45
|
-
"description": "RFC-0039 (OWASP AST03, AST10). What the skill's scripts do, declared so a reader and `skilldrop scan` can compare it with
|
|
45
|
+
"description": "RFC-0039, RFC-0054 (OWASP AST03, AST10). What the skill's scripts do and what its instructions tell the agent to run, declared so a reader and `skilldrop scan` can compare it with the code and with every fenced shell block in SKILL.md and its reference files. Required for every skill that ships scripts/, and for any skill whose instructions run a program or reach a host.",
|
|
46
46
|
"properties": {
|
|
47
47
|
"network": { "type": "array", "items": { "type": "string" },
|
|
48
|
-
"description": "Hosts the scripts contact, e.g. api.figma.com. Empty means none." },
|
|
48
|
+
"description": "Hosts the scripts contact or the instructed commands reach, e.g. api.figma.com. Empty means none." },
|
|
49
49
|
"commands": { "type": "array", "items": { "type": "string" },
|
|
50
|
-
"description": "External programs the scripts run, e.g. pdftotext. \"*\" means commands the user configures. Empty means none." },
|
|
50
|
+
"description": "External programs the scripts run or the instructions tell the agent to run, e.g. pdftotext, terraform. \"*\" means commands the user configures. Empty means none." },
|
|
51
51
|
"files": { "type": "string", "enum": ["none", "named-paths", "project", "anywhere"],
|
|
52
52
|
"description": "What the scripts write: nothing; only paths the user names; inside the current project; or anywhere." },
|
|
53
53
|
"notes": { "type": "string", "description": "Anything a reviewer should know that the fields can't say." }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skilldrop-cli",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.33",
|
|
4
4
|
"description": "Portable AI-agent skills, measured against the agent without them. ADRs, PRDs, runbooks, decks and reviews, installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Sanjay Ananthanarayan",
|
|
@@ -47,12 +47,16 @@ or a rewrite that goes back out through `md-to-docx` or `md-to-html`.
|
|
|
47
47
|
| `.csv` / `.tsv` | One table, delimiter detected |
|
|
48
48
|
| `.json` | An array of objects becomes a table; an object becomes a key/value table plus a table per array |
|
|
49
49
|
| `.txt` / `.md` | Passed through |
|
|
50
|
-
| `.pdf` | Through `pdftotext`. Tables from aligned columns, lists from indentation, short standalone lines as `###` headings (guessed, and said so); pages with side-by-side columns are read in column order with nothing inferred. No images |
|
|
50
|
+
| `.pdf` | Through `pdftotext`. Tables from aligned columns, lists from indentation, short standalone lines as `###` headings (guessed, and said so); a table that runs onto the next page is joined into one, without the header the PDF repeats; pages with side-by-side columns are read in column order with nothing inferred. Scanned pages are read by OCR when `tesseract` is installed. No images |
|
|
51
51
|
|
|
52
52
|
3. **PDF without `pdftotext`.** The script stops with install steps (`brew install poppler`
|
|
53
53
|
on macOS, `sudo apt install poppler-utils` on Debian/Ubuntu). Pass those on; don't try to
|
|
54
|
-
read the PDF bytes yourself. A
|
|
55
|
-
|
|
54
|
+
read the PDF bytes yourself. A page with no text layer is a scan. The script reads it by
|
|
55
|
+
OCR when `tesseract` is installed (`brew install tesseract`, `sudo apt install tesseract-ocr`),
|
|
56
|
+
and otherwise stops with those steps. For a scan that isn't in English, pass `--ocr-lang`
|
|
57
|
+
with tesseract's code (`deu`, `fra`, `eng+fra`). OCR misreads characters, so the reply says
|
|
58
|
+
which pages were OCR'd and asks the user to check every number, name and date in them
|
|
59
|
+
against the PDF.
|
|
56
60
|
|
|
57
61
|
4. **Old binary formats** (`.doc`, `.ppt`, `.xls`, `.rtf`, `.pages`, `.key`): the script
|
|
58
62
|
refuses and names the format to save as. On macOS, `textutil -convert docx file.doc`
|
|
@@ -73,7 +77,7 @@ missing, emit `BLOCKED: need <the file | pdftotext on PATH>` and stop.
|
|
|
73
77
|
|
|
74
78
|
## Useful references in this skill
|
|
75
79
|
|
|
76
|
-
- [`scripts/to_markdown.py`](scripts/to_markdown.py): the converter (stdlib only; `pdftotext` optional for PDF)
|
|
80
|
+
- [`scripts/to_markdown.py`](scripts/to_markdown.py): the converter (stdlib only; `pdftotext` optional for PDF, `tesseract` for scanned pages)
|
|
77
81
|
- [`examples/mixed-inputs.md`](examples/mixed-inputs.md): a Word brief, a deck, a workbook and a web page converted, with the real Markdown and stderr report
|
|
78
82
|
|
|
79
83
|
## Quality bar
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "file-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Convert a Word document, PowerPoint deck, Excel workbook, web page, CSV, JSON, text file or PDF into clean Markdown that keeps headings, lists, tables, links, bold and italic, slide titles and speaker notes, and reports everything it dropped (images, charts, comments, formulas, tracked changes), so the content can be read, reviewed or fed to another skill. Use when the user hands over a .docx, .pptx, .xlsx, .html, .csv, .json or .pdf and says \"read this\", \"turn this into markdown\", \"extract the text\", or wants it critiqued or briefed.",
|
|
5
5
|
"entrypoint": "SKILL.md",
|
|
6
6
|
"deps": {
|
|
@@ -41,7 +41,16 @@
|
|
|
41
41
|
"pdf",
|
|
42
42
|
"extract"
|
|
43
43
|
],
|
|
44
|
-
"permissions": {
|
|
44
|
+
"permissions": {
|
|
45
|
+
"network": [],
|
|
46
|
+
"commands": [
|
|
47
|
+
"pdftotext",
|
|
48
|
+
"pdftoppm",
|
|
49
|
+
"tesseract"
|
|
50
|
+
],
|
|
51
|
+
"files": "named-paths",
|
|
52
|
+
"notes": "pdftotext (Poppler) is used only for .pdf input, and only if it is installed. A page with no text layer is rendered with pdftoppm (Poppler) into a temporary folder that is deleted afterwards, and read with tesseract, only if tesseract is installed."
|
|
53
|
+
},
|
|
45
54
|
"model": {
|
|
46
55
|
"tier": "light",
|
|
47
56
|
"rationale": "A deterministic stdlib script does the extraction; the model runs it, relays the dropped-content report and hands off. No generation beyond a short summary of losses."
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"""Convert a document into clean Markdown that another skill can read.
|
|
3
3
|
|
|
4
4
|
Stdlib only, Python 3.9+. Handles .docx, .pptx, .xlsx, .html/.htm, .csv/.tsv,
|
|
5
|
-
.json, .txt/.md, and .pdf (through `pdftotext` when it is installed).
|
|
5
|
+
.json, .txt/.md, and .pdf (through `pdftotext` when it is installed). A PDF page with no
|
|
6
|
+
text layer (a scan) is read by OCR when `tesseract` and `pdftoppm` are installed.
|
|
6
7
|
Everything the conversion could not carry (images, charts, comments, formulas,
|
|
7
8
|
tracked changes) is counted and reported on stderr, so nobody mistakes the
|
|
8
9
|
Markdown for the whole document.
|
|
@@ -12,6 +13,7 @@ Usage:
|
|
|
12
13
|
python3 to_markdown.py deck.pptx -o deck.md
|
|
13
14
|
python3 to_markdown.py model.xlsx -o model.md --max-rows 200
|
|
14
15
|
python3 to_markdown.py page.html -o - # write to stdout
|
|
16
|
+
python3 to_markdown.py scan.pdf -o scan.md --ocr-lang deu # OCR a German scan
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
import argparse
|
|
@@ -22,6 +24,7 @@ import re
|
|
|
22
24
|
import shutil
|
|
23
25
|
import subprocess
|
|
24
26
|
import sys
|
|
27
|
+
import tempfile
|
|
25
28
|
import zipfile
|
|
26
29
|
from collections import OrderedDict
|
|
27
30
|
from datetime import datetime, timedelta
|
|
@@ -1122,8 +1125,9 @@ def _pdf_page_md(page, rep):
|
|
|
1122
1125
|
region.append(line); rows += 1; cols |= _col_starts(line); j += 1
|
|
1123
1126
|
elif line.strip() and _col_starts(line) and _col_starts(line) <= _near(cols):
|
|
1124
1127
|
region.append(line); j += 1
|
|
1125
|
-
elif not line.strip() and j + 1 < n and lines[j + 1]
|
|
1126
|
-
|
|
1128
|
+
elif not line.strip() and j + 1 < n and len(_cells(lines[j + 1])) >= 2:
|
|
1129
|
+
# After a blank line only a full row continues the table. A one-cell line
|
|
1130
|
+
# there ("End of report.") is the next paragraph, not a wrapped cell.
|
|
1127
1131
|
j += 1
|
|
1128
1132
|
else:
|
|
1129
1133
|
break
|
|
@@ -1193,7 +1197,83 @@ def _pdf_page_md(page, rep):
|
|
|
1193
1197
|
return out
|
|
1194
1198
|
|
|
1195
1199
|
|
|
1196
|
-
|
|
1200
|
+
_FURNITURE = re.compile(r"^\s*(page\s+)?\d{1,4}(\s*(of|/)\s*\d{1,4})?\s*$|^\s*[-–—]\s*\d{1,4}\s*[-–—]\s*$", re.I)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _join_split_tables(pages, rep):
|
|
1204
|
+
"""A table that runs off the foot of one page continues at the top of the next, where it
|
|
1205
|
+
would otherwise start a second table whose first data row reads as its header. When a page
|
|
1206
|
+
ends in a table and the next page's first rows (after page numbers and any running header)
|
|
1207
|
+
have the same number of cells, those rows are moved up to finish the table. A header row
|
|
1208
|
+
the PDF repeats on the new page is dropped. Returns the pages, rewritten."""
|
|
1209
|
+
pages = [p.split("\n") for p in pages]
|
|
1210
|
+
first_lines = [next((l.strip() for l in p if l.strip()), "") for p in pages]
|
|
1211
|
+
running = {t for t in first_lines if t and first_lines.count(t) > 1} # a header on several pages
|
|
1212
|
+
holder = None
|
|
1213
|
+
for k in range(len(pages) - 1):
|
|
1214
|
+
a_idx = k if holder is None else holder
|
|
1215
|
+
a, b = pages[a_idx], pages[k + 1]
|
|
1216
|
+
# the last table row on the holder page, before trailing page furniture
|
|
1217
|
+
end = len(a) - 1
|
|
1218
|
+
while end >= 0 and (not a[end].strip() or _FURNITURE.match(a[end])):
|
|
1219
|
+
end -= 1
|
|
1220
|
+
holder = None
|
|
1221
|
+
if end < 0 or len(_cells(a[end])) < 2:
|
|
1222
|
+
continue
|
|
1223
|
+
width = len(_cells(a[end]))
|
|
1224
|
+
start = end
|
|
1225
|
+
while start - 1 >= 0 and (len(_cells(a[start - 1])) == width or
|
|
1226
|
+
(not a[start - 1].strip() and start - 2 >= 0 and len(_cells(a[start - 2])) == width)):
|
|
1227
|
+
start -= 1
|
|
1228
|
+
header = " ".join(a[start].split())
|
|
1229
|
+
# the first content on the next page, past page numbers and a running header
|
|
1230
|
+
i = 0
|
|
1231
|
+
while i < len(b) and (not b[i].strip() or _FURNITURE.match(b[i]) or b[i].strip() in running):
|
|
1232
|
+
i += 1
|
|
1233
|
+
if i >= len(b) or len(_cells(b[i])) != width:
|
|
1234
|
+
continue
|
|
1235
|
+
j = i
|
|
1236
|
+
rows = []
|
|
1237
|
+
while j < len(b) and (len(_cells(b[j])) == width or
|
|
1238
|
+
(not b[j].strip() and j + 1 < len(b) and len(_cells(b[j + 1])) == width)):
|
|
1239
|
+
if b[j].strip():
|
|
1240
|
+
rows.append(b[j])
|
|
1241
|
+
j += 1
|
|
1242
|
+
dropped = bool(rows) and " ".join(rows[0].split()) == header
|
|
1243
|
+
if dropped:
|
|
1244
|
+
rows = rows[1:]
|
|
1245
|
+
if not rows:
|
|
1246
|
+
continue
|
|
1247
|
+
a[end + 1:end + 1] = rows
|
|
1248
|
+
del b[i:j]
|
|
1249
|
+
rep.notes.append("a table continued from page %d onto page %d was joined into one%s" % (
|
|
1250
|
+
a_idx + 1, k + 2, ", and the header row repeated on page %d was dropped" % (k + 2) if dropped else ""))
|
|
1251
|
+
rep.keep("pdf tables joined across pages", 1)
|
|
1252
|
+
if not any(l.strip() and not _FURNITURE.match(l) and l.strip() not in running for l in b):
|
|
1253
|
+
holder = a_idx # the whole page was table: the next page may continue it too
|
|
1254
|
+
return ["\n".join(p) for p in pages]
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _ocr_page(path, n, ppm, tess, lang):
|
|
1258
|
+
"""Page n of the PDF as text, by rendering it at 300 dpi and reading it with tesseract.
|
|
1259
|
+
Page segmentation mode 6 reads a uniform block and keeps the spacing between words, which
|
|
1260
|
+
is what the table and list inference reads columns from."""
|
|
1261
|
+
with tempfile.TemporaryDirectory(prefix="to-md-ocr-") as tmp:
|
|
1262
|
+
stem = str(Path(tmp) / "page")
|
|
1263
|
+
r = subprocess.run([ppm, "-r", "300", "-png", "-f", str(n), "-l", str(n), str(path), stem],
|
|
1264
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
|
1265
|
+
pngs = sorted(Path(tmp).glob("page*.png"))
|
|
1266
|
+
if r.returncode != 0 or not pngs:
|
|
1267
|
+
sys.exit("error: pdftoppm could not render page %d: %s" % (n, r.stderr.decode("utf-8", "replace").strip()))
|
|
1268
|
+
r = subprocess.run([tess, str(pngs[0]), "stdout", "-l", lang, "--psm", "6",
|
|
1269
|
+
"-c", "preserve_interword_spaces=1"],
|
|
1270
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
|
1271
|
+
if r.returncode != 0:
|
|
1272
|
+
sys.exit("error: tesseract failed on page %d: %s" % (n, r.stderr.decode("utf-8", "replace").strip()))
|
|
1273
|
+
return r.stdout.decode("utf-8", "replace").replace("\f", "")
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
|
|
1197
1277
|
exe = shutil.which("pdftotext")
|
|
1198
1278
|
if not exe:
|
|
1199
1279
|
sys.exit("error: reading PDF needs `pdftotext` (part of Poppler), which is not on PATH.\n"
|
|
@@ -1211,9 +1291,33 @@ def pdf_to_md(path, rep):
|
|
|
1211
1291
|
return pages[:-1] if pages and not pages[-1].strip() else pages
|
|
1212
1292
|
|
|
1213
1293
|
layout, reading = run(["-layout"]), run([])
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1294
|
+
blank = [n for n, p in enumerate(layout, 1) if not p.strip()]
|
|
1295
|
+
if blank and ocr is not False:
|
|
1296
|
+
tess, ppm = shutil.which("tesseract"), shutil.which("pdftoppm")
|
|
1297
|
+
if not (tess and ppm):
|
|
1298
|
+
if len(blank) == len(layout):
|
|
1299
|
+
sys.exit("error: %s has no text layer (probably scanned), so it needs OCR, which uses "
|
|
1300
|
+
"`tesseract`%s.\n"
|
|
1301
|
+
" macOS: brew install tesseract (pdftoppm comes with poppler)\n"
|
|
1302
|
+
" Debian/Ubuntu: sudo apt install tesseract-ocr poppler-utils\n"
|
|
1303
|
+
" Windows: install Tesseract (UB Mannheim build) and add it to PATH\n"
|
|
1304
|
+
"Then rerun. Add --ocr-lang deu (or another installed language) for a non-English scan."
|
|
1305
|
+
% (path, "" if ppm else " and `pdftoppm`"))
|
|
1306
|
+
rep.notes.append("page(s) %s have no text layer and were left empty: install tesseract to read them by OCR"
|
|
1307
|
+
% ", ".join(map(str, blank)))
|
|
1308
|
+
else:
|
|
1309
|
+
for n in blank:
|
|
1310
|
+
text = _ocr_page(path, n, ppm, tess, ocr_lang)
|
|
1311
|
+
layout[n - 1] = text
|
|
1312
|
+
if n <= len(reading):
|
|
1313
|
+
reading[n - 1] = text
|
|
1314
|
+
rep.keep("pages read by OCR", len(blank))
|
|
1315
|
+
rep.notes.append("page(s) %s are scanned, so their text was read by OCR (tesseract, language %s, 300 dpi). "
|
|
1316
|
+
"OCR misreads characters: check every number, name and date against the PDF"
|
|
1317
|
+
% (", ".join(map(str, blank)), ocr_lang))
|
|
1318
|
+
elif blank and len(blank) == len(layout):
|
|
1319
|
+
sys.exit("error: %s has no text layer (probably scanned), and --no-ocr was given." % path)
|
|
1320
|
+
layout = _join_split_tables(layout, rep)
|
|
1217
1321
|
out, plain_pages = [], []
|
|
1218
1322
|
for n, page in enumerate(layout, 1):
|
|
1219
1323
|
out += ["<!-- page %d -->" % n, ""]
|
|
@@ -1250,7 +1354,12 @@ def main(argv=None):
|
|
|
1250
1354
|
ap.add_argument("-o", "--output", required=True, help="Markdown file to write, or - for stdout")
|
|
1251
1355
|
ap.add_argument("--max-rows", type=int, default=0,
|
|
1252
1356
|
help="xlsx only: keep at most this many data rows per sheet (0 = all)")
|
|
1357
|
+
ap.add_argument("--ocr-lang", default="eng",
|
|
1358
|
+
help="pdf only: tesseract language for scanned pages, e.g. eng, deu, fra, eng+fra (default eng)")
|
|
1359
|
+
ap.add_argument("--no-ocr", action="store_true", help="pdf only: never run OCR on scanned pages")
|
|
1253
1360
|
a = ap.parse_args(argv)
|
|
1361
|
+
if not re.fullmatch(r"[A-Za-z_]{3,}(\+[A-Za-z_]{3,})*", a.ocr_lang):
|
|
1362
|
+
sys.exit("error: --ocr-lang takes tesseract language codes such as eng or eng+fra")
|
|
1254
1363
|
|
|
1255
1364
|
path = Path(a.input)
|
|
1256
1365
|
if not path.is_file():
|
|
@@ -1267,7 +1376,8 @@ def main(argv=None):
|
|
|
1267
1376
|
md += "\n"
|
|
1268
1377
|
elif ext in HANDLERS:
|
|
1269
1378
|
fn = HANDLERS[ext]
|
|
1270
|
-
md = fn(path, rep, a.max_rows) if fn is xlsx_to_md else
|
|
1379
|
+
md = (fn(path, rep, a.max_rows) if fn is xlsx_to_md else
|
|
1380
|
+
fn(path, rep, ocr=not a.no_ocr, ocr_lang=a.ocr_lang) if fn is pdf_to_md else fn(path, rep))
|
|
1271
1381
|
else:
|
|
1272
1382
|
sys.exit("error: unsupported file type %r. Supported: %s, .txt, .md"
|
|
1273
1383
|
% (ext, ", ".join(sorted(HANDLERS))))
|
|
@@ -66,6 +66,13 @@ has a shaded header row and right-aligned numbers, and the 480 × 240 px chart (
|
|
|
66
66
|
DPI) is centred at 3.3 × 1.7 in. The numbered proposals run 1 to 3, the "support dashboard" and
|
|
67
67
|
"the alias section" links are blue and underlined, and the quote is indented and italic.
|
|
68
68
|
|
|
69
|
+
**Opened in Microsoft Word** for Mac 16.114 (2026-10-20), through AppleScript: it opened with
|
|
70
|
+
no repair prompt, the first paragraph has the built-in Heading 1 style, and it holds one table
|
|
71
|
+
and one inline picture. Word's own PDF export of it shows the nested bullets, both task boxes,
|
|
72
|
+
the table with its numbers right-aligned, the code block and the quote, embeds the chart at
|
|
73
|
+
480 × 240, and carries both links as live link annotations: the dashboard URL and the jump to
|
|
74
|
+
"Retiring the alias".
|
|
75
|
+
|
|
69
76
|
---
|
|
70
77
|
|
|
71
78
|
## Reply to the user
|
|
@@ -76,7 +76,12 @@ all well-formed. Row 2 of the first sheet reads back as:
|
|
|
76
76
|
|
|
77
77
|
The frozen pane is `ySplit="1"` and the autofilter is `A1:G5`. A Quick Look preview shows
|
|
78
78
|
`$1,240,500.00`, `12.5%` and `2027-03-31` as formatted values. Quick Look leaves boolean cells
|
|
79
|
-
blank, which is a limitation of the previewer.
|
|
79
|
+
blank, which is a limitation of the previewer.
|
|
80
|
+
|
|
81
|
+
**Opened in Microsoft Excel** for Mac 16.114 (2026-10-20), read through AppleScript without
|
|
82
|
+
saving: three sheets; D2 is the number 1240500 shown as `$1,240,500.00`, E2 the number 0.125
|
|
83
|
+
shown as `12.5%`, F2 a date shown as `2027-03-31`, G2 the boolean `TRUE`, and A2 the text
|
|
84
|
+
`00412` with its zeros. The first sheet has its filter on and its header row frozen.
|
|
80
85
|
|
|
81
86
|
---
|
|
82
87
|
|
|
@@ -32,7 +32,17 @@
|
|
|
32
32
|
"png",
|
|
33
33
|
"render"
|
|
34
34
|
],
|
|
35
|
-
"permissions": {
|
|
35
|
+
"permissions": {
|
|
36
|
+
"network": [
|
|
37
|
+
"registry.npmjs.org"
|
|
38
|
+
],
|
|
39
|
+
"commands": [
|
|
40
|
+
"mmdc",
|
|
41
|
+
"npm"
|
|
42
|
+
],
|
|
43
|
+
"files": "named-paths",
|
|
44
|
+
"notes": "mmdc (mermaid-cli) renders SVG, PNG and PDF if it is installed. Without it the script writes an HTML page that loads Mermaid from cdn.jsdelivr.net when opened; the script itself makes no network calls. Its instructions suggest installing mermaid-cli with `npm install -g @mermaid-js/mermaid-cli`, which reaches the npm registry."
|
|
45
|
+
},
|
|
36
46
|
"model": {
|
|
37
47
|
"tier": "standard",
|
|
38
48
|
"rationale": "The script lints and renders deterministically, but fixing a broken diagram means reading the author's intent and rewriting syntax without changing nodes, edges or labels, and knowing when to ask instead."
|
|
@@ -7,7 +7,7 @@ jobs:
|
|
|
7
7
|
- uses: actions/checkout@v4
|
|
8
8
|
- uses: actions/setup-go@v5
|
|
9
9
|
with:
|
|
10
|
-
go-version: "1.
|
|
10
|
+
go-version: "1.25"
|
|
11
11
|
- run: make lint
|
|
12
12
|
- run: make test
|
|
13
13
|
integration:
|
|
@@ -24,5 +24,5 @@ jobs:
|
|
|
24
24
|
- uses: actions/checkout@v4
|
|
25
25
|
- uses: actions/setup-go@v5
|
|
26
26
|
with:
|
|
27
|
-
go-version: "1.
|
|
27
|
+
go-version: "1.25"
|
|
28
28
|
- run: make test-integration
|