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
295 lines (294 loc) • 15.9 kB
JavaScript
// 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/.
/**
* Ref RESOLUTION layer — akm 0.9.0 Chunk-5 flip, stage F1 (ref-grammar decision
* D-R1 / D-R4).
*
* The 0.9.0 ref abstraction is `parse → resolve → serialize`:
* - parse — `parseBundleRef` (`asset-ref.ts`), pure syntax, no I/O.
* - resolve — {@link resolveRef} (this module): turn a maybe-short
* {@link BundleRef} into a fully-qualified {@link ResolvedRef}
* against an injected {@link RefContext}. This is the "the short
* form is input sugar" rule (§11.1) made structural: only a
* `ResolvedRef` ever crosses a storage boundary.
* - serialize — `bundleRefToString` (`asset-ref.ts`); serializing a
* `ResolvedRef` always emits the fully-qualified form.
*
* ── Cycle-safety (HARD constraint) ──
*
* This is a PURE LEAF. It imports only sibling leaves (`asset-ref`,
* `asset-placement`) and the `core/errors` sink — nothing from the indexer-db
* SCC and no `Database`/config handle. The resolution surface is INJECTED via
* {@link RefContext}; callers build the context from their own db handle /
* installation list and hand it in. Keeping the resolver free of I/O is what
* lets it stay out of every import cycle.
*
* ── Input-boundary parser (new-grammar only) ──
*
* {@link parseRefInput} parses a RAW user/CLI/API ref in the 0.9.0
* `[bundle//]conceptId` grammar and returns an {@link AssetRef} value object via
* the permanent D-R2 reverse table {@link typeNameFromConceptId}. Post-Chunk-8
* every durable ref reaching a reader is already the new grammar, so this is the
* ONE ref parser outside the frozen migrator — there is no stored-ref dual
* grammar to bridge any more.
*/
import { NotFoundError, UsageError } from "../errors.js";
import { deriveCanonicalAssetNameFromStashRoot, placementSpecFor, stashDirFor, typeForStashDir, } from "./asset-placement.js";
import { isBundleSlug, parseBundleRef } from "./asset-ref.js";
/**
* Resolve a maybe-short input ref to a fully-qualified {@link ResolvedRef}
* against `ctx`, implementing D-R4 exactly:
*
* 1. Already-qualified input (`bundle//…`) → that bundle (passthrough). When
* `ctx.only` is set and disagrees, that is a not-found (the ref names a
* bundle the caller scoped out).
* 2. Short input, `only` set → resolve to `only` iff it contains the concept.
* 3. Short input, no `only` → `defaultBundle` if it contains the concept,
* otherwise the FIRST bundle (priority order) that contains it.
* 4. No match → {@link NotFoundError} naming the forms tried.
*
* The `#fragment` is carried through untouched.
*/
export function resolveRef(input, ctx) {
const ref = typeof input === "string" ? parseBundleRef(input) : input;
const { conceptId, fragment } = ref;
// 1. Qualified passthrough — an explicit bundle prefix wins.
if (ref.bundle !== undefined) {
if (ctx.only !== undefined && ref.bundle !== ctx.only) {
throw notFound(conceptId, [`${ref.bundle}//${conceptId}`], ctx);
}
return { bundle: ref.bundle, conceptId, fragment };
}
// `only` scoping restricts the candidate set to a single bundle.
const candidates = ctx.only !== undefined ? ctx.bundles.filter((b) => b.id === ctx.only) : ctx.bundles;
// 2/3. defaultBundle wins over priority order for a short ref (but only when
// not `only`-scoped; an `only` request never falls back to the default).
if (ctx.only === undefined && ctx.defaultBundle !== undefined) {
const def = ctx.bundles.find((b) => b.id === ctx.defaultBundle);
if (def?.hasConcept(conceptId))
return { bundle: def.id, conceptId, fragment };
}
// First candidate (priority order) that contains the concept.
for (const b of candidates) {
if (b.hasConcept(conceptId))
return { bundle: b.id, conceptId, fragment };
}
throw notFound(conceptId, [conceptId], ctx);
}
function notFound(conceptId, triedForms, ctx) {
const scope = ctx.only !== undefined ? ` in bundle "${ctx.only}"` : "";
const forms = triedForms.map((f) => `"${f}"`).join(", ");
return new NotFoundError(`No bundle contains concept "${conceptId}"${scope} (tried ${forms}).`, "ASSET_NOT_FOUND");
}
/**
* D-R2 conceptId derivation from an asset `type`/`name` pair
* (`stashDirFor(type)/name`; bare name for a foreign type with no placement
* stash-subdir). Kept self-contained so {@link displayRef} — a PERMANENT display
* rule remains independent of input parsing.
*
* Exported (Chunk-8 WI-8.5c) as the ONE conceptId derivation the improve
* correlation sites (`eligibility.ts` candidate refs, `salience.ts`
* last-use lookup) share with the
* display rule — the permanent successor to the retired transient
* `legacyConceptId`.
*/
export function conceptIdFromTypeName(type, name) {
const stashDir = stashDirFor(type);
return stashDir !== undefined ? `${stashDir}/${name}` : name;
}
/**
* User-facing conceptId for a file on disk, derived through the placement
* spec's canonical-name rule — the ONE way a diagnostic should spell a ref it
* expects the user to paste into `akm show`. (The dangerous-env-key lint used
* to hand-build `env:<base>` colon refs the parser rejects; both its emission
* sites now route through here.) For a type with no placement spec — which no
* built-in caller passes — falls back to the raw name so the output is still
* informative rather than empty.
*/
export function conceptIdForStashFile(type, stashRoot, filePath) {
const name = deriveCanonicalAssetNameFromStashRoot(type, stashRoot, filePath);
return name === undefined ? filePath : conceptIdFromTypeName(type, name);
}
/**
* Build the USER-FACING / envelope ref string for an indexed item, applying the
* Chunk-5 flip F4b output-spelling rule (orchestrator decision; ref-grammar
* decision D-R2 / D-R3). This is the ONE place the rule lives — every emission
* site calls it instead of hand-building a ref from an entry.
*
* The rule mirrors TODAY'S origin-qualification UX, transposed to the 0.9.0
* grammar:
*
* - An item in the **default/primary bundle** (`bundleId` undefined, or equal
* to `defaultBundleId`) emits the SHORT conceptId (`knowledge/http-caching`)
* — exactly where the pre-0.9.0 output emitted an un-qualified `type:name`.
* - Any other **non-default bundle** emits the fully-qualified
* `bundle//conceptId`. Post-Chunk-8 every bundle id is a legal slug (the
* config migration assigned each source its D-R5 slug bundle key), so this
* is always the new grammar — a non-slug registryId now displays under its
* derived slug bundle id, never the retired `origin//type:name` spelling.
*/
export function displayRef(item, defaultBundleId) {
return displayRefForConceptId(item.conceptId ?? conceptIdFromTypeName(item.type, item.name), item.bundleId, defaultBundleId);
}
/**
* The F4b output-spelling flip itself, for a caller that already holds the
* conceptId (no `type`/`name` derivation needed — e.g. lint findings built
* from {@link conceptIdForStashFile}). {@link displayRef} delegates here, so
* the short-default / qualified-secondary rule still has exactly one home.
*/
export function displayRefForConceptId(conceptId, bundleId, defaultBundleId) {
// Default/primary bundle → SHORT conceptId (the flip).
if (bundleId === undefined || bundleId === defaultBundleId)
return conceptId;
// Non-default bundle → the new fully-qualified `bundle//conceptId` grammar.
return `${bundleId}//${conceptId}`;
}
/**
* Split a qualified conceptId (`<stash-subdir>/<name>`) into its asset
* `type`/`name`, or `undefined` when the leading segment is not a known stash
* subdir (a bare-name conceptId from a foreign type — no legacy predicate
* applies). The PERMANENT D-R2 reverse table: the input boundary uses it to map
* a new-grammar conceptId onto today's {@link AssetRef} shape. (The migrate home
* keeps a private transient copy for stored-ref parsing.)
*/
export function typeNameFromConceptId(conceptId) {
const slash = conceptId.indexOf("/");
if (slash <= 0)
return undefined;
const type = typeForStashDir(conceptId.slice(0, slash));
if (type === undefined)
return undefined;
return { type, name: conceptId.slice(slash + 1) };
}
/**
* D11 — the opaque-adapter-conceptId fallback. `typeNameFromConceptId` only
* ever answers for the PLACEMENT_SPECS stash-resident subset (D-R2); a
* conceptId whose leading segment is NOT a registered placement stashDir is
* still perfectly legal DATA per D11 — an OKF item (`tables/customers`), a
* website page, a wiki pageKind, an adapter `instruction` doc, … — and the
* ref-consuming commands must accept it rather than treat "not an AKM
* placement dir" as "malformed ref". This function draws the line: it accepts
* any well-formed `<segment>/<rest>` conceptId (so the shape is still
* anchored — a bare no-slash name stays the caller's job to pre-qualify with a
* default type, the existing env/secret/`akm mv` "bare name" convenience), and
* REJECTS anything shaped like the retired `type:name` colon grammar (Q-02):
* a `:` in the leading segment is that grammar smuggled through a conceptId
* string (e.g. `script:db/migrate/run.sh`, `workflow:release/train`), not a
* real adapter directory name, so it is refused rather than silently
* reinterpreted as opaque data.
*
* `name` deliberately carries the FULL original conceptId, not just the tail.
* This is what makes the pair round-trip through the UNCHANGED
* {@link conceptIdFromTypeName} (`stashDirFor(type)/name`, bare `name` when
* `type` has no placement stashDir): since an opaque `type` never owns a
* placement stashDir, `conceptIdFromTypeName(type, name)` falls to its bare-
* `name` branch and returns `name` verbatim — the original conceptId,
* unchanged — instead of losing the leading segment. `type` itself carries
* the leading segment (informational/behavioral use: `.type === "lesson"`
* checks correctly miss for opaque data) UNLESS that segment happens to
* collide with a real PLACEMENT_SPECS type key that merely uses a different
* stashDir spelling (e.g. a foreign top-level dir literally named "skill",
* singular) — that pathological case would make `stashDirFor` succeed on the
* "type" and corrupt the round-trip, so it falls back to the full conceptId as
* `type` too (never a placement key, since a placement key never contains
* `/`).
*
* This intentionally does NOT collapse the KNOWN_TYPES/PLACEMENT_SPECS split:
* `typeNameFromConceptId` (PLACEMENT_SPECS only) is untouched, and an opaque
* `type` returned here is never a {@link KnownType} — it is a passthrough
* label, not a claim that AKM recognizes or owns the concept.
*/
function opaqueRefParts(conceptId, allowRoot = false) {
const slash = conceptId.indexOf("/");
if (slash <= 0)
return allowRoot && !conceptId.includes(":") ? { type: conceptId, name: conceptId } : undefined;
const segment = conceptId.slice(0, slash);
if (segment.includes(":"))
return undefined; // retired `type:name` grammar, not opaque data (Q-02).
const type = placementSpecFor(segment) === undefined ? segment : conceptId;
return { type, name: conceptId };
}
/**
* Parse a RAW user / CLI / API ref string in the 0.9.0 `[bundle//]conceptId`
* grammar, returning it in today's {@link AssetRef} value-object shape
* (ref-grammar decision D-R1 / D-R4). All boundaries are NEW-GRAMMAR ONLY: a
* legacy `type:name` input now fails as an unknown-conceptId not-found. Post-
* Chunk-8 every durable ref is already the new grammar, so this parser also
* serves the (formerly dual-grammar) stored-ref readers.
*
* Mapping (new grammar → {@link AssetRef}):
* - `conceptId` → `type`/`name` via {@link typeNameFromConceptId} (the D-R2
* static stash-subdir table) when the leading segment is a known AKM
* placement stashDir, else via {@link opaqueRefParts} (D11 — the ref-parser
* seam accepts opaque adapter conceptIds, e.g. OKF items, website pages,
* wiki pageKinds, adapter `instruction` docs) when it is merely a well-
* formed but foreign `<segment>/<rest>` shape. A conceptId that is neither
* — no slash at all, or a retired colon-grammar shape smuggled through —
* has no type predicate: the same not-found outcome a genuine typo
* produces today.
* - `bundle` → `origin`. A new-grammar bundle slug is a registryId-shaped
* id by construction, so it flows straight into the legacy origin channel
* that `resolveSourcesForOrigin` matches on `registryId`. The SHORT form (no
* bundle) leaves `origin` undefined = search-all-sources.
* - `#fragment` → rejected. No input boundary consumes an export fragment.
*/
export function parseRefInput(raw) {
const ref = parseBundleRef(raw);
if (ref.fragment !== undefined) {
throw new UsageError(`Export fragment "#${ref.fragment}" is not accepted here — drop it from "${raw.trim()}".`, "INVALID_FLAG_VALUE");
}
const parts = typeNameFromConceptId(ref.conceptId) ?? opaqueRefParts(ref.conceptId, ref.bundle !== undefined);
if (parts === undefined) {
throw new NotFoundError(`Unrecognized asset ref "${raw.trim()}": conceptId "${ref.conceptId}" has no known asset-type prefix.`, "ASSET_NOT_FOUND");
}
return { type: parts.type, name: parts.name, origin: ref.bundle };
}
/**
* Parse a CLI/API ref that MAY be qualified by a NON-slug origin — a registry
* ref (`github:owner/repo`, `npm:@scope/pkg`, `git:host/path`), a bare path, or
* a URL — as its `origin//conceptId` prefix. Such an origin carries `:`/`.`/`/`
* so it is not a legal bundle slug and the strict {@link parseRefInput} rejects
* it; but it is still a valid SOURCE origin that `resolveSourcesForOrigin`
* matches by registry-id / path and the remote-fetch fallback can install. The
* conceptId body is parsed under the strict new grammar; the raw origin is kept
* as-is — the symmetric input side of {@link displayRef}, which likewise keeps
* `origin//…` for exactly these non-slug origins (ref-grammar decision D-R5).
*
* A short ref (no origin) or a slug-origin ref is delegated verbatim to
* {@link parseRefInput}, so this is a safe superset for origin-accepting
* commands (`show`, `clone`, `graph`, `history`).
*/
export function parseQualifiedRefInput(raw) {
const trimmed = raw.trim();
const boundary = trimmed.indexOf("//");
if (boundary > 0) {
const origin = trimmed.slice(0, boundary);
if (!isBundleSlug(origin)) {
return { ...parseRefInput(trimmed.slice(boundary + 2)), origin };
}
}
return parseRefInput(trimmed);
}
/**
* Does `raw` already read as a COMPLETE new-grammar asset ref, as opposed to a
* bare asset name that a boundary would prefix with a default type (the
* `env`/`secret`/`akm mv` "bare name" convenience)?
*
* True when `raw` is a `[bundle//]conceptId` whose conceptId leads with a KNOWN
* stash subdir ({@link typeNameFromConceptId} resolves it). A bare name like
* `prod` or `projectA/new-note` is neither — its leading segment maps to no
* type — so it stays a bare name for the caller to qualify.
*/
export function isFullRefInput(raw) {
const trimmed = raw.trim();
if (!trimmed)
return false;
try {
const parsed = parseBundleRef(trimmed);
return parsed.bundle !== undefined || typeNameFromConceptId(parsed.conceptId) !== undefined;
}
catch {
return false;
}
}