akm-cli 0.4.0 → 0.5.0-rc1

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/dist/stash-add.js CHANGED
@@ -8,32 +8,50 @@ import { upsertLockEntry } from "./lockfile";
8
8
  import { detectStashRoot, installRegistryRef, upsertInstalledRegistryEntry } from "./registry-install";
9
9
  import { parseRegistryRef } from "./registry-resolve";
10
10
  import { ensureWebsiteMirror, validateWebsiteInputUrl } from "./stash-providers/website";
11
+ import { warn } from "./warn";
12
+ import { validateWikiName } from "./wiki";
13
+ const VALID_OVERRIDE_TYPES = new Set(["wiki"]);
11
14
  export async function akmAdd(input) {
12
15
  const ref = input.ref.trim();
13
16
  if (!ref)
14
17
  throw new UsageError("Install ref or local directory is required. " +
15
18
  "Examples: `akm add @scope/kit`, `akm add github:owner/repo`, `akm add ./local/path`");
19
+ // Validate and resolve wiki name when --type wiki is used
20
+ let wikiName;
21
+ if (input.overrideType) {
22
+ if (!VALID_OVERRIDE_TYPES.has(input.overrideType)) {
23
+ throw new UsageError(`Invalid --type value: "${input.overrideType}". Supported types: ${[...VALID_OVERRIDE_TYPES].join(", ")}`);
24
+ }
25
+ if (input.overrideType === "wiki") {
26
+ const derived = input.name ?? deriveWikiNameFromRef(ref);
27
+ validateWikiName(derived);
28
+ wikiName = derived;
29
+ }
30
+ }
16
31
  const stashDir = resolveStashDir();
17
32
  if (shouldAddAsWebsiteUrl(ref)) {
18
- return addWebsiteStashSource(ref, stashDir, input.name, input.options);
33
+ return addWebsiteStashSource(ref, stashDir, input.name ?? wikiName, input.options, wikiName);
19
34
  }
20
35
  // Detect local directory refs and route them to stashes[] instead of installed[]
21
36
  try {
22
37
  const parsed = parseRegistryRef(ref);
23
38
  if (parsed.source === "local") {
24
- return addLocalStashSource(ref, parsed.sourcePath, stashDir);
39
+ if (input.trustThisInstall) {
40
+ warn("--trust has no effect on local directory sources; the install audit is not run for local paths.");
41
+ }
42
+ return addLocalStashSource(ref, parsed.sourcePath, stashDir, wikiName);
25
43
  }
26
44
  }
27
45
  catch {
28
46
  // Not a local ref — fall through to registry install
29
47
  }
30
- return addRegistryKit(ref, stashDir);
48
+ return addRegistryKit(ref, stashDir, input.trustThisInstall, input.writable, wikiName);
31
49
  }
32
50
  /**
33
51
  * Add a local directory as a filesystem stash source.
34
52
  * Creates a stashes[] entry instead of an installed[] entry.
35
53
  */
