akm-cli 0.7.5 → 0.8.0-rc.6
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/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
- package/README.md +20 -4
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli.js +1995 -551
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +1531 -0
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +990 -75
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +5 -23
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +400 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +77 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-profiles.js +146 -0
- package/dist/commands/improve-result-file.js +103 -0
- package/dist/commands/improve.js +2175 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/index.js +183 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/vault-key-rules.js +139 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal.js +66 -12
- package/dist/commands/propose.js +86 -31
- package/dist/commands/reflect.js +1119 -73
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember.js +69 -6
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +144 -25
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +438 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/commands/vault.js +130 -77
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +7 -0
- package/dist/core/asset-registry.js +7 -16
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +22 -0
- package/dist/core/common.js +157 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +625 -0
- package/dist/core/config-schema.js +501 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +327 -987
- package/dist/core/errors.js +40 -19
- package/dist/core/events.js +91 -138
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +3 -6
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +326 -14
- package/dist/core/proposal-quality-validators.js +364 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +498 -42
- package/dist/core/state-db.js +927 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/warn.js +62 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +152 -253
- package/dist/indexer/db.js +933 -103
- package/dist/indexer/ensure-index.js +64 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -124
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +506 -291
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +148 -160
- package/dist/indexer/memory-inference.js +99 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +255 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +5 -16
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +150 -74
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +118 -23
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +68 -0
- package/dist/integrations/session-logs/providers/claude-code.js +59 -0
- package/dist/integrations/session-logs/providers/opencode.js +55 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +72 -124
- package/dist/llm/embedder.js +3 -19
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +3 -0
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +89 -48
- package/dist/llm/graph-extract.js +676 -70
- package/dist/llm/index-passes.js +9 -23
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +281 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +5 -318
- package/dist/output/context.js +3 -0
- package/dist/output/renderers.js +223 -256
- package/dist/output/shapes.js +150 -105
- package/dist/output/text.js +318 -30
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +3 -0
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +70 -49
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17307 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +7 -5
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +211 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +62 -91
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +9 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +20 -8
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -381
- package/dist/templates/wiki-templates.js +0 -100
package/dist/commands/info.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
1
4
|
import fs from "node:fs";
|
|
2
5
|
import { getAssetTypes } from "../core/asset-spec";
|
|
3
|
-
import { loadConfig } from "../core/config";
|
|
6
|
+
import { getSources, loadConfig } from "../core/config";
|
|
4
7
|
import { getDbPath } from "../core/paths";
|
|
5
8
|
import { closeDatabase, getEntryCount, getMeta, isVecAvailable, openExistingDatabase } from "../indexer/db";
|
|
6
9
|
import { getEffectiveSemanticStatus, readSemanticStatus } from "../indexer/semantic-status";
|
|
@@ -32,7 +35,7 @@ export function assembleInfo(options) {
|
|
|
32
35
|
// Stash providers — prefer `sources[]`; fall back to `stashDir` when the
|
|
33
36
|
// user has not yet migrated to the sources[] config shape so that info
|
|
34
37
|
// always reflects at least one provider when a stash is configured.
|
|
35
|
-
const configuredSources = config
|
|
38
|
+
const configuredSources = getSources(config);
|
|
36
39
|
const stashesList = configuredSources.length === 0 && config.stashDir
|
|
37
40
|
? [{ type: "filesystem", path: config.stashDir, name: "primary" }]
|
|
38
41
|
: configuredSources;
|
package/dist/commands/init.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
1
4
|
/**
|
|
2
5
|
* akm initialization logic.
|
|
3
6
|
*
|
|
@@ -8,11 +11,56 @@ import { spawnSync } from "node:child_process";
|
|
|
8
11
|
import fs from "node:fs";
|
|
9
12
|
import path from "node:path";
|
|
10
13
|
import { TYPE_DIRS } from "../core/asset-spec";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
14
|
+
import { loadUserConfig, saveConfig } from "../core/config";
|
|
15
|
+
import { ConfigError } from "../core/errors";
|
|
16
|
+
import { assertSafeStashDir, getBinDir, getConfigPath, getDefaultStashDir } from "../core/paths";
|
|
13
17
|
import { ensureRg } from "../setup/ripgrep-install";
|
|
18
|
+
/**
|
|
19
|
+
* Refuse to persist a temporary-directory stashDir to the user's config when
|
|
20
|
+
* running under a test runner AND `--dir <tempdir>` was passed explicitly.
|
|
21
|
+
* This guard targets the exact agent-overreach pattern documented in
|
|
22
|
+
* `memory:akm-init-persists-stashdir-warning`: an agent ran
|
|
23
|
+
* `akm init --dir $(mktemp -d)` for an E2E test and silently rewrote the
|
|
24
|
+
* developer's real config to point at a now-deleted temp dir.
|
|
25
|
+
*
|
|
26
|
+
* Tests that legitimately resolve a tempdir via HOME (default-path init) are
|
|
27
|
+
* unaffected — those are normal `~/akm` resolutions and not the failure mode.
|
|
28
|
+
*
|
|
29
|
+
* Test sentinels (either suffices):
|
|
30
|
+
* - `BUN_TEST=1` — explicit opt-in
|
|
31
|
+
* - `NODE_ENV=test` — what `bun test` sets today
|
|
32
|
+
*
|
|
33
|
+
* Tests that genuinely need to exercise `akm init --dir /tmp/...` should set
|
|
34
|
+
* `AKM_FORCE_INIT_TMP_STASH=1`.
|
|
35
|
+
*/
|
|
36
|
+
function assertInitSandbox(stashDir, dirExplicitlyProvided) {
|
|
37
|
+
if (!dirExplicitlyProvided)
|
|
38
|
+
return; // Only guard explicit --dir, not default HOME resolution.
|
|
39
|
+
const isUnderTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
|
|
40
|
+
if (!isUnderTest)
|
|
41
|
+
return;
|
|
42
|
+
if (process.env.AKM_FORCE_INIT_TMP_STASH === "1")
|
|
43
|
+
return;
|
|
44
|
+
const isTmp = stashDir.startsWith("/tmp/") ||
|
|
45
|
+
stashDir === "/tmp" ||
|
|
46
|
+
stashDir.startsWith("/var/tmp/") ||
|
|
47
|
+
stashDir === "/var/tmp" ||
|
|
48
|
+
stashDir.startsWith("/private/var/folders/") ||
|
|
49
|
+
stashDir.startsWith("/private/tmp/");
|
|
50
|
+
if (!isTmp)
|
|
51
|
+
return;
|
|
52
|
+
throw new ConfigError(`refusing to persist --dir stashDir to a temporary path while under test runner; set AKM_FORCE_INIT_TMP_STASH=1 if you really mean it (stashDir=${stashDir})`, "INIT_TMP_STASH_REFUSED");
|
|
53
|
+
}
|
|
14
54
|
export async function akmInit(options) {
|
|
15
55
|
const stashDir = options?.dir ? path.resolve(options.dir) : getDefaultStashDir();
|
|
56
|
+
// Safety check (#473): refuse stashDir at /, $HOME, /etc, ~/.config, etc.
|
|
57
|
+
// Runs BEFORE any disk write — a fat-fingered `akm init --dir /` or
|
|
58
|
+
// `akm init --dir ~` would otherwise mkdir + git-init the user's system
|
|
59
|
+
// root or home directory. Catastrophic-on-misuse vs. trivial-to-recover-from.
|
|
60
|
+
assertSafeStashDir(stashDir);
|
|
61
|
+
// Defense-in-depth: refuse to persist an explicit --dir /tmp/... stashDir
|
|
62
|
+
// to config under a test runner. Default HOME-resolved paths are exempt.
|
|
63
|
+
assertInitSandbox(stashDir, options?.dir != null);
|
|
16
64
|
let created = false;
|
|
17
65
|
if (!fs.existsSync(stashDir)) {
|
|
18
66
|
fs.mkdirSync(stashDir, { recursive: true });
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
1
4
|
/**
|
|
2
5
|
* Source operations: list, remove, update.
|
|
3
6
|
*
|
|
@@ -7,7 +10,7 @@
|
|
|
7
10
|
import fs from "node:fs";
|
|
8
11
|
import path from "node:path";
|
|
9
12
|
import { isWithin, resolveStashDir } from "../core/common";
|
|
10
|
-
import { loadConfig } from "../core/config";
|
|
13
|
+
import { getSources, loadConfig } from "../core/config";
|
|
11
14
|
import { NotFoundError, UsageError } from "../core/errors";
|
|
12
15
|
import { akmIndex } from "../indexer/indexer";
|
|
13
16
|
import { removeLockEntry, upsertLockEntry } from "../integrations/lockfile";
|
|
@@ -16,7 +19,6 @@ import { parseGitRepoUrl, syncMirroredRepo } from "../sources/providers/git";
|
|
|
16
19
|
import { syncFromRef } from "../sources/providers/sync-from-ref";
|
|
17
20
|
import { ensureWebsiteMirror } from "../sources/website-ingest";
|
|
18
21
|
import { listWikis, resolveWikisRoot } from "../wiki/wiki";
|
|
19
|
-
import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailure, } from "./install-audit";
|
|
20
22
|
import { removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./source-add";
|
|
21
23
|
import { removeStash } from "./source-manage";
|
|
22
24
|
export async function akmListSources(input) {
|
|
@@ -26,7 +28,7 @@ export async function akmListSources(input) {
|
|
|
26
28
|
const sources = [];
|
|
27
29
|
// Stash entries — each entry exposes its provider type as kind (spec §2.1).
|
|
28
30
|
// Writable defaults: true for filesystem, false for git/npm/website (CLAUDE.md "Writes").
|
|
29
|
-
for (const stash of config
|
|
31
|
+
for (const stash of getSources(config)) {
|
|
30
32
|
const kind = stash.type ?? "filesystem";
|
|
31
33
|
if (kindFilter && !kindFilter.includes(kind))
|
|
32
34
|
continue;
|
|
@@ -119,7 +121,7 @@ export async function akmRemove(input) {
|
|
|
119
121
|
stashRoot: entry.stashRoot,
|
|
120
122
|
},
|
|
121
123
|
config: {
|
|
122
|
-
sourceCount: (updatedConfig
|
|
124
|
+
sourceCount: getSources(updatedConfig).length,
|
|
123
125
|
installedKitCount: updatedConfig.installed?.length ?? 0,
|
|
124
126
|
},
|
|
125
127
|
index: {
|
|
@@ -150,7 +152,7 @@ export async function akmRemove(input) {
|
|
|
150
152
|
stashRoot: removedEntry.path ?? "",
|
|
151
153
|
},
|
|
152
154
|
config: {
|
|
153
|
-
sourceCount: (updatedConfig
|
|
155
|
+
sourceCount: getSources(updatedConfig).length,
|
|
154
156
|
installedKitCount: updatedConfig.installed?.length ?? 0,
|
|
155
157
|
},
|
|
156
158
|
index: {
|
|
@@ -161,6 +163,91 @@ export async function akmRemove(input) {
|
|
|
161
163
|
},
|
|
162
164
|
};
|
|
163
165
|
}
|
|
166
|
+
// ── akmUpdate helpers ────────────────────────────────────────────────────────
|
|
167
|
+
/** Build a standard UpdateResponse summary block from the current config and index run. */
|
|
168
|
+
async function buildUpdateResponse(stashDir, target, all, processed, full = false) {
|
|
169
|
+
const index = await akmIndex({ stashDir, ...(full ? { full: true } : {}) });
|
|
170
|
+
const finalConfig = loadConfig();
|
|
171
|
+
return {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
stashDir,
|
|
174
|
+
target,
|
|
175
|
+
all,
|
|
176
|
+
processed,
|
|
177
|
+
config: {
|
|
178
|
+
sourceCount: getSources(finalConfig).length,
|
|
179
|
+
installedKitCount: finalConfig.installed?.length ?? 0,
|
|
180
|
+
},
|
|
181
|
+
index: {
|
|
182
|
+
mode: index.mode,
|
|
183
|
+
totalEntries: index.totalEntries,
|
|
184
|
+
directoriesScanned: index.directoriesScanned,
|
|
185
|
+
directoriesSkipped: index.directoriesSkipped,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Sync a git-mirrored source and return an UpdateResponse. */
|
|
190
|
+
async function updateGitSource(stashDir, target, all, gitSource) {
|
|
191
|
+
await syncMirroredRepo(gitSource, { force: true, writable: gitSource.writable === true });
|
|
192
|
+
return buildUpdateResponse(stashDir, target, all, [], true);
|
|
193
|
+
}
|
|
194
|
+
/** Re-crawl a website source and return an UpdateResponse. */
|
|
195
|
+
async function updateWebsiteSource(stashDir, target, all, websiteSource) {
|
|
196
|
+
// TODO: full incremental re-crawl with delta tracking (#19)
|
|
197
|
+
await ensureWebsiteMirror(websiteSource, { requireStashDir: true, force: true });
|
|
198
|
+
return buildUpdateResponse(stashDir, target, all, []);
|
|
199
|
+
}
|
|
200
|
+
/** Sync a single installed registry entry and return the processed record. */
|
|
201
|
+
async function updateRegistryEntry(entry, force) {
|
|
202
|
+
if (force && shouldCleanupCache(entry)) {
|
|
203
|
+
cleanupDirectoryBestEffort(entry.cacheDir);
|
|
204
|
+
}
|
|
205
|
+
const synced = await syncFromRef(entry.ref, { force });
|
|
206
|
+
const installedEntry = {
|
|
207
|
+
id: synced.id,
|
|
208
|
+
source: synced.source,
|
|
209
|
+
ref: synced.ref,
|
|
210
|
+
artifactUrl: synced.artifactUrl,
|
|
211
|
+
resolvedVersion: synced.resolvedVersion,
|
|
212
|
+
resolvedRevision: synced.resolvedRevision,
|
|
213
|
+
stashRoot: synced.contentDir,
|
|
214
|
+
cacheDir: synced.cacheDir,
|
|
215
|
+
installedAt: synced.syncedAt,
|
|
216
|
+
writable: synced.writable ?? entry.writable,
|
|
217
|
+
...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
|
|
218
|
+
};
|
|
219
|
+
upsertInstalledRegistryEntry(installedEntry);
|
|
220
|
+
await upsertLockEntry({
|
|
221
|
+
id: synced.id,
|
|
222
|
+
source: synced.source,
|
|
223
|
+
ref: synced.ref,
|
|
224
|
+
resolvedVersion: synced.resolvedVersion,
|
|
225
|
+
resolvedRevision: synced.resolvedRevision,
|
|
226
|
+
integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
|
|
227
|
+
});
|
|
228
|
+
if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
|
|
229
|
+
cleanupDirectoryBestEffort(entry.cacheDir);
|
|
230
|
+
}
|
|
231
|
+
const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
|
|
232
|
+
const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
|
|
233
|
+
return {
|
|
234
|
+
id: entry.id,
|
|
235
|
+
source: entry.source,
|
|
236
|
+
ref: entry.ref,
|
|
237
|
+
previous: {
|
|
238
|
+
resolvedVersion: entry.resolvedVersion,
|
|
239
|
+
resolvedRevision: entry.resolvedRevision,
|
|
240
|
+
cacheDir: entry.cacheDir,
|
|
241
|
+
},
|
|
242
|
+
installed: { ...installedEntry, extractedDir: synced.extractedDir },
|
|
243
|
+
changed: {
|
|
244
|
+
version: versionChanged,
|
|
245
|
+
revision: revisionChanged,
|
|
246
|
+
any: versionChanged || revisionChanged,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// ── akmUpdate dispatcher ─────────────────────────────────────────────────────
|
|
164
251
|
export async function akmUpdate(input) {
|
|
165
252
|
const stashDir = input?.stashDir ?? resolveStashDir();
|
|
166
253
|
const target = input?.target?.trim();
|
|
@@ -168,10 +255,10 @@ export async function akmUpdate(input) {
|
|
|
168
255
|
const force = input?.force === true;
|
|
169
256
|
const config = loadConfig();
|
|
170
257
|
const installedEntries = config.installed ?? [];
|
|
171
|
-
// Check if the target refers to a website source — those are
|
|
172
|
-
//
|
|
258
|
+
// Check if the target refers to a git or website source — those are stored
|
|
259
|
+
// in sources[] not installed[] and need a different update path.
|
|
173
260
|
if (target && !all) {
|
|
174
|
-
const stashes = config
|
|
261
|
+
const stashes = getSources(config);
|
|
175
262
|
const isUrl = target.startsWith("http://") || target.startsWith("https://");
|
|
176
263
|
const resolvedPath = !isUrl ? path.resolve(target) : undefined;
|
|
177
264
|
const gitMatch = stashes.find((s) => {
|
|
@@ -195,28 +282,8 @@ export async function akmUpdate(input) {
|
|
|
195
282
|
}
|
|
196
283
|
return false;
|
|
197
284
|
});
|
|
198
|
-
if (gitMatch)
|
|
199
|
-
|
|
200
|
-
const index = await akmIndex({ stashDir, full: true });
|
|
201
|
-
const updatedConfig = loadConfig();
|
|
202
|
-
return {
|
|
203
|
-
schemaVersion: 1,
|
|
204
|
-
stashDir,
|
|
205
|
-
target,
|
|
206
|
-
all,
|
|
207
|
-
processed: [],
|
|
208
|
-
config: {
|
|
209
|
-
sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
|
|
210
|
-
installedKitCount: updatedConfig.installed?.length ?? 0,
|
|
211
|
-
},
|
|
212
|
-
index: {
|
|
213
|
-
mode: index.mode,
|
|
214
|
-
totalEntries: index.totalEntries,
|
|
215
|
-
directoriesScanned: index.directoriesScanned,
|
|
216
|
-
directoriesSkipped: index.directoriesSkipped,
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
}
|
|
285
|
+
if (gitMatch)
|
|
286
|
+
return updateGitSource(stashDir, target, all, gitMatch);
|
|
220
287
|
const websiteMatch = stashes.find((s) => {
|
|
221
288
|
if (s.type !== "website")
|
|
222
289
|
return false;
|
|
@@ -228,119 +295,15 @@ export async function akmUpdate(input) {
|
|
|
228
295
|
return true;
|
|
229
296
|
return false;
|
|
230
297
|
});
|
|
231
|
-
if (websiteMatch)
|
|
232
|
-
|
|
233
|
-
await ensureWebsiteMirror(websiteMatch, { requireStashDir: true, force: true });
|
|
234
|
-
const index = await akmIndex({ stashDir });
|
|
235
|
-
const updatedConfig = loadConfig();
|
|
236
|
-
return {
|
|
237
|
-
schemaVersion: 1,
|
|
238
|
-
stashDir,
|
|
239
|
-
target,
|
|
240
|
-
all,
|
|
241
|
-
processed: [],
|
|
242
|
-
config: {
|
|
243
|
-
sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
|
|
244
|
-
installedKitCount: updatedConfig.installed?.length ?? 0,
|
|
245
|
-
},
|
|
246
|
-
index: {
|
|
247
|
-
mode: index.mode,
|
|
248
|
-
totalEntries: index.totalEntries,
|
|
249
|
-
directoriesScanned: index.directoriesScanned,
|
|
250
|
-
directoriesSkipped: index.directoriesSkipped,
|
|
251
|
-
},
|
|
252
|
-
};
|
|
253
|
-
}
|
|
298
|
+
if (websiteMatch)
|
|
299
|
+
return updateWebsiteSource(stashDir, target, all, websiteMatch);
|
|
254
300
|
}
|
|
255
301
|
const selectedEntries = selectTargets(installedEntries, target, all);
|
|
256
|
-
const auditConfig = config;
|
|
257
302
|
const processed = [];
|
|
258
303
|
for (const entry of selectedEntries) {
|
|
259
|
-
|
|
260
|
-
cleanupDirectoryBestEffort(entry.cacheDir);
|
|
261
|
-
}
|
|
262
|
-
const synced = await syncFromRef(entry.ref, { force });
|
|
263
|
-
// Mirror the post-sync audit hook from akmAdd so `akm update` can't
|
|
264
|
-
// silently land malicious content during refresh.
|
|
265
|
-
const registryLabels = deriveRegistryLabels({
|
|
266
|
-
source: synced.source,
|
|
267
|
-
ref: synced.ref,
|
|
268
|
-
artifactUrl: synced.artifactUrl,
|
|
269
|
-
});
|
|
270
|
-
enforceRegistryInstallPolicy(registryLabels, auditConfig, entry.ref);
|
|
271
|
-
const audit = auditInstallCandidate({
|
|
272
|
-
rootDir: synced.extractedDir,
|
|
273
|
-
source: synced.source,
|
|
274
|
-
ref: synced.ref,
|
|
275
|
-
registryLabels,
|
|
276
|
-
config: auditConfig,
|
|
277
|
-
});
|
|
278
|
-
if (audit.blocked) {
|
|
279
|
-
throw new Error(formatInstallAuditFailure(synced.ref, audit));
|
|
280
|
-
}
|
|
281
|
-
const installedEntry = {
|
|
282
|
-
id: synced.id,
|
|
283
|
-
source: synced.source,
|
|
284
|
-
ref: synced.ref,
|
|
285
|
-
artifactUrl: synced.artifactUrl,
|
|
286
|
-
resolvedVersion: synced.resolvedVersion,
|
|
287
|
-
resolvedRevision: synced.resolvedRevision,
|
|
288
|
-
stashRoot: synced.contentDir,
|
|
289
|
-
cacheDir: synced.cacheDir,
|
|
290
|
-
installedAt: synced.syncedAt,
|
|
291
|
-
writable: synced.writable ?? entry.writable,
|
|
292
|
-
...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
|
|
293
|
-
};
|
|
294
|
-
upsertInstalledRegistryEntry(installedEntry);
|
|
295
|
-
await upsertLockEntry({
|
|
296
|
-
id: synced.id,
|
|
297
|
-
source: synced.source,
|
|
298
|
-
ref: synced.ref,
|
|
299
|
-
resolvedVersion: synced.resolvedVersion,
|
|
300
|
-
resolvedRevision: synced.resolvedRevision,
|
|
301
|
-
integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
|
|
302
|
-
});
|
|
303
|
-
if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
|
|
304
|
-
cleanupDirectoryBestEffort(entry.cacheDir);
|
|
305
|
-
}
|
|
306
|
-
const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
|
|
307
|
-
const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
|
|
308
|
-
processed.push({
|
|
309
|
-
id: entry.id,
|
|
310
|
-
source: entry.source,
|
|
311
|
-
ref: entry.ref,
|
|
312
|
-
previous: {
|
|
313
|
-
resolvedVersion: entry.resolvedVersion,
|
|
314
|
-
resolvedRevision: entry.resolvedRevision,
|
|
315
|
-
cacheDir: entry.cacheDir,
|
|
316
|
-
},
|
|
317
|
-
installed: { ...installedEntry, extractedDir: synced.extractedDir, audit },
|
|
318
|
-
changed: {
|
|
319
|
-
version: versionChanged,
|
|
320
|
-
revision: revisionChanged,
|
|
321
|
-
any: versionChanged || revisionChanged,
|
|
322
|
-
},
|
|
323
|
-
});
|
|
304
|
+
processed.push(await updateRegistryEntry(entry, force));
|
|
324
305
|
}
|
|
325
|
-
|
|
326
|
-
const finalConfig = loadConfig();
|
|
327
|
-
return {
|
|
328
|
-
schemaVersion: 1,
|
|
329
|
-
stashDir,
|
|
330
|
-
target,
|
|
331
|
-
all,
|
|
332
|
-
processed,
|
|
333
|
-
config: {
|
|
334
|
-
sourceCount: (finalConfig.sources ?? finalConfig.stashes ?? []).length,
|
|
335
|
-
installedKitCount: finalConfig.installed?.length ?? 0,
|
|
336
|
-
},
|
|
337
|
-
index: {
|
|
338
|
-
mode: index.mode,
|
|
339
|
-
totalEntries: index.totalEntries,
|
|
340
|
-
directoriesScanned: index.directoriesScanned,
|
|
341
|
-
directoriesSkipped: index.directoriesSkipped,
|
|
342
|
-
},
|
|
343
|
-
};
|
|
306
|
+
return buildUpdateResponse(stashDir, target, all, processed);
|
|
344
307
|
}
|
|
345
308
|
function selectTargets(installed, target, all) {
|
|
346
309
|
if (all && target) {
|
|
@@ -356,7 +319,7 @@ function selectTargets(installed, target, all) {
|
|
|
356
319
|
return [found];
|
|
357
320
|
// Check if target matches a stash source and give a helpful message
|
|
358
321
|
const config = loadConfig();
|
|
359
|
-
const stashes = config
|
|
322
|
+
const stashes = getSources(config);
|
|
360
323
|
const isUrl = target.startsWith("http://") || target.startsWith("https://");
|
|
361
324
|
const resolvedPath = !isUrl ? path.resolve(target) : undefined;
|
|
362
325
|
const stashMatch = stashes.find((s) => {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Knowledge-command helpers extracted from `src/cli.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Covers the shared pipeline for reading, naming, and writing markdown assets
|
|
8
|
+
* (knowledge and memory) from the CLI. Extracted to keep the CLI entry point
|
|
9
|
+
* focused on command definitions and routing.
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { resolveAssetPathFromName } from "../core/asset-spec";
|
|
14
|
+
import { isHttpUrl, isWithin, tryReadStdinText } from "../core/common";
|
|
15
|
+
import { loadConfig } from "../core/config";
|
|
16
|
+
import { UsageError } from "../core/errors";
|
|
17
|
+
import { resolveWriteTarget, writeAssetToSource } from "../core/write-source";
|
|
18
|
+
import { fetchWebsiteMarkdownSnapshot } from "../sources/website-ingest";
|
|
19
|
+
const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
|
|
20
|
+
// ── Asset-name normalisation ─────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Validate and normalise a markdown asset name supplied by the user.
|
|
23
|
+
*
|
|
24
|
+
* Strips the `.md` extension, rejects empty names, and guards against path
|
|
25
|
+
* traversal (`..` segments). The `fallback` is used when `name` is undefined.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeMarkdownAssetName(name, fallback) {
|
|
28
|
+
const trimmed = (name ?? fallback)
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/\\/g, "/")
|
|
31
|
+
.replace(/^\/+|\/+$/g, "")
|
|
32
|
+
.replace(/\.md$/i, "");
|
|
33
|
+
if (!trimmed)
|
|
34
|
+
throw new UsageError("Asset name cannot be empty.");
|
|
35
|
+
const segments = trimmed.split("/");
|
|
36
|
+
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
37
|
+
throw new UsageError("Asset name must be a relative path without '.' or '..' segments.");
|
|
38
|
+
}
|
|
39
|
+
return trimmed;
|
|
40
|
+
}
|
|
41
|
+
function slugifyAssetName(value, fallbackPrefix) {
|
|
42
|
+
const slug = value
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/^[#>\-\s]+/, "")
|
|
45
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
46
|
+
.replace(/^-+|-+$/g, "")
|
|
47
|
+
.slice(0, MAX_CAPTURED_ASSET_SLUG_LENGTH);
|
|
48
|
+
return slug || `${fallbackPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Derive a slug-style asset name from `content` and an optional `preferred`
|
|
52
|
+
* hint (e.g. a URL-derived page title or the source filename stem).
|
|
53
|
+
*/
|
|
54
|
+
export function inferAssetName(content, fallbackPrefix, preferred) {
|
|
55
|
+
const firstNonEmptyLine = content
|
|
56
|
+
.split(/\r?\n/)
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.find((line) => line.length > 0);
|
|
59
|
+
const basis = preferred?.trim() || firstNonEmptyLine || fallbackPrefix;
|
|
60
|
+
return slugifyAssetName(basis, fallbackPrefix);
|
|
61
|
+
}
|
|
62
|
+
// ── Content reading ──────────────────────────────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* Read knowledge content from a local file path or stdin (`"-"`).
|
|
65
|
+
*
|
|
66
|
+
* Returns the raw text and an optional `preferredName` derived from the
|
|
67
|
+
* source filename stem (used as a slug fallback when no `--name` flag was
|
|
68
|
+
* supplied).
|
|
69
|
+
*/
|
|
70
|
+
export function readKnowledgeContent(source) {
|
|
71
|
+
if (source === "-") {
|
|
72
|
+
const content = tryReadStdinText();
|
|
73
|
+
if (!content?.trim()) {
|
|
74
|
+
throw new UsageError("No stdin content received. Pipe a document into stdin or pass a file path.");
|
|
75
|
+
}
|
|
76
|
+
return { content };
|
|
77
|
+
}
|
|
78
|
+
const resolvedSource = path.resolve(source);
|
|
79
|
+
let stat;
|
|
80
|
+
try {
|
|
81
|
+
stat = fs.statSync(resolvedSource);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new UsageError(`Knowledge source not found: "${source}". Pass a readable file path or "-" for stdin.`);
|
|
85
|
+
}
|
|
86
|
+
if (!stat.isFile()) {
|
|
87
|
+
throw new UsageError(`Knowledge source must be a file: "${source}".`);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
content: fs.readFileSync(resolvedSource, "utf8"),
|
|
91
|
+
preferredName: path.basename(resolvedSource, path.extname(resolvedSource)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Read knowledge content from a local path, stdin (`"-"`), or a remote URL.
|
|
96
|
+
*
|
|
97
|
+
* URLs are fetched via `fetchWebsiteMarkdownSnapshot`; local sources delegate
|
|
98
|
+
* to `readKnowledgeContent`.
|
|
99
|
+
*/
|
|
100
|
+
export async function readKnowledgeInput(source) {
|
|
101
|
+
if (!isHttpUrl(source))
|
|
102
|
+
return readKnowledgeContent(source);
|
|
103
|
+
const snapshot = await fetchWebsiteMarkdownSnapshot(source);
|
|
104
|
+
return { content: snapshot.content, preferredName: snapshot.preferredName };
|
|
105
|
+
}
|
|
106
|
+
// ── Asset writing ────────────────────────────────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* Write a markdown asset (knowledge or memory) to the resolved write target.
|
|
109
|
+
*
|
|
110
|
+
* Resolves the write target via the v1 precedence chain (`--target` →
|
|
111
|
+
* `defaultWriteTarget` → working stash), validates the path is within the
|
|
112
|
+
* type root, enforces `--force` semantics, and delegates the actual write
|
|
113
|
+
* to `writeAssetToSource`.
|
|
114
|
+
*/
|
|
115
|
+
export async function writeMarkdownAsset(options) {
|
|
116
|
+
const cfg = loadConfig();
|
|
117
|
+
const { source, config } = resolveWriteTarget(cfg, options.target);
|
|
118
|
+
const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
|
|
119
|
+
const normalizedName = normalizeMarkdownAssetName(options.name, inferAssetName(options.content, options.fallbackPrefix, options.preferredName));
|
|
120
|
+
// Pre-flight: existence + force semantics. The helper itself overwrites
|
|
121
|
+
// unconditionally; the CLI surfaces a friendlier UsageError before any
|
|
122
|
+
// disk activity when --force is absent.
|
|
123
|
+
const assetPath = resolveAssetPathFromName(options.type, typeRoot, normalizedName);
|
|
124
|
+
if (!isWithin(assetPath, typeRoot)) {
|
|
125
|
+
throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
|
|
126
|
+
}
|
|
127
|
+
if (fs.existsSync(assetPath) && !options.force) {
|
|
128
|
+
throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
|
|
129
|
+
}
|
|
130
|
+
const result = await writeAssetToSource(source, config, { type: options.type, name: normalizedName }, options.content);
|
|
131
|
+
return {
|
|
132
|
+
ref: result.ref,
|
|
133
|
+
path: result.path,
|
|
134
|
+
stashDir: source.path,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { BaseLinter } from "./base-linter";
|
|
6
|
+
/**
|
|
7
|
+
* Linter for `agents/` assets.
|
|
8
|
+
*
|
|
9
|
+
* Extra check beyond base:
|
|
10
|
+
* - `missing-name-or-type`: frontmatter exists but `name` or `type` field is
|
|
11
|
+
* absent. Not auto-fixable; detail includes a suggested slug.
|
|
12
|
+
*/
|
|
13
|
+
export class AgentLinter extends BaseLinter {
|
|
14
|
+
types = ["agents"];
|
|
15
|
+
lint(ctx) {
|
|
16
|
+
const issues = this.runBaseChecks(ctx);
|
|
17
|
+
const missingFieldDetail = this.#checkMissingNameOrType(ctx.data, ctx.frontmatter);
|
|
18
|
+
if (missingFieldDetail) {
|
|
19
|
+
const slug = this.#suggestSlug(ctx.filePath);
|
|
20
|
+
issues.push({
|
|
21
|
+
file: ctx.relPath,
|
|
22
|
+
issue: "missing-name-or-type",
|
|
23
|
+
detail: `${missingFieldDetail}; suggested slug: ${slug}`,
|
|
24
|
+
fixed: false,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return issues;
|
|
28
|
+
}
|
|
29
|
+
#checkMissingNameOrType(data, frontmatterText) {
|
|
30
|
+
if (!frontmatterText)
|
|
31
|
+
return null;
|
|
32
|
+
const missingFields = [];
|
|
33
|
+
if (!("name" in data) || !data.name)
|
|
34
|
+
missingFields.push("name");
|
|
35
|
+
if (!("type" in data) || !data.type)
|
|
36
|
+
missingFields.push("type");
|
|
37
|
+
if (missingFields.length === 0)
|
|
38
|
+
return null;
|
|
39
|
+
return `missing fields: ${missingFields.join(", ")}`;
|
|
40
|
+
}
|
|
41
|
+
#suggestSlug(filePath) {
|
|
42
|
+
return path
|
|
43
|
+
.basename(filePath, ".md")
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
46
|
+
.replace(/-+/g, "-")
|
|
47
|
+
.replace(/^-|-$/g, "");
|
|
48
|
+
}
|
|
49
|
+
}
|