promptdock 0.1.0

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.
@@ -0,0 +1,206 @@
1
+ // The two-phase install engine (shared by `install` and `update`):
2
+ // POST install → download to a SIBLING temp dir → verify EVERY sha256 →
3
+ // re-validate EVERY path (E8/audit row 10 — the server is untrusted) →
4
+ // atomic move into place (same-parent rename; staged replace on update) →
5
+ // write the receipt → best-effort /install/complete.
6
+ // All-or-nothing: any failure discards the temp dir and leaves the target
7
+ // exactly as it was ("integrity check failed — nothing installed").
8
+ import { randomBytes } from "node:crypto";
9
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { CliError, EXIT, mapFsError, networkError } from "./errors.js";
12
+ import { MAX_SKILL_FILES, MAX_SKILL_FILE_BYTES, MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
13
+ import { sha256Hex, sha256Matches } from "./integrity.js";
14
+ import { validateSkillPaths } from "./paths.js";
15
+ import { writeReceipt, RECEIPT_SCHEMA } from "./receipts.js";
16
+ const INTEGRITY_ABORT = "integrity check failed — nothing installed";
17
+ /** Narrow the untrusted install response; throws INTEGRITY on a bad shape. */
18
+ export function checkInstallResponse(resp) {
19
+ const bad = () => new CliError(`${INTEGRITY_ABORT} (malformed server response)`, EXIT.INTEGRITY, {
20
+ footer: "integrity",
21
+ });
22
+ if (typeof resp !== "object" || resp === null)
23
+ throw bad();
24
+ const r = resp;
25
+ if (!r.skill || typeof r.skill.version_id !== "string" || typeof r.skill.slug !== "string")
26
+ throw bad();
27
+ if (!Array.isArray(r.manifest) || !Array.isArray(r.files))
28
+ throw bad();
29
+ for (const e of r.manifest) {
30
+ if (typeof e?.path !== "string" || typeof e?.sha256 !== "string")
31
+ throw bad();
32
+ }
33
+ for (const f of r.files) {
34
+ if (typeof f?.path !== "string" || typeof f?.url !== "string")
35
+ throw bad();
36
+ }
37
+ return r;
38
+ }
39
+ /**
40
+ * E8 client-side re-validation + the shared D7 bounds. A server compromise must
41
+ * not become an arbitrary file write OR a disk-filling download.
42
+ */
43
+ export function assertSafeManifest(manifest) {
44
+ const issues = validateSkillPaths(manifest.map((e) => e.path));
45
+ if (issues.length > 0) {
46
+ const first = issues[0];
47
+ throw new CliError(`unsafe file path in manifest ("${first.path}": ${first.reason}) — refusing to install`, EXIT.INTEGRITY, { footer: "manifest_path" });
48
+ }
49
+ if (manifest.length === 0 || manifest.length > MAX_SKILL_FILES) {
50
+ throw new CliError(`manifest lists ${manifest.length} files (limit ${MAX_SKILL_FILES}) — refusing to install`, EXIT.INTEGRITY, { footer: "manifest_bounds" });
51
+ }
52
+ let total = 0;
53
+ for (const e of manifest) {
54
+ const bytes = Number(e.bytes) || 0;
55
+ if (bytes > MAX_SKILL_FILE_BYTES) {
56
+ throw new CliError(`manifest entry "${e.path}" exceeds the ${MAX_SKILL_FILE_BYTES / (1024 * 1024)}MB per-file limit — refusing to install`, EXIT.INTEGRITY, { footer: "manifest_bounds" });
57
+ }
58
+ total += bytes;
59
+ }
60
+ if (total > MAX_SKILL_TOTAL_BYTES) {
61
+ throw new CliError(`manifest totals ${total} bytes (limit ${MAX_SKILL_TOTAL_BYTES}) — refusing to install`, EXIT.INTEGRITY, { footer: "manifest_bounds" });
62
+ }
63
+ }
64
+ /**
65
+ * Download every manifest file into `<parent>/.promptdock-staging-*` (SIBLING of
66
+ * the target so the final rename never crosses filesystems), verifying sha256 +
67
+ * byte length against the manifest. Throws (and discards the temp dir) on ANY
68
+ * mismatch — the sha in the manifest is the transport-integrity anchor.
69
+ */
70
+ export async function downloadAndStage(ctx, api, resp, targetDir) {
71
+ assertSafeManifest(resp.manifest);
72
+ const urlByPath = new Map(resp.files.map((f) => [f.path, f.url]));
73
+ const parent = dirname(targetDir);
74
+ try {
75
+ mkdirSync(parent, { recursive: true });
76
+ }
77
+ catch (err) {
78
+ throw mapFsError(err, parent);
79
+ }
80
+ const tempDir = join(parent, `.promptdock-staging-${randomBytes(6).toString("hex")}`);
81
+ const files = [];
82
+ try {
83
+ mkdirSync(tempDir, { recursive: true });
84
+ for (const entry of resp.manifest) {
85
+ const url = urlByPath.get(entry.path);
86
+ if (!url) {
87
+ throw new CliError(`${INTEGRITY_ABORT} (no download URL for "${entry.path}")`, EXIT.INTEGRITY, {
88
+ footer: "integrity",
89
+ });
90
+ }
91
+ let res;
92
+ try {
93
+ res = await ctx.fetch(url);
94
+ }
95
+ catch {
96
+ throw networkError(api.baseUrl, ctx.env);
97
+ }
98
+ if (!res.ok) {
99
+ // A lapsed signed URL is a free re-request (audit row 13) — say so.
100
+ throw new CliError(`download failed for "${entry.path}" (HTTP ${res.status}) — re-run the install (the download link may have expired; retrying is free)`, EXIT.NETWORK, { footer: "download" });
101
+ }
102
+ const buf = Buffer.from(await res.arrayBuffer());
103
+ if (buf.byteLength > MAX_SKILL_FILE_BYTES) {
104
+ throw new CliError(`${INTEGRITY_ABORT} ("${entry.path}" oversized)`, EXIT.INTEGRITY, {
105
+ footer: "integrity",
106
+ });
107
+ }
108
+ if (!sha256Matches(buf, entry.sha256)) {
109
+ throw new CliError(`${INTEGRITY_ABORT} (sha256 mismatch on "${entry.path}")`, EXIT.INTEGRITY, {
110
+ footer: "integrity",
111
+ });
112
+ }
113
+ const abs = join(tempDir, entry.path); // path already validated relative + traversal-free
114
+ try {
115
+ mkdirSync(dirname(abs), { recursive: true });
116
+ writeFileSync(abs, buf);
117
+ }
118
+ catch (err) {
119
+ throw mapFsError(err, abs);
120
+ }
121
+ files.push({ path: entry.path, sha256: sha256Hex(buf) });
122
+ }
123
+ return { tempDir, files };
124
+ }
125
+ catch (err) {
126
+ rmSync(tempDir, { recursive: true, force: true });
127
+ throw err;
128
+ }
129
+ }
130
+ /**
131
+ * Atomic-ish commit: fresh target → one rename; existing target → staged
132
+ * replace (target → .promptdock-old-*, temp → target, drop old; on failure the
133
+ * old dir is restored). Same-parent renames only.
134
+ */
135
+ export function commitStaged(tempDir, targetDir) {
136
+ const parent = dirname(targetDir);
137
+ if (!existsSync(targetDir)) {
138
+ try {
139
+ renameSync(tempDir, targetDir);
140
+ }
141
+ catch (err) {
142
+ rmSync(tempDir, { recursive: true, force: true });
143
+ throw mapFsError(err, targetDir);
144
+ }
145
+ return;
146
+ }
147
+ const oldDir = join(parent, `.promptdock-old-${randomBytes(6).toString("hex")}`);
148
+ try {
149
+ renameSync(targetDir, oldDir);
150
+ }
151
+ catch (err) {
152
+ rmSync(tempDir, { recursive: true, force: true });
153
+ throw mapFsError(err, targetDir);
154
+ }
155
+ try {
156
+ renameSync(tempDir, targetDir);
157
+ }
158
+ catch (err) {
159
+ // Roll the old contents back — the machine must never end up with NOTHING.
160
+ try {
161
+ renameSync(oldDir, targetDir);
162
+ }
163
+ catch {
164
+ /* the old dir stays on disk for manual recovery */
165
+ }
166
+ rmSync(tempDir, { recursive: true, force: true });
167
+ throw mapFsError(err, targetDir);
168
+ }
169
+ rmSync(oldDir, { recursive: true, force: true });
170
+ }
171
+ /**
172
+ * The full phase-2: download → verify → commit → receipt → best-effort complete.
173
+ * `refString` is the canonical handle/slug; `prior` carries a matching update's
174
+ * receipt so unknown fields survive the rewrite (DX F16).
175
+ */
176
+ export async function performInstall(ctx, api, opts) {
177
+ const staged = await downloadAndStage(ctx, api, opts.resp, opts.targetDir);
178
+ commitStaged(staged.tempDir, opts.targetDir);
179
+ const receipt = {
180
+ schema: RECEIPT_SCHEMA,
181
+ cli_version: ctx.version,
182
+ skill_id: opts.resp.skill.id,
183
+ ref: opts.refString,
184
+ version_id: opts.resp.skill.version_id,
185
+ version: opts.resp.skill.version,
186
+ installed_at: new Date(ctx.now()).toISOString(),
187
+ target: opts.targetId,
188
+ files: staged.files,
189
+ };
190
+ try {
191
+ writeReceipt(opts.targetDir, receipt, opts.prior);
192
+ }
193
+ catch (err) {
194
+ throw mapFsError(err, join(opts.targetDir, ".promptdock.json"));
195
+ }
196
+ // E5 phase 2 — best-effort: the files are on disk either way; the server's
197
+ // grace sweep expires an uncompleted ticket, and a retryable complete is
198
+ // idempotent. Never fail a finished install over this call.
199
+ try {
200
+ await api.request("POST", `/api/v1/cli/skills/${opts.resp.skill.id}/install/complete`);
201
+ }
202
+ catch {
203
+ /* best-effort */
204
+ }
205
+ return receipt;
206
+ }
@@ -0,0 +1,4 @@
1
+ /** sha256 hex of a buffer — the manifest's integrity anchor. */
2
+ export declare function sha256Hex(data: Uint8Array | Buffer): string;
3
+ /** Constant-shape compare (case-forgiving hex; empty expected = never matches). */
4
+ export declare function sha256Matches(data: Uint8Array | Buffer, expectedHex: string): boolean;
@@ -0,0 +1,11 @@
1
+ import { createHash } from "node:crypto";
2
+ /** sha256 hex of a buffer — the manifest's integrity anchor. */
3
+ export function sha256Hex(data) {
4
+ return createHash("sha256").update(data).digest("hex");
5
+ }
6
+ /** Constant-shape compare (case-forgiving hex; empty expected = never matches). */
7
+ export function sha256Matches(data, expectedHex) {
8
+ if (!/^[0-9a-fA-F]{64}$/.test(expectedHex))
9
+ return false;
10
+ return sha256Hex(data) === expectedHex.toLowerCase();
11
+ }
@@ -0,0 +1,5 @@
1
+ export type PathIssue = {
2
+ path: string;
3
+ reason: string;
4
+ };
5
+ export declare function validateSkillPaths(paths: string[]): PathIssue[];
package/dist/paths.js ADDED
@@ -0,0 +1,63 @@
1
+ // Port of lib/validation/skills.ts validateSkillPaths (E8 / audit row 10): the
2
+ // CLI RE-CHECKS every manifest path before ANY write — a compromised server must
3
+ // never become an arbitrary file write on an installer machine. Keep this port
4
+ // line-for-line with the app original; test/paths-parity.test.ts mirrors the
5
+ // app's vectors so a one-sided edit is a red test.
6
+ const WINDOWS_RESERVED = new Set([
7
+ "con", "prn", "aux", "nul",
8
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
9
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
10
+ ]);
11
+ export function validateSkillPaths(paths) {
12
+ const issues = [];
13
+ const seen = new Map(); // normalized key -> original
14
+ for (const p of paths) {
15
+ if (p.length === 0 || p.length > 256) {
16
+ issues.push({ path: p, reason: "path length" });
17
+ continue;
18
+ }
19
+ if (p.includes("\\")) {
20
+ issues.push({ path: p, reason: "backslash separator" });
21
+ continue;
22
+ }
23
+ if (p.startsWith("/") || /^[a-zA-Z]:/.test(p)) {
24
+ issues.push({ path: p, reason: "absolute path" });
25
+ continue;
26
+ }
27
+ if (/[\u0000-\u001f\u007f]/.test(p)) {
28
+ issues.push({ path: p, reason: "control characters" });
29
+ continue;
30
+ }
31
+ const segments = p.split("/");
32
+ let bad = false;
33
+ for (const seg of segments) {
34
+ if (seg === "" || seg === "." || seg === "..") {
35
+ issues.push({ path: p, reason: "path traversal segment" });
36
+ bad = true;
37
+ break;
38
+ }
39
+ const base = seg.toLowerCase().split(".")[0];
40
+ if (WINDOWS_RESERVED.has(base)) {
41
+ issues.push({ path: p, reason: `Windows reserved name "${seg}"` });
42
+ bad = true;
43
+ break;
44
+ }
45
+ if (seg.endsWith(" ") || seg.endsWith(".")) {
46
+ issues.push({ path: p, reason: "trailing space/dot segment" });
47
+ bad = true;
48
+ break;
49
+ }
50
+ }
51
+ if (bad)
52
+ continue;
53
+ // Case-insensitive + NFC-normalized collision detection (macOS/Windows default FS).
54
+ const key = p.normalize("NFC").toLowerCase();
55
+ const prior = seen.get(key);
56
+ if (prior !== undefined && prior !== p) {
57
+ issues.push({ path: p, reason: `collides with "${prior}" on case-insensitive filesystems` });
58
+ continue;
59
+ }
60
+ seen.set(key, p);
61
+ }
62
+ return issues;
63
+ }
@@ -0,0 +1,58 @@
1
+ export declare const RECEIPT_NAME = ".promptdock.json";
2
+ export declare const RECEIPT_SCHEMA = 1;
3
+ export type ReceiptFile = {
4
+ path: string;
5
+ sha256: string;
6
+ };
7
+ export type Receipt = {
8
+ schema: number;
9
+ cli_version: string;
10
+ skill_id: string;
11
+ /** canonical `handle/slug` */
12
+ ref: string;
13
+ version_id: string;
14
+ version: number;
15
+ installed_at: string;
16
+ /** target registry id, or "custom" for --dir installs */
17
+ target: string;
18
+ files: ReceiptFile[];
19
+ /** forward-compat: unknown fields from newer CLIs survive rewrites */
20
+ [key: string]: unknown;
21
+ };
22
+ export type ReadReceipt = {
23
+ kind: "ok";
24
+ receipt: Receipt;
25
+ } | {
26
+ kind: "none";
27
+ } | {
28
+ kind: "newer_schema";
29
+ schema: number;
30
+ } | {
31
+ kind: "invalid";
32
+ };
33
+ export declare function readReceipt(dir: string): ReadReceipt;
34
+ /** Write the receipt; `prior` carries unknown fields forward (DX F16). */
35
+ export declare function writeReceipt(dir: string, receipt: Receipt, prior?: Receipt): void;
36
+ export type InstalledSkill = {
37
+ dir: string;
38
+ targetId: string;
39
+ scope: "local" | "global";
40
+ read: ReadReceipt;
41
+ };
42
+ /**
43
+ * Scan the known target dirs for receipts (U2/DX F5 scope rules: bare commands
44
+ * see project-local; -g sees global; --all sees both). Never follows symlinked
45
+ * skill dirs (a symlinked dir could point outside the scan root).
46
+ */
47
+ export declare function scanReceipts(opts: {
48
+ cwd: string;
49
+ home: string;
50
+ scope: "local" | "global" | "both";
51
+ targetId?: string;
52
+ }): InstalledSkill[];
53
+ /**
54
+ * DX5 local-edit guard: diff on-disk hashes vs the receipt manifest. A missing
55
+ * file or a symlink where a regular file should be counts as modified (the
56
+ * guard's job is "would uninstall/update destroy human work?").
57
+ */
58
+ export declare function modifiedFiles(dir: string, receipt: Receipt): string[];
@@ -0,0 +1,121 @@
1
+ // <dir>/.promptdock.json — the install receipt (DX5, audit row 4). THE ONLY
2
+ // uninstall/update authority: the CLI never deletes a file a receipt doesn't
3
+ // list. schema:1; unknown fields are PRESERVED across rewrites; a NEWER schema
4
+ // is a "run npx promptdock@latest" refusal, never a guessy uninstall (DX F16).
5
+ import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { sha256Hex } from "./integrity.js";
8
+ import { TARGETS, targetBaseDir } from "./registry.js";
9
+ export const RECEIPT_NAME = ".promptdock.json";
10
+ export const RECEIPT_SCHEMA = 1;
11
+ export function readReceipt(dir) {
12
+ let raw;
13
+ try {
14
+ raw = readFileSync(join(dir, RECEIPT_NAME), "utf8");
15
+ }
16
+ catch {
17
+ return { kind: "none" };
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(raw);
22
+ }
23
+ catch {
24
+ return { kind: "invalid" };
25
+ }
26
+ if (typeof parsed !== "object" || parsed === null)
27
+ return { kind: "invalid" };
28
+ const r = parsed;
29
+ if (typeof r.schema !== "number")
30
+ return { kind: "invalid" };
31
+ if (r.schema > RECEIPT_SCHEMA)
32
+ return { kind: "newer_schema", schema: r.schema };
33
+ if (typeof r.skill_id !== "string" ||
34
+ typeof r.ref !== "string" ||
35
+ typeof r.version_id !== "string" ||
36
+ !Array.isArray(r.files)) {
37
+ return { kind: "invalid" };
38
+ }
39
+ const files = [];
40
+ for (const f of r.files) {
41
+ if (typeof f !== "object" || f === null)
42
+ return { kind: "invalid" };
43
+ const { path, sha256 } = f;
44
+ if (typeof path !== "string" || typeof sha256 !== "string")
45
+ return { kind: "invalid" };
46
+ files.push({ path, sha256 });
47
+ }
48
+ return { kind: "ok", receipt: { ...r, files } };
49
+ }
50
+ /** Write the receipt; `prior` carries unknown fields forward (DX F16). */
51
+ export function writeReceipt(dir, receipt, prior) {
52
+ const merged = prior ? { ...prior, ...receipt } : receipt;
53
+ writeFileSync(join(dir, RECEIPT_NAME), JSON.stringify(merged, null, 2) + "\n");
54
+ }
55
+ /**
56
+ * Scan the known target dirs for receipts (U2/DX F5 scope rules: bare commands
57
+ * see project-local; -g sees global; --all sees both). Never follows symlinked
58
+ * skill dirs (a symlinked dir could point outside the scan root).
59
+ */
60
+ export function scanReceipts(opts) {
61
+ const out = [];
62
+ const scopes = opts.scope === "both" ? ["local", "global"] : [opts.scope];
63
+ const targets = opts.targetId
64
+ ? TARGETS.filter((t) => t.id === opts.targetId)
65
+ : TARGETS;
66
+ const seenDirs = new Set();
67
+ for (const scope of scopes) {
68
+ for (const t of targets) {
69
+ const base = targetBaseDir(t, { cwd: opts.cwd, home: opts.home, global: scope === "global" });
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(base);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ for (const name of entries) {
78
+ const dir = join(base, name);
79
+ if (seenDirs.has(dir))
80
+ continue; // claude local==global base when cwd==home
81
+ try {
82
+ if (!lstatSync(dir).isDirectory())
83
+ continue; // skips symlinked dirs too
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ const read = readReceipt(dir);
89
+ if (read.kind === "none")
90
+ continue;
91
+ seenDirs.add(dir);
92
+ out.push({ dir, targetId: t.id, scope, read });
93
+ }
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * DX5 local-edit guard: diff on-disk hashes vs the receipt manifest. A missing
100
+ * file or a symlink where a regular file should be counts as modified (the
101
+ * guard's job is "would uninstall/update destroy human work?").
102
+ */
103
+ export function modifiedFiles(dir, receipt) {
104
+ const modified = [];
105
+ for (const f of receipt.files) {
106
+ const abs = join(dir, f.path);
107
+ try {
108
+ const st = lstatSync(abs);
109
+ if (!st.isFile()) {
110
+ modified.push(f.path);
111
+ continue;
112
+ }
113
+ if (sha256Hex(readFileSync(abs)) !== f.sha256.toLowerCase())
114
+ modified.push(f.path);
115
+ }
116
+ catch {
117
+ modified.push(f.path);
118
+ }
119
+ }
120
+ return modified;
121
+ }
package/dist/ref.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type SkillRef = {
2
+ handle: string;
3
+ slug: string;
4
+ };
5
+ /**
6
+ * Accepts `handle/slug`, `@handle/slug`, and pasted promptdock.ai skill URLs;
7
+ * case-folds both halves (handles are citext). Null when the string can't be a
8
+ * ref (callers render the not-found help).
9
+ */
10
+ export declare function parseSkillRef(input: string): SkillRef | null;
11
+ export declare function formatRef(ref: SkillRef): string;
package/dist/ref.js ADDED
@@ -0,0 +1,32 @@
1
+ // Port of lib/validation/skills.ts parseSkillRef (DX2 forgiveness). The regex
2
+ // halves come from the GENERATED constants, and test/paths-parity.test.ts pins
3
+ // this port against the app's test vectors — keep the logic line-for-line.
4
+ import { SKILL_HANDLE_RE_SOURCE, SKILL_SLUG_RE_SOURCE } from "./generated/constants.js";
5
+ const SLUG_RE = new RegExp(SKILL_SLUG_RE_SOURCE);
6
+ const HANDLE_RE = new RegExp(SKILL_HANDLE_RE_SOURCE);
7
+ /**
8
+ * Accepts `handle/slug`, `@handle/slug`, and pasted promptdock.ai skill URLs;
9
+ * case-folds both halves (handles are citext). Null when the string can't be a
10
+ * ref (callers render the not-found help).
11
+ */
12
+ export function parseSkillRef(input) {
13
+ let s = input.trim();
14
+ const url = s.match(/^(?:https?:\/\/)?(?:www\.)?promptdock\.ai\/(?:dashboard\/)?skill\/([^/\s?#]+)\/([^/\s?#]+)/i);
15
+ if (url)
16
+ s = `${url[1]}/${url[2]}`;
17
+ if (s.startsWith("@"))
18
+ s = s.slice(1);
19
+ const parts = s.split("/");
20
+ if (parts.length !== 2)
21
+ return null;
22
+ const handle = parts[0].toLowerCase();
23
+ const slug = parts[1].toLowerCase();
24
+ if (!HANDLE_RE.test(handle))
25
+ return null;
26
+ if (!SLUG_RE.test(slug))
27
+ return null;
28
+ return { handle, slug };
29
+ }
30
+ export function formatRef(ref) {
31
+ return `${ref.handle}/${ref.slug}`;
32
+ }
@@ -0,0 +1,43 @@
1
+ export type TargetDef = {
2
+ id: string;
3
+ label: string;
4
+ /** project-local skills base dir (relative to cwd); install dir = base/<slug> */
5
+ localBase: string;
6
+ /** global skills base dir (relative to home) */
7
+ globalBase: string;
8
+ /** dir whose presence means "this tool is set up here" (relative to cwd) */
9
+ detectLocal: string;
10
+ /** dir whose presence means "this tool is set up for this user" (relative to home) */
11
+ detectGlobal: string;
12
+ experimental: boolean;
13
+ };
14
+ export declare const TARGETS: TargetDef[];
15
+ export declare function targetById(id: string): TargetDef | null;
16
+ /** The skills BASE dir for a target in a scope (receipt scans walk base/<slug>). */
17
+ export declare function targetBaseDir(t: TargetDef, opts: {
18
+ cwd: string;
19
+ home: string;
20
+ global: boolean;
21
+ }): string;
22
+ /** The install dir for a slug. */
23
+ export declare function targetInstallDir(t: TargetDef, opts: {
24
+ cwd: string;
25
+ home: string;
26
+ global: boolean;
27
+ slug: string;
28
+ }): string;
29
+ export type DetectedTarget = TargetDef & {
30
+ detected: boolean;
31
+ };
32
+ /**
33
+ * D-UX11 ordering: detected tools float to the top (registry order preserved
34
+ * within each group); the preselect is index 0 (first detected, else Claude).
35
+ */
36
+ export declare function detectTargets(opts: {
37
+ cwd: string;
38
+ home: string;
39
+ global: boolean;
40
+ exists?: (p: string) => boolean;
41
+ }): DetectedTarget[];
42
+ /** DX1/DX F2/F16 — the per-target next step, incl. the RELOAD caveat. */
43
+ export declare function nextStepLine(targetId: string, slug: string): string;
@@ -0,0 +1,111 @@
1
+ // U3/D-UX11 target registry — DATA, not a plugin system. Paths are each tool's
2
+ // convention; every tool except Claude Code ships "(experimental)" (DX5 — the
3
+ // convention is best-effort until verified against that tool's docs). Detection
4
+ // floats detected tools to the top and preselects the first (D-UX11).
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ export const TARGETS = [
8
+ {
9
+ id: "claude",
10
+ label: "Claude Code",
11
+ localBase: ".claude/skills",
12
+ globalBase: ".claude/skills",
13
+ detectLocal: ".claude",
14
+ detectGlobal: ".claude",
15
+ experimental: false,
16
+ },
17
+ {
18
+ id: "codex",
19
+ label: "Codex",
20
+ localBase: ".codex/skills",
21
+ globalBase: ".codex/skills",
22
+ detectLocal: ".codex",
23
+ detectGlobal: ".codex",
24
+ experimental: true,
25
+ },
26
+ {
27
+ id: "gemini",
28
+ label: "Gemini CLI",
29
+ localBase: ".gemini/skills",
30
+ globalBase: ".gemini/skills",
31
+ detectLocal: ".gemini",
32
+ detectGlobal: ".gemini",
33
+ experimental: true,
34
+ },
35
+ {
36
+ id: "cursor",
37
+ label: "Cursor",
38
+ localBase: ".cursor/skills",
39
+ globalBase: ".cursor/skills",
40
+ detectLocal: ".cursor",
41
+ detectGlobal: ".cursor",
42
+ experimental: true,
43
+ },
44
+ {
45
+ id: "copilot",
46
+ label: "GitHub Copilot",
47
+ localBase: ".github/skills",
48
+ globalBase: ".copilot/skills",
49
+ detectLocal: ".github",
50
+ detectGlobal: ".copilot",
51
+ experimental: true,
52
+ },
53
+ {
54
+ id: "kimi",
55
+ label: "Kimi",
56
+ localBase: ".kimi/skills",
57
+ globalBase: ".kimi/skills",
58
+ detectLocal: ".kimi",
59
+ detectGlobal: ".kimi",
60
+ experimental: true,
61
+ },
62
+ {
63
+ id: "openai",
64
+ label: "OpenAI/GPT",
65
+ localBase: ".openai/skills",
66
+ globalBase: ".openai/skills",
67
+ detectLocal: ".openai",
68
+ detectGlobal: ".openai",
69
+ experimental: true,
70
+ },
71
+ ];
72
+ export function targetById(id) {
73
+ return TARGETS.find((t) => t.id === id) ?? null;
74
+ }
75
+ /** The skills BASE dir for a target in a scope (receipt scans walk base/<slug>). */
76
+ export function targetBaseDir(t, opts) {
77
+ return opts.global ? join(opts.home, t.globalBase) : join(opts.cwd, t.localBase);
78
+ }
79
+ /** The install dir for a slug. */
80
+ export function targetInstallDir(t, opts) {
81
+ return join(targetBaseDir(t, opts), opts.slug);
82
+ }
83
+ /**
84
+ * D-UX11 ordering: detected tools float to the top (registry order preserved
85
+ * within each group); the preselect is index 0 (first detected, else Claude).
86
+ */
87
+ export function detectTargets(opts) {
88
+ const exists = opts.exists ?? existsSync;
89
+ const all = TARGETS.map((t) => ({
90
+ ...t,
91
+ detected: exists(opts.global ? join(opts.home, t.detectGlobal) : join(opts.cwd, t.detectLocal)),
92
+ }));
93
+ return [...all.filter((t) => t.detected), ...all.filter((t) => !t.detected)];
94
+ }
95
+ /** DX1/DX F2/F16 — the per-target next step, incl. the RELOAD caveat. */
96
+ export function nextStepLine(targetId, slug) {
97
+ switch (targetId) {
98
+ case "claude":
99
+ return `Next: start a NEW session in Claude Code (open sessions don't see new skills) and type /${slug}`;
100
+ case "codex":
101
+ return `Next: restart Codex so it picks up the new skill, then invoke ${slug}.`;
102
+ case "gemini":
103
+ return `Next: restart Gemini CLI so it picks up the new skill.`;
104
+ case "cursor":
105
+ return `Next: reload Cursor so it picks up the new skill.`;
106
+ case "copilot":
107
+ return `Next: reload your editor so Copilot picks up the new skill.`;
108
+ default:
109
+ return `Next: restart your agent tool so it picks up the new skill.`;
110
+ }
111
+ }