36
- async function addLocalStashSource(ref, sourcePath, stashDir) {
54
+ async function addLocalStashSource(ref, sourcePath, stashDir, wikiName) {
37
55
  const stashRoot = detectStashRoot(sourcePath);
38
56
  const resolvedPath = path.resolve(stashRoot);
39
57
  const config = loadUserConfig();
@@ -44,11 +62,16 @@ async function addLocalStashSource(ref, sourcePath, stashDir) {
44
62
  const entry = {
45
63
  type: "filesystem",
46
64
  path: resolvedPath,
47
- name: toReadableId(resolvedPath),
65
+ name: wikiName ?? toReadableId(resolvedPath),
66
+ ...(wikiName ? { wikiName } : {}),
48
67
  };
49
68
  stashes.push(entry);
50
69
  saveConfig({ ...config, stashes });
51
70
  }
71
+ else if (wikiName && existing.wikiName !== wikiName) {
72
+ existing.wikiName = wikiName;
73
+ saveConfig({ ...config, stashes });
74
+ }
52
75
  const index = await akmIndex({ stashDir });
53
76
  const updatedConfig = loadConfig();
54
77
  return {
@@ -73,7 +96,7 @@ async function addLocalStashSource(ref, sourcePath, stashDir) {
73
96
  },
74
97
  };
75
98
  }
76
- async function addWebsiteStashSource(ref, stashDir, name, options) {
99
+ async function addWebsiteStashSource(ref, stashDir, name, options, wikiName) {
77
100
  const normalizedUrl = validateWebsiteInputUrl(ref);
78
101
  const config = loadUserConfig();
79
102
  const stashes = [...(config.stashes ?? [])];
@@ -84,13 +107,23 @@ async function addWebsiteStashSource(ref, stashDir, name, options) {
84
107
  url: normalizedUrl,
85
108
  name: name ?? toWebsiteName(normalizedUrl),
86
109
  ...(options && Object.keys(options).length > 0 ? { options } : {}),
110
+ ...(wikiName ? { wikiName } : {}),
87
111
  };
88
112
  stashes.push(entry);
89
113
  saveConfig({ ...config, stashes });
90
114
  }
91
- else if (options && Object.keys(options).length > 0) {
92
- entry.options = { ...entry.options, ...options };
93
- saveConfig({ ...config, stashes });
115
+ else {
116
+ let changed = false;
117
+ if (options && Object.keys(options).length > 0) {
118
+ entry.options = { ...entry.options, ...options };
119
+ changed = true;
120
+ }
121
+ if (wikiName && entry.wikiName !== wikiName) {
122
+ entry.wikiName = wikiName;
123
+ changed = true;
124
+ }
125
+ if (changed)
126
+ saveConfig({ ...config, stashes });
94
127
  }
95
128
  const cachePaths = await ensureWebsiteMirror(entry, { requireStashDir: true });
96
129
  const index = await akmIndex({ stashDir });
@@ -120,8 +153,8 @@ async function addWebsiteStashSource(ref, stashDir, name, options) {
120
153
  /**
121
154
  * Install a kit from a registry (npm, github, git).
122
155
  */
123
- async function addRegistryKit(ref, stashDir) {
124
- const installed = await installRegistryRef(ref);
156
+ async function addRegistryKit(ref, stashDir, trustThisInstall, writable, wikiName) {
157
+ const installed = await installRegistryRef(ref, { trustThisInstall, writable });
125
158
  const replaced = (loadConfig().installed ?? []).find((entry) => entry.id === installed.id);
126
159
  const config = upsertInstalledRegistryEntry({
127
160
  id: installed.id,
@@ -133,6 +166,8 @@ async function addRegistryKit(ref, stashDir) {
133
166
  stashRoot: installed.stashRoot,
134
167
  cacheDir: installed.cacheDir,
135
168
  installedAt: installed.installedAt,
169
+ writable: installed.writable,
170
+ ...(wikiName ? { wikiName } : {}),
136
171
  });
137
172
  await upsertLockEntry({
138
173
  id: installed.id,
@@ -211,3 +246,41 @@ function toWebsiteName(siteUrl) {
211
246
  return siteUrl;
212
247
  }
213
248
  }
249
+ /**
250
+ * Derive a wiki name from a ref string when --name is not provided.
251
+ * Lowercases and slugifies the most meaningful identifier segment.
252
+ */
253
+ export function deriveWikiNameFromRef(ref) {
254
+ let candidate = ref;
255
+ // github:owner/repo or github:owner/repo@ref
256
+ if (/^github:/i.test(ref)) {
257
+ const repoPath = ref.replace(/^github:/i, "").split("@")[0];
258
+ candidate = repoPath.split("/").pop() ?? repoPath;
259
+ }
260
+ // npm:pkg or @scope/pkg
261
+ else if (/^npm:/i.test(ref) || ref.startsWith("@")) {
262
+ candidate = ref
263
+ .replace(/^npm:/i, "")
264
+ .replace(/^@[^/]+\//, "")
265
+ .split("@")[0];
266
+ }
267
+ // git URLs or HTTPS git URLs
268
+ else if (/^(git:|https?:\/\/)/.test(ref)) {
269
+ try {
270
+ candidate = new URL(ref).pathname.split("/").pop() ?? candidate;
271
+ }
272
+ catch {
273
+ candidate = ref.split("/").pop() ?? ref;
274
+ }
275
+ candidate = candidate.replace(/\.git$/, "");
276
+ }
277
+ // Local paths
278
+ else {
279
+ candidate = path.basename(ref.replace(/\/+$/, ""));
280
+ }
281
+ return candidate
282
+ .toLowerCase()
283
+ .replace(/[^a-z0-9]+/g, "-")
284
+ .replace(/^-+|-+$/g, "")
285
+ .slice(0, 64);
286
+ }
@@ -1,16 +1,19 @@
1
- import { createHash } from "node:crypto";
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
- import { fetchWithRetry } from "../common";
5
- import { ConfigError } from "../errors";
5
+ import { resolveStashDir } from "../common";
6
+ import { loadConfig } from "../config";
7
+ import { ConfigError, UsageError } from "../errors";
6
8
  import { getRegistryIndexCacheDir } from "../paths";
7
- import { extractTarGzSecure } from "../registry-install";
9
+ import { validateGitUrl } from "../registry-resolve";
8
10
  import { registerStashProvider } from "../stash-provider-factory";
9
11
  import { isExpired, sanitizeString } from "./provider-utils";
10
12
  /** Cache TTL before refreshing the mirrored repo (12 hours). */
11
13
  const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
12
14
  /** Maximum stale age allowed when refresh fails (7 days). */
13
15
  const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const GIT_STASH_TYPES = new Set(["git", "context-hub", "github"]);
14
17
  class GitStashProvider {
15
18
  type = "git";
16
19
  name;
@@ -40,13 +43,13 @@ function getCachePaths(repoUrl) {
40
43
  const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
41
44
  return {
42
45
  rootDir,
43
- archivePath: path.join(rootDir, "repo.tar.gz"),
44
46
  repoDir: path.join(rootDir, "repo"),
45
47
  indexPath: path.join(rootDir, "index.json"),
46
48
  };
47
49
  }
48
50
  async function ensureGitMirror(repo, cachePaths, options) {
49
51
  const requireRepoDir = options?.requireRepoDir === true;
52
+ const writable = options?.writable === true;
50
53
  // Check if cache is fresh
51
54
  let mtime = 0;
52
55
  try {
@@ -60,8 +63,13 @@ async function ensureGitMirror(repo, cachePaths, options) {
60
63
  }
61
64
  try {
62
65
  fs.mkdirSync(cachePaths.rootDir, { recursive: true });
63
- await downloadArchive(buildTarballUrl(repo), cachePaths.archivePath);
64
- extractTarGzSecure(cachePaths.archivePath, cachePaths.repoDir);
66
+ if (writable && fs.existsSync(path.join(cachePaths.repoDir, ".git"))) {
67
+ // Writable repo already cloned — pull instead of re-clone to preserve local changes
68
+ pullRepo(cachePaths.repoDir);
69
+ }
70
+ else {
71
+ cloneRepo(repo.cloneUrl, repo.ref, cachePaths.repoDir, writable);
72
+ }
65
73
  // Touch index file to track freshness
66
74
  fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
67
75
  }
@@ -72,6 +80,50 @@ async function ensureGitMirror(repo, cachePaths, options) {
72
80
  throw err;
73
81
  }
74
82
  }
83
+ export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
84
+ // Stage the clone into a sibling temp dir so that a failed clone never
85
+ // destroys a previously-valid destDir (e.g. when the remote is temporarily
86
+ // unreachable and we have a valid cached copy).
87
+ const tmpDir = `${destDir}.tmp-${randomBytes(4).toString("hex")}`;
88
+ const args = ["clone", "--depth", "1"];
89
+ if (ref)
90
+ args.push("--branch", ref);
91
+ args.push(cloneUrl, tmpDir);
92
+ const result = spawnSync("git", args, { encoding: "utf8", timeout: 120_000 });
93
+ if (result.status !== 0) {
94
+ // Clean up the (possibly partial) temp dir but leave destDir untouched.
95
+ fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ const err = result.stderr?.trim() || result.error?.message || "unknown error";
97
+ throw new Error(`Failed to clone ${cloneUrl}: ${err}`);
98
+ }
99
+ try {
100
+ if (!writable) {
101
+ // Remove .git directory — we only need the working tree for read-only stashes
102
+ const gitDir = path.join(tmpDir, ".git");
103
+ if (fs.existsSync(gitDir))
104
+ fs.rmSync(gitDir, { recursive: true, force: true });
105
+ }
106
+ // Swap: remove the old destDir (if any) then atomically rename tmpDir into place.
107
+ if (fs.existsSync(destDir))
108
+ fs.rmSync(destDir, { recursive: true, force: true });
109
+ fs.renameSync(tmpDir, destDir);
110
+ }
111
+ catch (err) {
112
+ // Post-clone steps failed — clean up the temp dir to avoid orphaned dirs.
113
+ fs.rmSync(tmpDir, { recursive: true, force: true });
114
+ throw err;
115
+ }
116
+ }
117
+ function pullRepo(repoDir) {
118
+ const result = spawnSync("git", ["-C", repoDir, "pull", "--ff-only"], {
119
+ encoding: "utf8",
120
+ timeout: 120_000,
121
+ });
122
+ if (result.status !== 0) {
123
+ const err = result.stderr?.trim() || result.error?.message || "unknown error";
124
+ throw new Error(`Failed to pull ${repoDir}: ${err}`);
125
+ }
126
+ }
75
127
  function hasExtractedRepo(repoDir) {
76
128
  try {
77
129
  return fs.statSync(repoDir).isDirectory() && fs.statSync(path.join(repoDir, "content")).isDirectory();
@@ -80,25 +132,22 @@ function hasExtractedRepo(repoDir) {
80
132
  return false;
81
133
  }
82
134
  }
83
- async function downloadArchive(url, destination) {
84
- const response = await fetchWithRetry(url, undefined, { timeout: 120_000, retries: 1 });
85
- if (!response.ok) {
86
- throw new Error(`Failed to download archive (${response.status}) from ${url}`);
87
- }
88
- const BunRuntime = globalThis.Bun;
89
- if (BunRuntime?.write) {
90
- await BunRuntime.write(destination, response);
91
- return;
92
- }
93
- const arrayBuffer = await response.arrayBuffer();
94
- fs.writeFileSync(destination, Buffer.from(arrayBuffer));
95
- }
96
- function buildTarballUrl(repo) {
97
- return `https://github.com/${repo.owner}/${repo.repo}/archive/refs/heads/${repo.ref}.tar.gz`;
98
- }
99
135
  function parseGitRepoUrl(rawUrl) {
100
136
  if (!rawUrl) {
101
- throw new ConfigError("Git provider requires a GitHub repository URL");
137
+ throw new ConfigError("Git provider requires a repository URL");
138
+ }
139
+ // SSH shorthand: git@host:path — valid as-is, delegated to system git credentials
140
+ if (/^git@[^:]+:.+$/.test(rawUrl)) {
141
+ return { cloneUrl: rawUrl, ref: null, canonicalUrl: rawUrl };
142
+ }
143
+ // Validate URL scheme is safe before parsing
144
+ try {
145
+ validateGitUrl(rawUrl);
146
+ }
147
+ catch (err) {
148
+ if (err instanceof UsageError)
149
+ throw new ConfigError(err.message);
150
+ throw err;
102
151
  }
103
152
  let parsed;
104
153
  try {
@@ -107,33 +156,122 @@ function parseGitRepoUrl(rawUrl) {
107
156
  catch {
108
157
  throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`);
109
158
  }
110
- if (parsed.protocol !== "https:") {
111
- throw new ConfigError(`Git provider URL must use https://, got "${parsed.protocol}"`);
159
+ // GitHub web URLs: extract a clean clone URL and optional branch from /tree/<ref>
160
+ if (parsed.hostname === "github.com" && parsed.protocol === "https:") {
161
+ const segments = parsed.pathname.split("/").filter(Boolean);
162
+ if (segments.length < 2) {
163
+ throw new ConfigError(`Git provider URL must point to a repository, got "${rawUrl}"`);
164
+ }
165
+ const owner = sanitizeString(segments[0]);
166
+ const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
167
+ if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
168
+ throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
169
+ }
170
+ let ref = null;
171
+ if (segments[2] === "tree" && segments.length >= 4) {
172
+ const rawRef = sanitizeString(segments.slice(3).join("/"), 255);
173
+ if (rawRef && !rawRef.includes("..") && /^[A-Za-z0-9._/-]+$/.test(rawRef)) {
174
+ ref = rawRef;
175
+ }
176
+ }
177
+ const cloneUrl = `https://github.com/${owner}/${repo}`;
178
+ const canonicalUrl = ref ? `${cloneUrl}/tree/${ref}` : cloneUrl;
179
+ return { cloneUrl, ref, canonicalUrl };
180
+ }
181
+ // Any other valid git URL: use as-is for cloning, but strip embedded credentials
182
+ // from canonicalUrl so secrets don't leak into cache keys or warning messages.
183
+ let canonicalUrl = rawUrl;
184
+ try {
185
+ const u = new URL(rawUrl);
186
+ u.username = "";
187
+ u.password = "";
188
+ u.search = "";
189
+ u.hash = "";
190
+ canonicalUrl = u.toString();
191
+ }
192
+ catch {
193
+ // URL failed to parse — fall back to raw (validateGitUrl already accepted it)
194
+ }
195
+ return { cloneUrl: rawUrl, ref: null, canonicalUrl };
196
+ }
197
+ /**
198
+ * Commit (and optionally push) local changes in a git-backed stash.
199
+ *
200
+ * Behaviour:
201
+ * - Not a git repo → skipped (no-op)
202
+ * - Git repo, no remote → commit only
203
+ * - Git repo, has remote, but stash is not writable → commit only
204
+ * - Git repo, has remote, stash is writable → commit + push
205
+ *
206
+ * When `name` is omitted the primary stash directory is used.
207
+ * When `message` is omitted a timestamp is used.
208
+ */
209
+ export function saveGitStash(name, message, writableOverride) {
210
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
211
+ const commitMessage = message?.trim() || `akm save ${timestamp}`;
212
+ let repoDir;
213
+ let writable = false;
214
+ if (name) {
215
+ const config = loadConfig();
216
+ const stash = config.stashes?.find((s) => s.name === name || s.url === name);
217
+ if (!stash)
218
+ throw new UsageError(`No git stash found with name "${name}"`);
219
+ if (!GIT_STASH_TYPES.has(stash.type)) {
220
+ throw new UsageError(`Stash "${name}" is not a git stash (type: ${stash.type})`);
221
+ }
222
+ if (!stash.url)
223
+ throw new UsageError(`Stash "${name}" has no URL configured`);
224
+ const repo = parseGitRepoUrl(stash.url);
225
+ repoDir = getCachePaths(repo.canonicalUrl).repoDir;
226
+ writable = stash.writable === true;
227
+ }
228
+ else {
229
+ repoDir = resolveStashDir({ readOnly: true });
230
+ // Allow caller to override writable for the primary stash (e.g. from root config.writable)
231
+ if (writableOverride !== undefined) {
232
+ writable = writableOverride;
233
+ }
234
+ }
235
+ // No-op: not a git repo
236
+ if (!fs.existsSync(path.join(repoDir, ".git"))) {
237
+ return { committed: false, pushed: false, skipped: true, reason: "not a git repository", output: "" };
238
+ }
239
+ // Nothing to commit?
240
+ const statusResult = spawnSync("git", ["-C", repoDir, "status", "--porcelain"], { encoding: "utf8" });
241
+ if (statusResult.error || statusResult.status !== 0) {
242
+ throw new Error(`git status failed: ${statusResult.error?.message || statusResult.stderr?.trim() || "unknown error"}`);
243
+ }
244
+ if (!statusResult.stdout.trim()) {
245
+ return { committed: false, pushed: false, skipped: false, output: "nothing to commit, working tree clean" };
112
246
  }
113
- if (parsed.hostname !== "github.com") {
114
- throw new ConfigError(`Git provider only supports github.com URLs, got "${parsed.hostname}"`);
247
+ // Stage and commit — supply fallback identity so fresh environments without
248
+ // user.name/user.email configured can always commit to the default stash.
249
+ const addResult = spawnSync("git", ["-C", repoDir, "add", "-A"], { encoding: "utf8" });
250
+ if (addResult.status !== 0) {
251
+ throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
115
252
  }
116
- const segments = parsed.pathname.split("/").filter(Boolean);
117
- if (segments.length < 2) {
118
- throw new ConfigError(`Git provider URL must point to a GitHub repository, got "${rawUrl}"`);
253
+ const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", commitMessage], { encoding: "utf8" });
254
+ if (commitResult.status !== 0) {
255
+ throw new Error(`git commit failed: ${commitResult.stderr?.trim() || "unknown error"}`);
119
256
  }
120
- const owner = sanitizeString(segments[0]);
121
- const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
122
- let ref = "main";
123
- if (segments[2] === "tree" && segments.length >= 4) {
124
- ref = sanitizeString(segments.slice(3).join("/"), 255) || "main";
257
+ // Push only when there is a remote AND the stash is marked writable
258
+ const remoteResult = spawnSync("git", ["-C", repoDir, "remote"], { encoding: "utf8" });
259
+ if (remoteResult.status !== 0) {
260
+ throw new Error(`git remote failed: ${remoteResult.stderr?.trim() || "unknown error"}`);
125
261
  }
126
- if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
127
- throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
262
+ const hasRemote = remoteResult.stdout.trim().length > 0;
263
+ if (!hasRemote || !writable) {
264
+ return { committed: true, pushed: false, skipped: false, output: commitResult.stdout.trim() };
128
265
  }
129
- if (!ref || ref.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(ref)) {
130
- throw new ConfigError(`Unsupported branch/ref in URL: "${rawUrl}"`);
266
+ const pushResult = spawnSync("git", ["-C", repoDir, "push"], { encoding: "utf8", timeout: 120_000 });
267
+ if (pushResult.status !== 0) {
268
+ throw new Error(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`);
131
269
  }
132
270
  return {
133
- owner,
134
- repo,
135
- ref,
136
- canonicalUrl: `https://github.com/${owner}/${repo}/tree/${ref}`,
271
+ committed: true,
272
+ pushed: true,
273
+ skipped: false,
274
+ output: (commitResult.stdout + pushResult.stdout).trim() || "changes committed and pushed",
137
275
  };
138
276
  }
139
277
  // ── Exports ─────────────────────────────────────────────────────────────────
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { loadConfig } from "./config";
3
4
  import { closeDatabase, openDatabase } from "./db";
@@ -13,6 +14,35 @@ import { resolveAssetPath } from "./stash-resolve";
13
14
  import { insertUsageEvent } from "./usage-events";
14
15
  // Eagerly import stash providers to trigger self-registration
15
16
  import "./stash-providers/index";
17
+ /**
18
+ * Show a wiki root (no page path) — returns the same payload as
19
+ * `akm wiki show <name>`.
20
+ *
21
+ * Called when `parseAssetRef` yields `type === "wiki"` and the name has no
22
+ * `/`, e.g. `wiki:research`.
23
+ */
24
+ async function showWikiRoot(stashDir, wikiName) {
25
+ const { showWiki, resolveWikiDir } = await import("./wiki.js");
26
+ const wikiDir = resolveWikiDir(stashDir, wikiName);
27
+ if (!fs.existsSync(wikiDir)) {
28
+ throw new NotFoundError(`Wiki not found: ${wikiName}. Run \`akm wiki create ${wikiName}\` to create it.`);
29
+ }
30
+ const result = showWiki(stashDir, wikiName);
31
+ // Shape the WikiShowResult into a ShowResponse-compatible object.
32
+ // The payload mirrors what `akm wiki show <name>` returns.
33
+ return {
34
+ type: "wiki",
35
+ name: result.ref,
36
+ path: result.path,
37
+ ...(result.description ? { description: result.description } : {}),
38
+ origin: null,
39
+ editable: false,
40
+ pages: result.pages,
41
+ raws: result.raws,
42
+ ...(result.lastModified ? { lastModified: result.lastModified } : {}),
43
+ recentLog: result.recentLog,
44
+ };
45
+ }
16
46
  /**
17
47
  * Unified show: tries local FTS5 index first, then remote providers.
18
48
  *
@@ -21,6 +51,30 @@ import "./stash-providers/index";
21
51
  */
22
52
  export async function akmShowUnified(input) {
23
53
  const ref = input.ref.trim();
54
+ // 0. Wiki-root shortcut: `wiki:<name>` with no page path routes to the
55
+ // wiki summary (same payload as `akm wiki show <name>`). Honour
56
+ // `parsed.origin` by resolving against the matching stash source(s),
57
+ // falling back to the primary stash when no origin is given.
58
+ {
59
+ const parsed = parseAssetRef(ref);
60
+ if (parsed.type === "wiki" && !parsed.name.includes("/")) {
61
+ const allSources = resolveStashSources();
62
+ const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
63
+ let lastError;
64
+ for (const source of searchSources) {
65
+ try {
66
+ return await showWikiRoot(source.path, parsed.name);
67
+ }
68
+ catch (err) {
69
+ if (!(err instanceof NotFoundError))
70
+ throw err;
71
+ lastError = err;
72
+ }
73
+ }
74
+ throw (lastError ??
75
+ new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`));
76
+ }
77
+ }
24
78
  // 1. Try local filesystem first (FTS5 index lookup)
25
79
  let localError;
26
80
  try {
@@ -127,7 +181,7 @@ export async function showLocal(input) {
127
181
  if (!renderer) {
128
182
  throw new UsageError(`Renderer "${match.renderer}" not found for asset: ${displayType}:${parsed.name}`);
129
183
  }
130
- const renderCtx = buildRenderContext(fileCtx, match, allStashDirs);
184
+ const renderCtx = buildRenderContext(fileCtx, match, allStashDirs, source?.registryId);
131
185
  const response = renderer.buildShowResponse(renderCtx);
132
186
  const editable = isEditable(assetPath, config);
133
187
  const fullResponse = {
@@ -188,6 +242,7 @@ function buildSummaryResponse(full, assetPath) {
188
242
  ...(description ? { description } : {}),
189
243
  ...(tags && tags.length > 0 ? { tags } : {}),
190
244
  ...(full.parameters ? { parameters: full.parameters } : {}),
245
+ ...(full.workflowTitle ? { workflowTitle: full.workflowTitle } : {}),
191
246
  ...(full.action ? { action: full.action } : {}),
192
247
  ...(full.run ? { run: full.run } : {}),
193
248
  ...(full.origin !== undefined ? { origin: full.origin } : {}),
@@ -11,12 +11,16 @@ import { resolveStashSources } from "./search-source";
11
11
  * (e.g. "openviking").
12
12
  */
13
13
  export function addStash(opts) {
14
- const { target, name, providerType, options: providerOptions } = opts;
14
+ const { target, name, providerType, options: providerOptions, writable } = opts;
15
15
  const config = loadUserConfig();
16
16
  const stashes = [...(config.stashes ?? [])];
17
- const isUrl = target.startsWith("http://") || target.startsWith("https://");
17
+ const isRemoteUrl = target.startsWith("http://") ||
18
+ target.startsWith("https://") ||
19
+ target.startsWith("git@") ||
20
+ target.startsWith("ssh://") ||
21
+ target.startsWith("git://");
18
22
  let entry;
19
- if (isUrl) {
23
+ if (isRemoteUrl) {
20
24
  if (!providerType) {
21
25
  throw new UsageError("--provider is required for URL sources (e.g. --provider openviking)");
22
26
  }
@@ -27,6 +31,8 @@ export function addStash(opts) {
27
31
  entry = { type: providerType, url: target };
28
32
  if (name)
29
33
  entry.name = name;
34
+ if (writable)
35
+ entry.writable = true;
30
36
  if (providerOptions)
31
37
  entry.options = providerOptions;
32
38
  }
@@ -51,7 +57,11 @@ export function addStash(opts) {
51
57
  export function removeStash(target) {
52
58
  const config = loadUserConfig();
53
59
  const stashes = [...(config.stashes ?? [])];
54
- const isUrl = target.startsWith("http://") || target.startsWith("https://");
60
+ const isUrl = target.startsWith("http://") ||
61
+ target.startsWith("https://") ||
62
+ target.startsWith("git@") ||
63
+ target.startsWith("ssh://") ||
64
+ target.startsWith("git://");
55
65
  const resolvedPath = !isUrl ? path.resolve(target) : undefined;
56
66
  // Try URL match first, then path, then name (most specific → least specific)
57
67
  let idx = -1;