akm-cli 0.10.261006-beta → 0.10.261008-beta
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/CHANGELOG.md +38 -0
- package/dist/commands/metrics/metrics-cli.js +3 -3
- package/dist/commands/proposal/propose-cli.js +5 -0
- package/dist/commands/proposal/propose.js +6 -2
- package/dist/commands/read/show.js +10 -6
- package/dist/commands/sources/source-clone.js +123 -11
- package/dist/commands/ui/api.js +1 -1
- package/dist/core/asset/resolve-ref.js +7 -3
- package/dist/core/common.js +17 -2
- package/dist/core/config/config-io.js +14 -4
- package/dist/indexer/indexer.js +14 -2
- package/dist/indexer/search/db-search.js +5 -2
- package/dist/scripts/akm-migrate-node.js +22 -5
- package/dist/scripts/akm-migrate.js +22 -5
- package/dist/storage/repositories/index-db.js +10 -2
- package/docs/reference/cli.md +24 -2
- package/docs/reference/data-and-telemetry.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.261008] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`akm remember`, config saves and other writes work on filesystems that refuse chmod.** On a CIFS/SMB mount
|
|
14
|
+
such as an Azure Files share, chmod fails with `EPERM`, and akm treated that as fatal: every atomic write
|
|
15
|
+
(`akm remember`, `akm setup`, `akm env`, `akm secret`) and every config backup failed. akm now keeps the mode the
|
|
16
|
+
file was created with when the filesystem refuses chmod (`EPERM`, `ENOTSUP`, `EINVAL`); the mount options decide
|
|
17
|
+
it there, so `env/`, `secrets/` and config backups are not guaranteed owner-only on such a share. Any other chmod
|
|
18
|
+
error still fails the write.
|
|
19
|
+
|
|
20
|
+
## [0.10.261007] - 2026-10-20
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`akm proposal new --bundle <name>` queues a new-asset proposal for another configured bundle.** The bundle must be
|
|
25
|
+
configured and writable; an unknown, disabled or read-only one is refused with the same error `akm remember --bundle`
|
|
26
|
+
gives. The generation prompt draws its convention facts from that bundle, and the proposal records it as its target,
|
|
27
|
+
so `akm proposal accept` writes there. Without the flag the proposal goes to the working bundle, as before.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **`akm ui` searches and asset views never open the index for write.** In read-only mode, `akm search` and
|
|
32
|
+
`akm show` still opened `index.db` read-write; closing that connection can checkpoint the WAL into the main file,
|
|
33
|
+
so browsing could change `index.db` even though nothing was written (seen as an intermittent CI failure). They now
|
|
34
|
+
use read-only connections.
|
|
35
|
+
- **`akm clone` works on flat skill repos and website pages, and records where the clone came from.** A ref whose
|
|
36
|
+
concept id is not under a type directory (`my-repo//domain-name-brainstormer`, a page from a `bundle add <url>`
|
|
37
|
+
source) failed with `TypeError: paths[1] must be string`. The source is now found through the bundle's adapter and
|
|
38
|
+
placed by what the file is: a skill into `skills/<name>/`, other markdown into `knowledge/<name>.md`, and an asset
|
|
39
|
+
that cannot be placed fails with a usage error that points to `akm import`. Cloned markdown gains the origin ref in
|
|
40
|
+
its `sources:` frontmatter. A `knowledge/<name>` ref given without `.md` now clones to `<name>.md`; it used to
|
|
41
|
+
be written with no extension and left out of the index.
|
|
42
|
+
- **`akm ui`'s Overview no longer stalls while another process writes the index.** Its metrics resolved refs through
|
|
43
|
+
a disposable copy of `index.db`, which retried for several seconds and printed a "could not take a non-mutating
|
|
44
|
+
snapshot" warning whenever the index was being written. The UI now reads `index.db` directly in read-only mode;
|
|
45
|
+
`akm metrics` from the CLI is unchanged.
|
|
46
|
+
|
|
9
47
|
## [0.10.261006] - 2026-10-16
|
|
10
48
|
|
|
11
49
|
### Added
|
|
@@ -53,7 +53,7 @@ export function metricsIncludeRows(mode) {
|
|
|
53
53
|
* `bundle//conceptId`; resolving here keeps one asset from showing as two rows.
|
|
54
54
|
* A ref that does not resolve (registry results, removed assets) is kept as is.
|
|
55
55
|
*/
|
|
56
|
-
async function durableSelects(selects) {
|
|
56
|
+
async function durableSelects(selects, indexSnapshot = true) {
|
|
57
57
|
const unqualified = [];
|
|
58
58
|
for (const ref of new Set(selects.map((select) => select.ref))) {
|
|
59
59
|
try {
|
|
@@ -65,7 +65,7 @@ async function durableSelects(selects) {
|
|
|
65
65
|
// Not a local bundle ref: keep the ref as recorded.
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
const entries = await lookupBundleRefsReadonly(unqualified.map(({ parsed }) => parsed));
|
|
68
|
+
const entries = await lookupBundleRefsReadonly(unqualified.map(({ parsed }) => parsed), { isolatedSnapshot: indexSnapshot });
|
|
69
69
|
const resolved = new Map(unqualified.map(({ ref }, i) => [ref, entries[i]?.itemRef ?? ref]));
|
|
70
70
|
return selects.map((select) => ({ ts: select.ts, ref: resolved.get(select.ref) ?? select.ref }));
|
|
71
71
|
}
|
|
@@ -190,7 +190,7 @@ export async function akmMetrics(options = {}) {
|
|
|
190
190
|
finally {
|
|
191
191
|
stateDb.close();
|
|
192
192
|
}
|
|
193
|
-
input.selects = await durableSelects(selects);
|
|
193
|
+
input.selects = await durableSelects(selects, options.indexSnapshot ?? true);
|
|
194
194
|
return buildMetricsResult(input);
|
|
195
195
|
}
|
|
196
196
|
export const metricsCommand = defineJsonCommand({
|
|
@@ -47,6 +47,10 @@ export const proposeCommand = defineCommand({
|
|
|
47
47
|
},
|
|
48
48
|
task: { type: "string", description: "Task description for the agent (what should the asset do?)" },
|
|
49
49
|
file: { type: "string", description: "Read the task or prompt text from a UTF-8 file" },
|
|
50
|
+
bundle: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "Configured writable bundle to queue the proposal for and draw convention facts from (default: the working bundle)",
|
|
53
|
+
},
|
|
50
54
|
engine: { type: "string", description: "Engine to use (defaults to defaults.engine)" },
|
|
51
55
|
"timeout-ms": { type: "string", description: "Override the engine timeout in milliseconds" },
|
|
52
56
|
},
|
|
@@ -73,6 +77,7 @@ export const proposeCommand = defineCommand({
|
|
|
73
77
|
name: proposedName,
|
|
74
78
|
task: taskText,
|
|
75
79
|
engine: getStringArg(args, "engine"),
|
|
80
|
+
bundle: getStringArg(args, "bundle"),
|
|
76
81
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
77
82
|
});
|
|
78
83
|
output("proposal-new", result);
|
|
@@ -23,6 +23,7 @@ import { redactSensitiveText } from "../../core/redaction.js";
|
|
|
23
23
|
import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
|
|
24
24
|
import { runStructured } from "../../core/structured.js";
|
|
25
25
|
import { warn } from "../../core/warn.js";
|
|
26
|
+
import { resolveWriteTarget } from "../../core/write-source.js";
|
|
26
27
|
import { deriveEntryProvenance } from "../../indexer/installations.js";
|
|
27
28
|
import { fallbackAnnouncement } from "../../integrations/agent/engine-fallback.js";
|
|
28
29
|
import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
|
|
@@ -144,10 +145,13 @@ export async function akmPropose(options) {
|
|
|
144
145
|
if (!stashDirFor(options.type)) {
|
|
145
146
|
throw new UsageError(`propose: unknown asset type "${options.type}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_FLAG_VALUE");
|
|
146
147
|
}
|
|
147
|
-
const stash = options.stashDir ?? resolveStashDir();
|
|
148
148
|
// 1. Resolve the write target. Engine/model/inference resolution happens
|
|
149
|
-
// exactly once below through the shared execution cascade.
|
|
149
|
+
// exactly once below through the shared execution cascade. `--bundle` names a
|
|
150
|
+
// configured, writable bundle (same resolver and errors as `akm remember
|
|
151
|
+
// --bundle`); its root becomes the stash the proposal is queued for and the
|
|
152
|
+
// prompt's standards are read from.
|
|
150
153
|
const config = options.agentConfig ?? (await import("../../core/config/config.js")).loadConfig();
|
|
154
|
+
const stash = options.stashDir ?? (options.bundle ? resolveWriteTarget(config, options.bundle).source.path : resolveStashDir());
|
|
151
155
|
const target = resolveProposalQueueTarget(stash, config);
|
|
152
156
|
// 2. Build terminal user content.
|
|
153
157
|
// Standards "rulebook" for this target — wiki schema (wiki page) or stash
|
|
@@ -33,7 +33,7 @@ import { appendEvent } from "../../core/events.js";
|
|
|
33
33
|
import { SCRIPT_EXTENSIONS } from "../../core/recognition-util.js";
|
|
34
34
|
import { presentationFor } from "../../core/type-presentation.js";
|
|
35
35
|
import { warn, warnOnce } from "../../core/warn.js";
|
|
36
|
-
import { lookupBundleRef, lookupBundleRefWithResolution } from "../../indexer/indexer.js";
|
|
36
|
+
import { lookupBundleRef, lookupBundleRefWithResolution, lookupBundleRefWithResolutionReadonly, } from "../../indexer/indexer.js";
|
|
37
37
|
import { projectMarkdownFragmentContent } from "../../indexer/passes/metadata.js";
|
|
38
38
|
import { ensurePrimaryIndexForRead, resolveReadSources } from "../../indexer/read-preflight.js";
|
|
39
39
|
import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries, } from "../../indexer/search/search-source.js";
|
|
@@ -213,7 +213,9 @@ export async function showLocal(input) {
|
|
|
213
213
|
const allSources = resolveSourceEntries(input.stashDir);
|
|
214
214
|
const searchSources = resolveSourcesForOrigin(parsed.bundle, allSources);
|
|
215
215
|
const allSourceDirs = searchSources.map((s) => s.path);
|
|
216
|
-
const resolution =
|
|
216
|
+
const resolution = input.readOnly
|
|
217
|
+
? await lookupBundleRefWithResolutionReadonly(parsed)
|
|
218
|
+
: await lookupBundleRefWithResolution(parsed);
|
|
217
219
|
if (resolution.indexError !== undefined)
|
|
218
220
|
throw resolution.indexError;
|
|
219
221
|
const indexedEntry = resolution.entry;
|
|
@@ -262,7 +264,9 @@ export async function showLocal(input) {
|
|
|
262
264
|
// Only resolve the indexed-safe revision when the selector itself is opaque,
|
|
263
265
|
// or when the caller explicitly opts into indexed-safe contextual output.
|
|
264
266
|
const indexedFragment = parsed.fragment && (opaqueFragmentSelector || input.contextMode === "lead")
|
|
265
|
-
? withIndexDb((db) => getIndexedMarkdownFragment(db, indexedEntry.itemRef, parsed.fragment)
|
|
267
|
+
? withIndexDb((db) => getIndexedMarkdownFragment(db, indexedEntry.itemRef, parsed.fragment), {
|
|
268
|
+
readOnly: input.readOnly === true,
|
|
269
|
+
})
|
|
266
270
|
: undefined;
|
|
267
271
|
const indexedFragmentContent = opaqueFragmentSelector ? indexedFragment?.content : undefined;
|
|
268
272
|
const indexedRenderer = rendererForIndexedEntry(indexedEntry, fileCtx);
|
|
@@ -366,7 +370,7 @@ export async function showLocal(input) {
|
|
|
366
370
|
origin: source?.registryId ?? null,
|
|
367
371
|
editable,
|
|
368
372
|
...(!editable ? { editHint: buildEditHint(canonicalRef) } : {}),
|
|
369
|
-
...showLinks(indexedEntry.itemRef, displayDefaultBundle),
|
|
373
|
+
...showLinks(indexedEntry.itemRef, displayDefaultBundle, input.readOnly === true),
|
|
370
374
|
};
|
|
371
375
|
const activeRun = input.readOnly ? null : await getActiveWorkflowRun(getCurrentWorkflowScopeKey());
|
|
372
376
|
if (activeRun) {
|
|
@@ -387,10 +391,10 @@ const LINKS_PER_KIND = 10;
|
|
|
387
391
|
* authored order, incoming by ref, and unresolved tokens as authored. Empty
|
|
388
392
|
* parts are omitted, and the field when nothing links either way.
|
|
389
393
|
*/
|
|
390
|
-
function showLinks(itemRef, defaultBundle) {
|
|
394
|
+
function showLinks(itemRef, defaultBundle, readOnly = false) {
|
|
391
395
|
let rows;
|
|
392
396
|
try {
|
|
393
|
-
rows = withIndexDb((db) => readEntryLinks(db, itemRef));
|
|
397
|
+
rows = withIndexDb((db) => readEntryLinks(db, itemRef), { readOnly });
|
|
394
398
|
}
|
|
395
399
|
catch (err) {
|
|
396
400
|
rethrowIfTestIsolationError(err);
|
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
|
+
import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
|
|
9
|
+
import { adapterForId } from "../../core/adapter/registry.js";
|
|
10
|
+
import { placementSpecFor, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
8
11
|
import { isBundleSlug } from "../../core/asset/asset-ref.js";
|
|
9
|
-
import {
|
|
12
|
+
import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
13
|
+
import { parseFrontmatter, replaceFrontmatterBlocks } from "../../core/asset/frontmatter.js";
|
|
14
|
+
import { conceptIdFromTypeName, displayRef, parseQualifiedRefInput } from "../../core/asset/resolve-ref.js";
|
|
10
15
|
import { isWithin } from "../../core/common.js";
|
|
11
16
|
import { loadConfig } from "../../core/config/config.js";
|
|
12
17
|
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
|
|
@@ -15,10 +20,13 @@ import { warn } from "../../core/warn.js";
|
|
|
15
20
|
import { commitWriteTargetBoundary, prepareWriteTargetForMutation, resolveWriteTarget, } from "../../core/write-source.js";
|
|
16
21
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
17
22
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
23
|
+
import { resolveAdapterConceptOwner } from "../../indexer/lookup/adapter-concept-owner.js";
|
|
18
24
|
import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
25
|
+
import { buildFileContext } from "../../indexer/walk/file-context.js";
|
|
19
26
|
import { isRemoteOrigin, resolveSourcesForLocator, resolveSourcesForOrigin } from "../../registry/origin-resolve.js";
|
|
20
27
|
import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
|
|
21
28
|
import { resolveAssetPath } from "../../sources/resolve.js";
|
|
29
|
+
import { inferAssetName } from "../read/knowledge.js";
|
|
22
30
|
function destinationRef(config, target, type, name) {
|
|
23
31
|
if (!config || !target)
|
|
24
32
|
return undefined;
|
|
@@ -77,11 +85,25 @@ export async function akmClone(options) {
|
|
|
77
85
|
cacheDir: installResult.cacheDir,
|
|
78
86
|
};
|
|
79
87
|
}
|
|
88
|
+
// A ref whose conceptId is not under a known type directory (a flat skill
|
|
89
|
+
// repo, a website or wiki page) is opaque: its source and type come from the
|
|
90
|
+
// adapter that owns the concept, not from the shape of the ref.
|
|
91
|
+
const known = placementSpecFor(parsed.type) !== undefined;
|
|
80
92
|
let sourcePath;
|
|
93
|
+
let opaqueType;
|
|
81
94
|
let lastError;
|
|
82
95
|
for (const source of searchSources) {
|
|
83
96
|
try {
|
|
84
|
-
|
|
97
|
+
if (known) {
|
|
98
|
+
sourcePath = await resolveAssetPath(source.path, parsed.type, parsed.name);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const owned = resolveOpaqueAsset(source, parsed.name);
|
|
102
|
+
if (!owned)
|
|
103
|
+
throw new NotFoundError(`Source asset not found for ref: ${options.sourceRef}`, "ASSET_NOT_FOUND");
|
|
104
|
+
sourcePath = owned.path;
|
|
105
|
+
opaqueType = owned.type;
|
|
106
|
+
}
|
|
85
107
|
break;
|
|
86
108
|
}
|
|
87
109
|
catch (err) {
|
|
@@ -95,8 +117,20 @@ export async function akmClone(options) {
|
|
|
95
117
|
throw lastError ?? new NotFoundError(`Source asset not found for ref: ${options.sourceRef}`, "ASSET_NOT_FOUND");
|
|
96
118
|
}
|
|
97
119
|
const sourceSource = findSourceForPath(sourcePath, allSources);
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
// Where the clone lands: a known-type ref keeps its type and name; an opaque
|
|
121
|
+
// one is placed by what the source file is.
|
|
122
|
+
const sourceExt = path.extname(sourcePath);
|
|
123
|
+
const cloneType = known ? parsed.type : placementTypeForOpaque(options.sourceRef, sourcePath, opaqueType);
|
|
124
|
+
const defaultName = known ? parsed.name : slugName(parsed.name);
|
|
125
|
+
const destName = options.newName ?? defaultName;
|
|
126
|
+
const typeDir = stashDirFor(cloneType);
|
|
127
|
+
// A ref's name often omits its extension (`knowledge/<name>` names `<name>.md`), and an opaque one is a slug:
|
|
128
|
+
// either way the copy keeps the source file's extension, or it would be written as a file the index ignores.
|
|
129
|
+
const destFileName = path.extname(destName) ? destName : destName + sourceExt;
|
|
130
|
+
const isSkill = cloneType === "skill";
|
|
131
|
+
// A concept id is the path without its markdown extension.
|
|
132
|
+
const originConceptId = (known ? conceptIdFromTypeName(parsed.type, parsed.name) : parsed.name).replace(/\.md$/i, "");
|
|
133
|
+
const originRef = sourceSource?.registryId ? `${sourceSource.registryId}//${originConceptId}` : undefined;
|
|
100
134
|
// Validate destName to prevent path traversal (parsed.name is already
|
|
101
135
|
// validated by the ref parser, but newName comes directly from user input).
|
|
102
136
|
// Run whenever newName is provided, including empty string.
|
|
@@ -128,7 +162,7 @@ export async function akmClone(options) {
|
|
|
128
162
|
? `in target "${writeTarget.selector}"`
|
|
129
163
|
: "in working stash";
|
|
130
164
|
// Guard against self-clone
|
|
131
|
-
if (
|
|
165
|
+
if (isSkill) {
|
|
132
166
|
const sourceSkillDir = path.resolve(path.dirname(sourcePath));
|
|
133
167
|
const destSkillDir = path.resolve(path.join(destRoot, typeDir, destName));
|
|
134
168
|
if (sourceSkillDir === destSkillDir) {
|
|
@@ -137,8 +171,7 @@ export async function akmClone(options) {
|
|
|
137
171
|
}
|
|
138
172
|
else {
|
|
139
173
|
const resolvedSource = path.resolve(sourcePath);
|
|
140
|
-
const
|
|
141
|
-
const effectiveDestName = !path.extname(destName) && sourceExt ? destName + sourceExt : destName;
|
|
174
|
+
const effectiveDestName = !path.extname(destFileName) && sourceExt ? destFileName + sourceExt : destFileName;
|
|
142
175
|
const resolvedDest = path.resolve(path.join(destRoot, typeDir, effectiveDestName));
|
|
143
176
|
if (resolvedSource === resolvedDest) {
|
|
144
177
|
throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`);
|
|
@@ -148,7 +181,7 @@ export async function akmClone(options) {
|
|
|
148
181
|
let destPath;
|
|
149
182
|
let overwritten;
|
|
150
183
|
let operationPaths;
|
|
151
|
-
if (
|
|
184
|
+
if (isSkill) {
|
|
152
185
|
const sourceSkillDir = path.dirname(sourcePath);
|
|
153
186
|
const destSkillDir = path.join(destRoot, typeDir, destName);
|
|
154
187
|
assertNoDestinationSymlinkParent(destRoot, destSkillDir);
|
|
@@ -163,6 +196,7 @@ export async function akmClone(options) {
|
|
|
163
196
|
const stagingDir = `${destSkillDir}.tmp-${randomUUID()}`;
|
|
164
197
|
try {
|
|
165
198
|
fs.cpSync(sourceSkillDir, stagingDir, { recursive: true, errorOnExist: true, force: false });
|
|
199
|
+
stampCloneOrigin(path.join(stagingDir, "SKILL.md"), originRef);
|
|
166
200
|
if (overwritten)
|
|
167
201
|
fs.rmSync(destSkillDir, { recursive: true, force: true });
|
|
168
202
|
fs.renameSync(stagingDir, destSkillDir);
|
|
@@ -175,7 +209,7 @@ export async function akmClone(options) {
|
|
|
175
209
|
operationPaths = [...new Set([...replacedPaths, ...listTreeFiles(destSkillDir)])];
|
|
176
210
|
}
|
|
177
211
|
else {
|
|
178
|
-
destPath = path.join(destRoot, typeDir,
|
|
212
|
+
destPath = path.join(destRoot, typeDir, destFileName);
|
|
179
213
|
assertNoDestinationSymlinkParent(destRoot, destPath);
|
|
180
214
|
const existing = lstatIfExists(destPath);
|
|
181
215
|
overwritten = existing !== undefined;
|
|
@@ -186,6 +220,8 @@ export async function akmClone(options) {
|
|
|
186
220
|
const stagingPath = `${destPath}.tmp-${randomUUID()}`;
|
|
187
221
|
try {
|
|
188
222
|
fs.copyFileSync(sourcePath, stagingPath, fs.constants.COPYFILE_EXCL);
|
|
223
|
+
if (cloneType !== "workflow" && sourceExt.toLowerCase() === ".md")
|
|
224
|
+
stampCloneOrigin(stagingPath, originRef);
|
|
189
225
|
if (overwritten)
|
|
190
226
|
fs.rmSync(destPath, { recursive: true, force: true });
|
|
191
227
|
fs.renameSync(stagingPath, destPath);
|
|
@@ -196,7 +232,7 @@ export async function akmClone(options) {
|
|
|
196
232
|
}
|
|
197
233
|
operationPaths = [destPath];
|
|
198
234
|
}
|
|
199
|
-
const ref = destinationRef(config, writeTarget,
|
|
235
|
+
const ref = destinationRef(config, writeTarget, cloneType, destName);
|
|
200
236
|
if (writeTarget) {
|
|
201
237
|
const commitRoot = writeTarget.source.repoPath ?? writeTarget.source.path;
|
|
202
238
|
const commitPaths = operationPaths.map((filePath) => path.relative(commitRoot, filePath).replaceAll(path.sep, "/"));
|
|
@@ -213,6 +249,82 @@ export async function akmClone(options) {
|
|
|
213
249
|
};
|
|
214
250
|
});
|
|
215
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Find an opaque concept (one whose id is not under a known type directory) in
|
|
254
|
+
* a source through the adapter that owns it, and name the type that adapter
|
|
255
|
+
* recognizes the file as. The same ownership + recognition `akm show` and the
|
|
256
|
+
* index use, so a clone sees exactly the file the index holds for the ref.
|
|
257
|
+
*/
|
|
258
|
+
function resolveOpaqueAsset(source, conceptId) {
|
|
259
|
+
const adapterId = source.adapterId ?? detectAdapterId(source.path);
|
|
260
|
+
const owner = resolveAdapterConceptOwner(source.path, adapterId, conceptId, { mode: "read" });
|
|
261
|
+
if (!owner)
|
|
262
|
+
return undefined;
|
|
263
|
+
let type;
|
|
264
|
+
try {
|
|
265
|
+
const component = { id: adapterId, adapter: adapterId, root: source.path, writable: false };
|
|
266
|
+
type = adapterForId(adapterId)?.recognize(component, buildFileContext(source.path, owner.path))?.type;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// An adapter that cannot recognize the file leaves its type unknown.
|
|
270
|
+
}
|
|
271
|
+
return { path: owner.realPath, type };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Where an opaque asset is cloned: a skill (SKILL.md) as a directory under
|
|
275
|
+
* `skills/`, any other recognized placement type under its own directory, and
|
|
276
|
+
* markdown of an unknown type (a website or wiki page, a flat doc) as
|
|
277
|
+
* `knowledge/`. Anything else cannot be placed.
|
|
278
|
+
*/
|
|
279
|
+
function placementTypeForOpaque(sourceRef, sourcePath, indexedType) {
|
|
280
|
+
if (indexedType === "skill" && path.basename(sourcePath) === "SKILL.md")
|
|
281
|
+
return "skill";
|
|
282
|
+
if (indexedType !== undefined && indexedType !== "skill" && placementSpecFor(indexedType) !== undefined) {
|
|
283
|
+
return indexedType;
|
|
284
|
+
}
|
|
285
|
+
if (path.extname(sourcePath).toLowerCase() === ".md")
|
|
286
|
+
return "knowledge";
|
|
287
|
+
throw new UsageError(`cannot clone ${sourceRef}: its type${indexedType ? ` "${indexedType}"` : ""} has no place in a bundle and ` +
|
|
288
|
+
`${path.basename(sourcePath)} is not markdown; use \`akm import <path>\` or copy the file.`);
|
|
289
|
+
}
|
|
290
|
+
/** Clone name of an opaque asset: the last segment of its concept id, slugified like every other asset name. */
|
|
291
|
+
function slugName(conceptId) {
|
|
292
|
+
const last = conceptId.split("/").pop() ?? conceptId;
|
|
293
|
+
const stem = last.replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
|
294
|
+
return inferAssetName("", "clone", stem);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Record where a cloned markdown asset came from by merging the origin ref into
|
|
298
|
+
* its `sources:` frontmatter. `sources:` is free-form provenance that `akm lint` does
|
|
299
|
+
* not resolve, unlike `xrefs:`: the origin bundle is often not installed where the
|
|
300
|
+
* clone is read (a shared domain bundle), and an `xrefs:` entry would be reported as
|
|
301
|
+
* a missing ref there. Only the `sources` block is rewritten; every other
|
|
302
|
+
* byte stays as written. A file whose frontmatter cannot be edited that way
|
|
303
|
+
* (and still parse back to exactly the intended mapping) is left untouched.
|
|
304
|
+
*/
|
|
305
|
+
function stampCloneOrigin(filePath, originRef) {
|
|
306
|
+
if (!originRef)
|
|
307
|
+
return;
|
|
308
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
309
|
+
const parsed = parseFrontmatter(raw);
|
|
310
|
+
const existing = parsed.data.sources;
|
|
311
|
+
const prior = Array.isArray(existing) ? existing : typeof existing === "string" ? [existing] : [];
|
|
312
|
+
if (existing !== undefined && prior.length === 0)
|
|
313
|
+
return;
|
|
314
|
+
if (prior.includes(originRef))
|
|
315
|
+
return;
|
|
316
|
+
const sources = [...prior, originRef];
|
|
317
|
+
let next;
|
|
318
|
+
if (parsed.frontmatter === null) {
|
|
319
|
+
next = `---\n${serializeFrontmatter({ sources })}\n---\n${raw}`;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
next = replaceFrontmatterBlocks(raw, ["sources"], serializeFrontmatter({ sources }).split("\n"));
|
|
323
|
+
if (next === null || !isDeepStrictEqual(parseFrontmatter(next).data, { ...parsed.data, sources }))
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
fs.writeFileSync(filePath, next);
|
|
327
|
+
}
|
|
216
328
|
function lstatIfExists(filePath) {
|
|
217
329
|
try {
|
|
218
330
|
return fs.lstatSync(filePath);
|
package/dist/commands/ui/api.js
CHANGED
|
@@ -212,7 +212,7 @@ async function overview(query) {
|
|
|
212
212
|
}), null);
|
|
213
213
|
return { ...base, ...(report ? { report } : {}) };
|
|
214
214
|
});
|
|
215
|
-
const metrics = await section(errors, "metrics", () => akmMetrics({ since: query.get("metricsSince") || "30d" }));
|
|
215
|
+
const metrics = await section(errors, "metrics", () => akmMetrics({ since: query.get("metricsSince") || "30d", indexSnapshot: false }));
|
|
216
216
|
const stateReads = await section(errors, "state", () => withReadonlyStateDb((db) => ({
|
|
217
217
|
improveRuns: listRecentImproveRuns(db, IMPROVE_RUNS_SHOWN).map(summarizeImproveRun),
|
|
218
218
|
usageByDay: usageByDay(db, Date.now()),
|
|
@@ -232,13 +232,17 @@ function opaqueRefParts(conceptId, allowRoot = false) {
|
|
|
232
232
|
* that `resolveSourcesForOrigin` matches on `registryId`. The SHORT form (no
|
|
233
233
|
* bundle) leaves `origin` undefined = search-all-sources.
|
|
234
234
|
* - `#fragment` → rejected. No input boundary consumes an export fragment.
|
|
235
|
+
*
|
|
236
|
+
* `originGiven` — the caller already split a (non-slug) origin off the ref, so a
|
|
237
|
+
* single-segment conceptId (a flat skill repo's `domain-name-brainstormer`) is
|
|
238
|
+
* qualified and parses as opaque, exactly as it does behind a slug `bundle//`.
|
|
235
239
|
*/
|
|
236
|
-
export function parseRefInput(raw) {
|
|
240
|
+
export function parseRefInput(raw, originGiven = false) {
|
|
237
241
|
const ref = parseBundleRef(raw);
|
|
238
242
|
if (ref.fragment !== undefined) {
|
|
239
243
|
throw new UsageError(`Export fragment "#${ref.fragment}" is not accepted here — drop it from "${raw.trim()}".`, "INVALID_FLAG_VALUE");
|
|
240
244
|
}
|
|
241
|
-
const parts = typeNameFromConceptId(ref.conceptId) ?? opaqueRefParts(ref.conceptId, ref.bundle !== undefined);
|
|
245
|
+
const parts = typeNameFromConceptId(ref.conceptId) ?? opaqueRefParts(ref.conceptId, originGiven || ref.bundle !== undefined);
|
|
242
246
|
if (parts === undefined) {
|
|
243
247
|
throw new NotFoundError(`Unrecognized asset ref "${raw.trim()}": conceptId "${ref.conceptId}" has no known asset-type prefix.`, "ASSET_NOT_FOUND");
|
|
244
248
|
}
|
|
@@ -265,7 +269,7 @@ export function parseQualifiedRefInput(raw) {
|
|
|
265
269
|
if (boundary > 0) {
|
|
266
270
|
const origin = trimmed.slice(0, boundary);
|
|
267
271
|
if (!isBundleSlug(origin)) {
|
|
268
|
-
return { ...parseRefInput(trimmed.slice(boundary + 2)), origin };
|
|
272
|
+
return { ...parseRefInput(trimmed.slice(boundary + 2), true), origin };
|
|
269
273
|
}
|
|
270
274
|
}
|
|
271
275
|
return parseRefInput(trimmed);
|
package/dist/core/common.js
CHANGED
|
@@ -165,8 +165,15 @@ export function writeFileAtomic(target, content, mode) {
|
|
|
165
165
|
try {
|
|
166
166
|
fd = fs.openSync(tmp, "wx", fileMode);
|
|
167
167
|
tempOwned = true;
|
|
168
|
-
if (process.platform !== "win32")
|
|
169
|
-
|
|
168
|
+
if (process.platform !== "win32") {
|
|
169
|
+
try {
|
|
170
|
+
fs.fchmodSync(fd, fileMode);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (!isChmodUnsupported(error))
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
170
177
|
let offset = 0;
|
|
171
178
|
while (offset < data.byteLength) {
|
|
172
179
|
const written = fs.writeSync(fd, data, offset, data.byteLength - offset);
|
|
@@ -361,6 +368,14 @@ export function hasErrnoCode(error, code) {
|
|
|
361
368
|
return false;
|
|
362
369
|
return error.code === code;
|
|
363
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* True when a chmod failed because the filesystem has no settable POSIX modes
|
|
373
|
+
* (CIFS/SMB such as an Azure Files share, some FUSE mounts). The mount options
|
|
374
|
+
* decide the mode there, so callers keep going with whatever mode the file got.
|
|
375
|
+
*/
|
|
376
|
+
export function isChmodUnsupported(error) {
|
|
377
|
+
return hasErrnoCode(error, "EPERM") || hasErrnoCode(error, "ENOTSUP") || hasErrnoCode(error, "EINVAL");
|
|
378
|
+
}
|
|
364
379
|
/**
|
|
365
380
|
* True when `value` is a RELATIVE path that cannot leave its base directory:
|
|
366
381
|
* no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~`
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { sleepSync } from "../../runtime.js";
|
|
18
|
-
import { readTextFile, stripJsonComments, writeFileAtomic } from "../common.js";
|
|
18
|
+
import { isChmodUnsupported, readTextFile, stripJsonComments, writeFileAtomic } from "../common.js";
|
|
19
19
|
import { ConfigError } from "../errors.js";
|
|
20
20
|
import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
|
|
21
21
|
import { getCacheDir, getConfigDir } from "../paths.js";
|
|
@@ -88,7 +88,7 @@ export function backupExistingConfig(configPath, now = new Date()) {
|
|
|
88
88
|
// env.ts/secret.ts convention — so no other local user can traverse in during
|
|
89
89
|
// the copy→chmod window. chmod again to tighten a dir from an older version.
|
|
90
90
|
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
91
|
-
|
|
91
|
+
chmodIfSupported(backupDir, 0o700);
|
|
92
92
|
const timestamp = now.toISOString().replace(/[.:]/g, "-");
|
|
93
93
|
let sequence = 0;
|
|
94
94
|
let timestamped;
|
|
@@ -109,11 +109,21 @@ export function backupExistingConfig(configPath, now = new Date()) {
|
|
|
109
109
|
// 08-F4: a config backup carries the same sensitive fields as the live config
|
|
110
110
|
// (endpoints, tokens). `copyFileSync` inherits the source's (often 0644) mode,
|
|
111
111
|
// so tighten the backups to owner-only — mirrors the env-cli 0600 write floor.
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
chmodIfSupported(timestamped, 0o600);
|
|
113
|
+
chmodIfSupported(latest, 0o600);
|
|
114
114
|
pruneOldBackups(backupDir);
|
|
115
115
|
return { timestamped, latest };
|
|
116
116
|
}
|
|
117
|
+
/** chmod, skipped on a filesystem without POSIX modes (CIFS), where the mount sets them. */
|
|
118
|
+
function chmodIfSupported(target, mode) {
|
|
119
|
+
try {
|
|
120
|
+
fs.chmodSync(target, mode);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (!isChmodUnsupported(error))
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
117
127
|
function pruneOldBackups(backupDir) {
|
|
118
128
|
pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name));
|
|
119
129
|
}
|
package/dist/indexer/indexer.js
CHANGED
|
@@ -1196,6 +1196,18 @@ borrowed = false) {
|
|
|
1196
1196
|
export async function lookupBundleRefWithResolution(ref) {
|
|
1197
1197
|
return lookupBundleRefWithResolutionUsing(ref, openExistingDatabase);
|
|
1198
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* {@link lookupBundleRefWithResolution} on a plain read-only connection (`akm ui`): a read-write one can checkpoint
|
|
1201
|
+
* the WAL into the main file when it closes, changing the file although nothing was written.
|
|
1202
|
+
*/
|
|
1203
|
+
export async function lookupBundleRefWithResolutionReadonly(ref) {
|
|
1204
|
+
return lookupBundleRefWithResolutionUsing(ref, (dbPath) => {
|
|
1205
|
+
const db = openReadonlyExistingDatabase(dbPath);
|
|
1206
|
+
if (!db)
|
|
1207
|
+
throw new Error(`Index database not found at ${dbPath}. Run 'akm index' to build it.`);
|
|
1208
|
+
return db;
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1199
1211
|
/** Resolve an adapter-owned `[bundle//]conceptId` without interpreting its path as an AKM type. */
|
|
1200
1212
|
export async function lookupBundleRef(ref) {
|
|
1201
1213
|
const resolution = await lookupBundleRefWithResolution(ref);
|
|
@@ -1223,12 +1235,12 @@ export async function lookupBundleRefReadonly(ref) {
|
|
|
1223
1235
|
* index, which is copied once rather than once per ref. A ref that does not
|
|
1224
1236
|
* resolve, or an index that cannot be read, gives `null` for that ref.
|
|
1225
1237
|
*/
|
|
1226
|
-
export async function lookupBundleRefsReadonly(refs) {
|
|
1238
|
+
export async function lookupBundleRefsReadonly(refs, options = {}) {
|
|
1227
1239
|
if (refs.length === 0)
|
|
1228
1240
|
return [];
|
|
1229
1241
|
let db;
|
|
1230
1242
|
try {
|
|
1231
|
-
db = openReadonlyExistingDatabase(getDbPath(), { isolatedSnapshot: true });
|
|
1243
|
+
db = openReadonlyExistingDatabase(getDbPath(), { isolatedSnapshot: options.isolatedSnapshot ?? true });
|
|
1232
1244
|
}
|
|
1233
1245
|
catch {
|
|
1234
1246
|
// Unreadable index: every ref stays unresolved.
|
|
@@ -21,7 +21,7 @@ import { presentationFor } from "../../core/type-presentation.js";
|
|
|
21
21
|
import { embed } from "../../llm/embedder.js";
|
|
22
22
|
import { applyEmbeddingTemplate, resolveEmbeddingProfile } from "../../llm/embedders/profile.js";
|
|
23
23
|
import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
|
|
24
|
-
import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
|
|
24
|
+
import { assertIndexPathReadable, closeDatabase, openExistingDatabase, openReadonlyExistingDatabase, } from "../../storage/repositories/index-connection.js";
|
|
25
25
|
import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getEntryRefsAndTypes, } from "../../storage/repositories/index-entries-repository.js";
|
|
26
26
|
import { searchFts } from "../../storage/repositories/index-fts-repository.js";
|
|
27
27
|
import { getMeta } from "../../storage/repositories/index-meta-repository.js";
|
|
@@ -128,7 +128,10 @@ export async function searchLocal(input) {
|
|
|
128
128
|
mode: "keyword",
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
|
-
|
|
131
|
+
// A read-only search (`akm ui`) must not open the index for write: closing a
|
|
132
|
+
// read-write connection can checkpoint the WAL into the main file, which
|
|
133
|
+
// changes it even though nothing was written.
|
|
134
|
+
const db = input.readOnly ? openReadonlyExistingDatabase(dbPath) : openExistingDatabase(dbPath);
|
|
132
135
|
try {
|
|
133
136
|
const entryCount = getEntryCount(db);
|
|
134
137
|
if (entryCount === 0) {
|
|
@@ -26201,8 +26201,14 @@ function writeFileAtomic(target, content, mode) {
|
|
|
26201
26201
|
try {
|
|
26202
26202
|
fd = fs2.openSync(tmp, "wx", fileMode);
|
|
26203
26203
|
tempOwned = true;
|
|
26204
|
-
if (process.platform !== "win32")
|
|
26205
|
-
|
|
26204
|
+
if (process.platform !== "win32") {
|
|
26205
|
+
try {
|
|
26206
|
+
fs2.fchmodSync(fd, fileMode);
|
|
26207
|
+
} catch (error) {
|
|
26208
|
+
if (!isChmodUnsupported(error))
|
|
26209
|
+
throw error;
|
|
26210
|
+
}
|
|
26211
|
+
}
|
|
26206
26212
|
let offset = 0;
|
|
26207
26213
|
while (offset < data.byteLength) {
|
|
26208
26214
|
const written = fs2.writeSync(fd, data, offset, data.byteLength - offset);
|
|
@@ -26351,6 +26357,9 @@ function hasErrnoCode(error, code) {
|
|
|
26351
26357
|
return false;
|
|
26352
26358
|
return error.code === code;
|
|
26353
26359
|
}
|
|
26360
|
+
function isChmodUnsupported(error) {
|
|
26361
|
+
return hasErrnoCode(error, "EPERM") || hasErrnoCode(error, "ENOTSUP") || hasErrnoCode(error, "EINVAL");
|
|
26362
|
+
}
|
|
26354
26363
|
function isContainedRelativePath(value) {
|
|
26355
26364
|
if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~"))
|
|
26356
26365
|
return false;
|
|
@@ -39230,7 +39239,7 @@ function backupExistingConfig(configPath, now = new Date) {
|
|
|
39230
39239
|
return;
|
|
39231
39240
|
const backupDir = path20.join(getCacheDir(), "config-backups");
|
|
39232
39241
|
fs16.mkdirSync(backupDir, { recursive: true, mode: 448 });
|
|
39233
|
-
|
|
39242
|
+
chmodIfSupported(backupDir, 448);
|
|
39234
39243
|
const timestamp = now.toISOString().replace(/[.:]/g, "-");
|
|
39235
39244
|
let sequence = 0;
|
|
39236
39245
|
let timestamped;
|
|
@@ -39247,11 +39256,19 @@ function backupExistingConfig(configPath, now = new Date) {
|
|
|
39247
39256
|
}
|
|
39248
39257
|
const latest = path20.join(backupDir, "config.latest.json");
|
|
39249
39258
|
fs16.copyFileSync(configPath, latest);
|
|
39250
|
-
|
|
39251
|
-
|
|
39259
|
+
chmodIfSupported(timestamped, 384);
|
|
39260
|
+
chmodIfSupported(latest, 384);
|
|
39252
39261
|
pruneOldBackups(backupDir);
|
|
39253
39262
|
return { timestamped, latest };
|
|
39254
39263
|
}
|
|
39264
|
+
function chmodIfSupported(target, mode) {
|
|
39265
|
+
try {
|
|
39266
|
+
fs16.chmodSync(target, mode);
|
|
39267
|
+
} catch (error2) {
|
|
39268
|
+
if (!isChmodUnsupported(error2))
|
|
39269
|
+
throw error2;
|
|
39270
|
+
}
|
|
39271
|
+
}
|
|
39255
39272
|
function pruneOldBackups(backupDir) {
|
|
39256
39273
|
pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name));
|
|
39257
39274
|
}
|
|
@@ -25529,8 +25529,14 @@ function writeFileAtomic(target, content, mode) {
|
|
|
25529
25529
|
try {
|
|
25530
25530
|
fd = fs2.openSync(tmp, "wx", fileMode);
|
|
25531
25531
|
tempOwned = true;
|
|
25532
|
-
if (process.platform !== "win32")
|
|
25533
|
-
|
|
25532
|
+
if (process.platform !== "win32") {
|
|
25533
|
+
try {
|
|
25534
|
+
fs2.fchmodSync(fd, fileMode);
|
|
25535
|
+
} catch (error) {
|
|
25536
|
+
if (!isChmodUnsupported(error))
|
|
25537
|
+
throw error;
|
|
25538
|
+
}
|
|
25539
|
+
}
|
|
25534
25540
|
let offset = 0;
|
|
25535
25541
|
while (offset < data.byteLength) {
|
|
25536
25542
|
const written = fs2.writeSync(fd, data, offset, data.byteLength - offset);
|
|
@@ -25679,6 +25685,9 @@ function hasErrnoCode(error, code) {
|
|
|
25679
25685
|
return false;
|
|
25680
25686
|
return error.code === code;
|
|
25681
25687
|
}
|
|
25688
|
+
function isChmodUnsupported(error) {
|
|
25689
|
+
return hasErrnoCode(error, "EPERM") || hasErrnoCode(error, "ENOTSUP") || hasErrnoCode(error, "EINVAL");
|
|
25690
|
+
}
|
|
25682
25691
|
function isContainedRelativePath(value) {
|
|
25683
25692
|
if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~"))
|
|
25684
25693
|
return false;
|
|
@@ -38558,7 +38567,7 @@ function backupExistingConfig(configPath, now = new Date) {
|
|
|
38558
38567
|
return;
|
|
38559
38568
|
const backupDir = path20.join(getCacheDir(), "config-backups");
|
|
38560
38569
|
fs16.mkdirSync(backupDir, { recursive: true, mode: 448 });
|
|
38561
|
-
|
|
38570
|
+
chmodIfSupported(backupDir, 448);
|
|
38562
38571
|
const timestamp = now.toISOString().replace(/[.:]/g, "-");
|
|
38563
38572
|
let sequence = 0;
|
|
38564
38573
|
let timestamped;
|
|
@@ -38575,11 +38584,19 @@ function backupExistingConfig(configPath, now = new Date) {
|
|
|
38575
38584
|
}
|
|
38576
38585
|
const latest = path20.join(backupDir, "config.latest.json");
|
|
38577
38586
|
fs16.copyFileSync(configPath, latest);
|
|
38578
|
-
|
|
38579
|
-
|
|
38587
|
+
chmodIfSupported(timestamped, 384);
|
|
38588
|
+
chmodIfSupported(latest, 384);
|
|
38580
38589
|
pruneOldBackups(backupDir);
|
|
38581
38590
|
return { timestamped, latest };
|
|
38582
38591
|
}
|
|
38592
|
+
function chmodIfSupported(target, mode) {
|
|
38593
|
+
try {
|
|
38594
|
+
fs16.chmodSync(target, mode);
|
|
38595
|
+
} catch (error2) {
|
|
38596
|
+
if (!isChmodUnsupported(error2))
|
|
38597
|
+
throw error2;
|
|
38598
|
+
}
|
|
38599
|
+
}
|
|
38583
38600
|
function pruneOldBackups(backupDir) {
|
|
38584
38601
|
pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name));
|
|
38585
38602
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import { resolveStorageLocations } from "../locations.js";
|
|
5
5
|
import { withManagedDb } from "../managed-db.js";
|
|
6
|
-
import { openExistingDatabase } from "./index-connection.js";
|
|
6
|
+
import { openExistingDatabase, openReadonlyExistingDatabase } from "./index-connection.js";
|
|
7
7
|
/**
|
|
8
8
|
* Busy-timeout (ms) for read-path telemetry writers. Small on purpose: a
|
|
9
9
|
* usage-event insert contending with a background reindex should be dropped,
|
|
@@ -41,7 +41,15 @@ export const TELEMETRY_BUSY_TIMEOUT_MS = 250;
|
|
|
41
41
|
* @returns Whatever `fn` returns.
|
|
42
42
|
*/
|
|
43
43
|
export function withIndexDb(fn, opts) {
|
|
44
|
-
return withManagedDb(() =>
|
|
44
|
+
return withManagedDb(() => {
|
|
45
|
+
const dbPath = resolveStorageLocations().indexDb;
|
|
46
|
+
if (!opts?.readOnly)
|
|
47
|
+
return openExistingDatabase(dbPath);
|
|
48
|
+
const db = openReadonlyExistingDatabase(dbPath);
|
|
49
|
+
if (!db)
|
|
50
|
+
throw new Error(`Index database not found at ${dbPath}. Run 'akm index' to build it.`);
|
|
51
|
+
return db;
|
|
52
|
+
}, (db) => {
|
|
45
53
|
if (opts?.busyTimeoutMs !== undefined) {
|
|
46
54
|
db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts.busyTimeoutMs))}`);
|
|
47
55
|
}
|
package/docs/reference/cli.md
CHANGED
|
@@ -1558,6 +1558,23 @@ akm clone "npm:@scope/pkg//scripts/deploy.sh" --dest /tmp/preview
|
|
|
1558
1558
|
|
|
1559
1559
|
Skills (directories) are copied recursively. Other types copy a single file.
|
|
1560
1560
|
|
|
1561
|
+
**Refs outside the type directories.** A ref whose concept id is not under a
|
|
1562
|
+
type directory (a skill in a flat skills repo, `my-repo//domain-name-brainstormer`;
|
|
1563
|
+
a page from a website or wiki bundle) is placed by what the source file is: a
|
|
1564
|
+
`SKILL.md` directory clones to `skills/<name>/`, any other recognized type to its
|
|
1565
|
+
own directory, and markdown of any other type to `knowledge/<name>.md`. `<name>`
|
|
1566
|
+
is the last segment of the concept id, slugified; `--name` overrides it. An asset
|
|
1567
|
+
that is not markdown and has no place in a bundle fails with a usage error that
|
|
1568
|
+
points to `akm import <path>`.
|
|
1569
|
+
|
|
1570
|
+
**Provenance.** A cloned markdown asset (a skill's `SKILL.md` included) records
|
|
1571
|
+
where it came from: the origin ref, `<bundle>//<concept-id>`, is added to its
|
|
1572
|
+
`sources:` frontmatter, keeping any entries already listed. `sources:` is free-form
|
|
1573
|
+
provenance that `akm lint` does not resolve, so a clone shared where the origin
|
|
1574
|
+
bundle is not installed does not report a missing ref. Other bytes of the file
|
|
1575
|
+
stay as written, non-markdown files and workflows are copied unchanged, and the
|
|
1576
|
+
origin is never modified.
|
|
1577
|
+
|
|
1561
1578
|
**Remote clone:** When the origin in the ref points to a package that is not
|
|
1562
1579
|
installed locally (e.g. an npm package or local path not in your bundle
|
|
1563
1580
|
sources), akm fetches it to the cache automatically and extracts the
|
|
@@ -2176,7 +2193,8 @@ service's config; reach for `secret` when one value *is* an auth credential.
|
|
|
2176
2193
|
Manage `.env`-backed **environment files** — a group of related **configuration**
|
|
2177
2194
|
for an app or service (URLs, feature flags, and any credentials it needs),
|
|
2178
2195
|
loaded together. Each `env` asset is an entire `.env` file stored under `env/`
|
|
2179
|
-
in your bundle (mode 0600
|
|
2196
|
+
in your bundle (mode 0600; on a filesystem without POSIX permissions, such as a
|
|
2197
|
+
CIFS/Azure Files share, the mount options decide the mode instead). Values may or may not be sensitive; **akm protects
|
|
2180
2198
|
them all the same** — key *names* are discoverable; values and comment text
|
|
2181
2199
|
never appear in structured output (comments routinely contain commented-out
|
|
2182
2200
|
credentials, so they are treated like values). akm does **not** manage
|
|
@@ -2336,7 +2354,9 @@ Manage **secrets** — a single sensitive value used on its own for
|
|
|
2336
2354
|
service-account JSON. Where an [env](#env) file holds a *group* of related
|
|
2337
2355
|
configuration and exposes key *names*, a secret is *one* value and its **entire
|
|
2338
2356
|
file is the value**, so only the secret's *name* is ever surfaced. Each secret
|
|
2339
|
-
is a mode-0600 file under `secrets/` in your bundle
|
|
2357
|
+
is a mode-0600 file under `secrets/` in your bundle (on a filesystem without
|
|
2358
|
+
POSIX permissions, such as a CIFS/Azure Files share, the mount options decide
|
|
2359
|
+
the mode instead).
|
|
2340
2360
|
|
|
2341
2361
|
This mirrors Docker's secret model (one value per file, mounted at
|
|
2342
2362
|
`/run/secrets/<name>`, read at runtime, never baked into the image or env at
|
|
@@ -3030,11 +3050,13 @@ akm proposal new <type> <name> --file ./prompt.md
|
|
|
3030
3050
|
akm proposal new skill code-review --task "PR-style review skill"
|
|
3031
3051
|
akm proposal new lesson docker-cleanup --file ./prompts/docker-cleanup.md
|
|
3032
3052
|
akm proposal new skill code-review --path team --task "PR-style review skill" # writes under skills/team/
|
|
3053
|
+
akm proposal new skill code-review --bundle team --task "PR-style review skill" # queued for the `team` bundle
|
|
3033
3054
|
```
|
|
3034
3055
|
|
|
3035
3056
|
| Flag | Description |
|
|
3036
3057
|
| --- | --- |
|
|
3037
3058
|
| `--path` | Relative subdirectory under the type dir to place the proposed asset in (e.g. `release`). The filename comes from `<name>`. |
|
|
3059
|
+
| `--bundle` | Configured writable bundle to queue the proposal for. The prompt takes its convention facts from that bundle, the proposal records it as its target (so `akm proposal accept` writes there), and it is queued in that bundle's queue (`akm proposal list --queue <bundle>`). An unknown, disabled or read-only bundle is refused, as for `akm remember --bundle`. Default: the working bundle |
|
|
3038
3060
|
| `--task` | Inline task text |
|
|
3039
3061
|
| `--file` | Read task text from a UTF-8 file |
|
|
3040
3062
|
| `--engine` | Override the default execution engine. Any kind works: an LLM engine, an agent CLI or `opencode-sdk` |
|
|
@@ -97,7 +97,7 @@ Everything in the cache is regenerable. It is safe to delete the entire cache di
|
|
|
97
97
|
|
|
98
98
|
| Path | Contents | Safe to delete? |
|
|
99
99
|
|---|---|---|
|
|
100
|
-
| `config-backups/config-<timestamp>.json` | Pre-save config snapshots (5 retained; owner-only permissions — file `0600`, dir `0700`, since 08-F4) | Yes |
|
|
100
|
+
| `config-backups/config-<timestamp>.json` | Pre-save config snapshots (5 retained; owner-only permissions — file `0600`, dir `0700`, since 08-F4; on a filesystem without POSIX permissions, such as a CIFS/Azure Files share, the mount options decide them instead) | Yes |
|
|
101
101
|
| `config-backups/config.latest.json` | Latest backup alias (owner-only `0600`) | Yes |
|
|
102
102
|
| `registry/` | Downloaded registry tarballs (bundle packages from npm, GitHub, etc.) | Yes — re-downloaded on next `akm bundle add` or `akm bundle update` |
|
|
103
103
|
| `registry-index/` | Legacy per-URL JSON cache (v0.7 artifact) | Yes — fully replaced by `index.db` in 0.8.0 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.261008-beta",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "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 Cursor.",
|
|
6
6
|
"keywords": [
|