UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

260 lines (259 loc) • 10.4 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Shared filesystem walker for akm stash directories. * * Provides a single implementation used by both the search fallback * (stash.ts) and the indexer (indexer.ts) to walk type-specific asset * directories and group files by parent directory. */ import fs from "node:fs"; import path from "node:path"; import { isRelevantAssetFile } from "../../core/asset/asset-placement.js"; import { WORKFLOW_EXTENSIONS } from "../../core/recognition-util.js"; import { spawnSync } from "../../runtime.js"; import { buildFileContext } from "./file-context.js"; const ALWAYS_SKIP_DIRS = new Set([".git"]); const AKM_SKIP_DIRS = new Set(["node_modules", "bin", ".cache"]); /** * Walk a type root directory and return files grouped by their parent directory. * * Only files relevant to the given `assetType` are included (e.g. `.md` for * commands, script extensions for scripts, `SKILL.md` for skills). */ export function walkStash(typeRoot, assetType) { if (!fs.existsSync(typeRoot)) return []; const groups = new Map(); const stack = [typeRoot]; while (stack.length > 0) { const current = stack.pop(); if (!current) continue; const entries = fs.readdirSync(current, { withFileTypes: true }); for (const entry of entries) { if (entry.name === ".stash.json") continue; const fullPath = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile() && isRelevantAssetFile(assetType, entry.name)) { const parentDir = path.dirname(fullPath); const existing = groups.get(parentDir); if (existing) { existing.push(fullPath); } else { groups.set(parentDir, [fullPath]); } } } } return Array.from(groups, ([dirPath, files]) => ({ dirPath, files })); } /** * Walk an entire stash root directory and return FileContext objects for every * regular file found. * * Unlike walkStash(), this does NOT filter by asset type or require files to * live under type-specific directories. Matchers decide what each file is. * * If the directory is a git repo, uses `git ls-files` to respect .gitignore. * Otherwise falls back to a manual walk that skips .git, node_modules, bin, * .cache, dot-directories, and the legacy metadata sidecar. */ export function walkStashFlat(stashRoot, options = {}) { return walkStashFlatWithStatus(stashRoot, options).files; } export function walkStashFlatWithStatus(stashRoot, options = {}) { if (!fs.existsSync(stashRoot)) return { files: [], complete: false }; // Try git-based walk first (respects .gitignore) const gitResult = walkStashGit(stashRoot, options); if (gitResult) return gitResult; // Fallback: manual walk return walkStashManual(stashRoot, options); } /** * Walk using `git ls-files` to respect .gitignore. * Returns null if the directory is not a git repo or git fails. */ function walkStashGit(stashRoot, options) { // Quick check: is this a git repo? Look for .git in this dir or parents. if (!isInsideGitRepo(stashRoot)) return null; // Get tracked + untracked (non-ignored) files const result = spawnSync(["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "."], { cwd: stashRoot, }); // result.success is false if the process exited non-zero OR git was not found if (!result.success) return null; // `--cached` includes tracked files deleted from the worktree. They are not // scan failures and must not make an otherwise complete snapshot look stale. const deletedResult = spawnSync(["git", "ls-files", "--deleted", "-z", "--", "."], { cwd: stashRoot }); if (!deletedResult.success) return null; const deletedStdout = Buffer.isBuffer(deletedResult.stdout) ? deletedResult.stdout.toString("utf8") : String(deletedResult.stdout ?? ""); const deletedFiles = new Set(deletedStdout.split("\0").filter(Boolean)); // Data-hygiene filename skips: the legacy metadata sidecar (never indexed as // content — Chunk-8 folds it into the bundle format) plus git dot-files. const SKIP_FILES = new Set([".stash.json", ".gitignore", ".gitattributes"]); const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : String(result.stdout ?? ""); const files = stdout .split("\0") .filter((f) => f.length > 0) .filter((f) => !deletedFiles.has(f)) .filter((f) => !f.startsWith("..") && !path.isAbsolute(f)) .filter((f) => { const dirParts = path .dirname(f) .split(/[\\/]+/) .filter(Boolean); return !dirParts.some((part) => ALWAYS_SKIP_DIRS.has(part) || (!options.includeAllDirectories && (AKM_SKIP_DIRS.has(part) || part.startsWith(".")))); }) .filter((f) => !SKIP_FILES.has(path.basename(f))); const results = []; let complete = true; for (const relFile of files) { const absPath = path.join(stashRoot, relFile); try { // lstat, not stat: a tracked symlink must not be dereferenced. statSync // follows the link, so a target outside stashRoot would be read and // indexed. The manual walk below already skips symlinks for exactly this // reason; the two walkers have to agree regardless of whether the stash // happens to sit inside a git repo. const stat = fs.lstatSync(absPath); if (stat.isFile() || (stat.isSymbolicLink() && isOwnedWorkflowSymlink(stashRoot, absPath, options))) { results.push(buildFileContext(stashRoot, absPath)); } } catch { // File may have been deleted since git ls-files ran complete = false; } } return { files: results, complete }; } /** * Check if a directory is inside a git repository by walking up to find .git. * Intentionally walks above stashRoot so that parent repo .gitignore rules * apply when the stash is nested inside a larger git repository. */ function isInsideGitRepo(dir) { let current = path.resolve(dir); const root = path.parse(current).root; while (current !== root) { try { const gitDir = path.join(current, ".git"); const stat = fs.statSync(gitDir); if (stat.isDirectory() || stat.isFile()) return true; } catch { // .git doesn't exist at this level, keep climbing } const parent = path.dirname(current); if (parent === current) break; current = parent; } return false; } /** * Recursively collect every `.md` file under `root`. * * Used by memory-inference. Mirrors the `complete` tracking of the other * walkers in this file: a directory that cannot be read makes the result * incomplete instead of silently looking like a clean, empty scan. */ export function walkMarkdownFiles(root) { const files = []; let complete = true; const stack = [root]; while (stack.length > 0) { const current = stack.pop(); if (!current) continue; let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { complete = false; continue; } for (const entry of entries) { const full = path.join(current, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { stack.push(full); } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { files.push(full); } } } return { files, complete }; } /** Manual walk for non-git directories. */ function walkStashManual(stashRoot, options) { const results = []; let complete = true; const stack = [stashRoot]; while (stack.length > 0) { const current = stack.pop(); if (!current) continue; let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { complete = false; continue; } for (const entry of entries) { if (entry.name === ".stash.json") continue; const fullPath = path.join(current, entry.name); if (entry.isSymbolicLink()) { // Native workflow components arbitrate file symlinks explicitly in the // drain before reading them. Directory symlinks remain unfollowed. if (isOwnedWorkflowSymlink(stashRoot, fullPath, options)) { results.push(buildFileContext(stashRoot, fullPath)); } continue; } if (entry.isDirectory()) { if (ALWAYS_SKIP_DIRS.has(entry.name) || (!options.includeAllDirectories && (AKM_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")))) continue; stack.push(fullPath); } else if (entry.isFile()) { results.push(buildFileContext(stashRoot, fullPath)); } } } return { files: results, complete }; } function isOwnedWorkflowSymlink(stashRoot, candidatePath, options) { const adapterId = options.workflowSymlinkAdapter; if (!adapterId) return false; const relativePath = path.relative(path.resolve(stashRoot), path.resolve(candidatePath)).replaceAll("\\", "/"); if (!relativePath || relativePath.startsWith("../") || path.posix.isAbsolute(relativePath)) return false; if (adapterId === "akm" && !relativePath.startsWith("workflows/")) return false; const extension = path.posix.extname(relativePath).toLowerCase(); return WORKFLOW_EXTENSIONS.includes(extension); }