@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2
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/README.md +33 -0
- package/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/edits/worktree.js +229 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/prompts.js +4 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/lsp/client.js +631 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1896 -13
- package/dist/core/repl/slash-commands.js +59 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +767 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/lsp.js +184 -0
- package/dist/runtime/commands/patch.js +111 -0
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/worktree.js +133 -0
- package/dist/tools/apply-patch.js +314 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +185 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +11 -5
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent REPL session store — Sprint α6.4 (PR-PUGI-CLI-SESSION-STORE).
|
|
3
|
+
*
|
|
4
|
+
* Public types consumed by the REPL session module + the `pugi sessions`
|
|
5
|
+
* / `pugi resume` dispatchers. Wire format is stable:
|
|
6
|
+
*
|
|
7
|
+
* - `SessionRow` mirrors the SQLite `sessions` table one-to-one and is
|
|
8
|
+
* also the JSON shape returned by `pugi sessions --json`. Field names
|
|
9
|
+
* are camelCase (PascalCase types, camelCase fields per CLAUDE.md
|
|
10
|
+
* conventions). The SQL columns themselves stay snake_case because
|
|
11
|
+
* they originate from raw SQL.
|
|
12
|
+
*
|
|
13
|
+
* - `SessionEvent` is the on-disk shape of one line in
|
|
14
|
+
* `events.<n>.jsonl`. `kind` is a closed union; `payload` is an
|
|
15
|
+
* opaque JSON value so the producer can attach whatever fields the
|
|
16
|
+
* event type requires without forcing the store to change.
|
|
17
|
+
*
|
|
18
|
+
* - `SessionListOptions` / `SessionLoadEventsOptions` /
|
|
19
|
+
* `SessionSearchOptions` are inputs the store accepts. Defaults are
|
|
20
|
+
* spec'd inline so the test plan can pin them without reading the
|
|
21
|
+
* implementation.
|
|
22
|
+
*
|
|
23
|
+
* The blob store + `pugi undo` + named checkpoints are α6.4b follow-ups
|
|
24
|
+
* — out of scope for THIS PR per spec. The types here intentionally do
|
|
25
|
+
* NOT model blob refs so a future blob-store landing can extend without
|
|
26
|
+
* a wire break.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Concrete shape of the lock detection error so the caller can branch
|
|
30
|
+
* on `error.code === 'EBUSY_SESSION_LOCK'` rather than parsing the
|
|
31
|
+
* message. The store is the only thrower of this error.
|
|
32
|
+
*/
|
|
33
|
+
export class SessionLockBusyError extends Error {
|
|
34
|
+
code = 'EBUSY_SESSION_LOCK';
|
|
35
|
+
holderPid;
|
|
36
|
+
lockPath;
|
|
37
|
+
constructor(holderPid, lockPath) {
|
|
38
|
+
super(`Another pugi process (pid ${holderPid}) holds the session lock at ${lockPath}.`);
|
|
39
|
+
this.name = 'SessionLockBusyError';
|
|
40
|
+
this.holderPid = holderPid;
|
|
41
|
+
this.lockPath = lockPath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UUID v7 generator — Sprint α6.4 (PR-PUGI-CLI-SESSION-STORE).
|
|
3
|
+
*
|
|
4
|
+
* uuid v7 (RFC 9562 draft) is a time-sortable 128-bit identifier whose
|
|
5
|
+
* first 48 bits are the unix-epoch milliseconds, the next 4 bits are
|
|
6
|
+
* the version (0b0111), the next 12 bits are sub-millisecond random
|
|
7
|
+
* entropy, the next 2 bits are the IETF variant (0b10), and the last
|
|
8
|
+
* 62 bits are random.
|
|
9
|
+
*
|
|
10
|
+
* Why v7 and not v4 (random) or v6 (gregorian time):
|
|
11
|
+
*
|
|
12
|
+
* - The session id IS the primary key of the SQLite table. We want
|
|
13
|
+
* inserts to land at the end of the b-tree to keep page fanout
|
|
14
|
+
* small. v4 fragments the tree (uniformly random keys); v7 sorts
|
|
15
|
+
* in time order so inserts are append-only.
|
|
16
|
+
* - `pugi sessions` sorts by `updated_at DESC` for display, but the
|
|
17
|
+
* pagination cursor uses the session id directly — a v7 id IS the
|
|
18
|
+
* creation timestamp, so the cursor is a single column compare
|
|
19
|
+
* instead of (updated_at, id) tuple.
|
|
20
|
+
* - Operators see ids in `/resume` picker; v7 prefix is monotonically
|
|
21
|
+
* increasing so the most recent session lands at the bottom of an
|
|
22
|
+
* id sort, matching the human expectation of "newest last".
|
|
23
|
+
*
|
|
24
|
+
* Node 22 does not ship a v7 generator (`crypto.randomUUID()` is v4
|
|
25
|
+
* only). We implement it inline with `crypto.randomBytes(10)` for the
|
|
26
|
+
* entropy bits — 10 bytes covers the 12-bit random + 62-bit random
|
|
27
|
+
* fields with one syscall.
|
|
28
|
+
*
|
|
29
|
+
* Format: `xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx` where `y` is 8/9/a/b.
|
|
30
|
+
*/
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
32
|
+
/**
|
|
33
|
+
* Mint a fresh uuid v7. The `now` parameter is injectable for tests so
|
|
34
|
+
* the generated id is deterministic when the test fixes the clock.
|
|
35
|
+
* Production callers pass `Date.now`.
|
|
36
|
+
*/
|
|
37
|
+
export function uuidV7(now = Date.now) {
|
|
38
|
+
const ms = Math.floor(now());
|
|
39
|
+
// 48-bit timestamp (6 bytes).
|
|
40
|
+
const tsHex = ms.toString(16).padStart(12, '0');
|
|
41
|
+
// 10 bytes of entropy: 12 random bits go into octet 6 (after the 4
|
|
42
|
+
// version bits), 2 variant bits go into octet 8 (high nibble 8/9/a/b),
|
|
43
|
+
// the remaining 62 bits fill octets 9..15.
|
|
44
|
+
const rand = randomBytes(10);
|
|
45
|
+
// Set version (high nibble of octet 6) to 7.
|
|
46
|
+
rand[0] = ((rand[0] ?? 0) & 0x0f) | 0x70;
|
|
47
|
+
// Set IETF variant (high two bits of octet 8) to 0b10.
|
|
48
|
+
rand[2] = ((rand[2] ?? 0) & 0x3f) | 0x80;
|
|
49
|
+
const hex = Array.from(rand, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
50
|
+
// Assemble: 8-4-4-4-12.
|
|
51
|
+
return (`${tsHex.slice(0, 8)}-${tsHex.slice(8, 12)}-${hex.slice(0, 4)}-`
|
|
52
|
+
+ `${hex.slice(4, 8)}-${hex.slice(8, 20)}`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Extract the unix-ms timestamp from a uuid v7. Returns null when the
|
|
56
|
+
* input is not a v7 id (wrong version nibble, wrong shape). Used by
|
|
57
|
+
* `pugi sessions` to render "created N seconds ago" without reading
|
|
58
|
+
* the SQLite row.
|
|
59
|
+
*/
|
|
60
|
+
export function uuidV7Timestamp(id) {
|
|
61
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
62
|
+
.test(id)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const hex = id.slice(0, 8) + id.slice(9, 13);
|
|
66
|
+
return Number.parseInt(hex, 16);
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=uuid-v7.js.map
|
|
@@ -29,6 +29,16 @@ import { basename, resolve as resolvePath } from 'node:path';
|
|
|
29
29
|
import { slugForCwd } from './history.js';
|
|
30
30
|
/** Cap on the PUGI.md head we forward. Mirrors the admin-api clamp. */
|
|
31
31
|
const PUGI_MD_HEAD_LIMIT = 200;
|
|
32
|
+
/**
|
|
33
|
+
* Workspace label shown when the cwd has no project markers (no .git,
|
|
34
|
+
* no package.json, no PUGI.md). Per CEO 2026-05-25 dogfood, the
|
|
35
|
+
* previous behaviour leaked the parent directory basename (e.g.
|
|
36
|
+
* `codeforge-io`) into the splash as if it were a real workspace,
|
|
37
|
+
* confusing Mira/Pugi about what repo she was looking at. The
|
|
38
|
+
* unbound label is a single explicit string so the splash + status bar
|
|
39
|
+
* read the same warning. (α6.14.2 wave 5.)
|
|
40
|
+
*/
|
|
41
|
+
export const UNBOUND_WORKSPACE_LABEL = '(not bound - run /init OR cd into project)';
|
|
32
42
|
/**
|
|
33
43
|
* Resolve a `ReplWorkspaceContext` from the operator's working directory.
|
|
34
44
|
* Returns a bundle with at least `workspaceCwd` + `workspaceSlug` +
|
|
@@ -38,13 +48,74 @@ const PUGI_MD_HEAD_LIMIT = 200;
|
|
|
38
48
|
export function resolveWorkspaceContext(cwd) {
|
|
39
49
|
const normalised = resolvePath(cwd);
|
|
40
50
|
const slug = slugForCwd(normalised);
|
|
41
|
-
|
|
51
|
+
// α6.14.2 wave 5: when the cwd has no project markers, prefer the
|
|
52
|
+
// explicit "not bound" summary so admin-api's prompt builder knows
|
|
53
|
+
// not to fabricate a workspace context for Mira/Pugi. The cwd +
|
|
54
|
+
// slug still travel so the server can record where the operator
|
|
55
|
+
// launched from for telemetry, but the summary no longer leaks a
|
|
56
|
+
// stray parent-dir basename as if it were a real workspace.
|
|
57
|
+
const summary = isBoundWorkspace(normalised)
|
|
58
|
+
? (readPugiSummary(normalised) ?? basename(normalised) ?? 'workspace')
|
|
59
|
+
: UNBOUND_WORKSPACE_LABEL;
|
|
42
60
|
return {
|
|
43
61
|
workspaceCwd: normalised,
|
|
44
62
|
workspaceSlug: slug,
|
|
45
63
|
workspaceSummary: summary,
|
|
46
64
|
};
|
|
47
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Project-marker probe used by the REPL splash + status bar to decide
|
|
68
|
+
* whether the cwd is a real workspace or a stray parent dir the
|
|
69
|
+
* operator wandered into. The probe is intentionally cheap — three
|
|
70
|
+
* `existsSync` calls — and the order matches the brand convention:
|
|
71
|
+
*
|
|
72
|
+
* 1. `.git` — any clone of a real repo
|
|
73
|
+
* 2. `package.json` — JS/TS workspace root
|
|
74
|
+
* 3. `PUGI.md` — a Pugi-initialised workspace (root or
|
|
75
|
+
* `.pugi/PUGI.md`)
|
|
76
|
+
*
|
|
77
|
+
* Hitting any one of these counts the cwd as "bound" — the operator
|
|
78
|
+
* intentionally landed in a project. Hitting none means they ran
|
|
79
|
+
* `pugi` from `$HOME` or from the parent of a checkout; in that case
|
|
80
|
+
* the splash surfaces an explicit "not bound" label instead of leaking
|
|
81
|
+
* the parent-dir basename as if it were a workspace. The check
|
|
82
|
+
* mirrors the Claude Code "no CLAUDE.md → silent context" rule —
|
|
83
|
+
* never fake-bind. (α6.14.2 wave 5 — CEO dogfood fix.)
|
|
84
|
+
*/
|
|
85
|
+
export function isBoundWorkspace(cwd) {
|
|
86
|
+
const normalised = resolvePath(cwd);
|
|
87
|
+
// Case-sensitive checks; the resolver lower-cases PUGI.md when it
|
|
88
|
+
// reads the head, but the existence probe stays strict so a stray
|
|
89
|
+
// `pugi.md` does not accidentally pass without `pugi init` having
|
|
90
|
+
// run.
|
|
91
|
+
if (existsSync(resolvePath(normalised, '.git')))
|
|
92
|
+
return true;
|
|
93
|
+
if (existsSync(resolvePath(normalised, 'package.json')))
|
|
94
|
+
return true;
|
|
95
|
+
if (existsSync(resolvePath(normalised, 'PUGI.md')))
|
|
96
|
+
return true;
|
|
97
|
+
if (existsSync(resolvePath(normalised, '.pugi', 'PUGI.md')))
|
|
98
|
+
return true;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the workspace label shown on the splash + the REPL header.
|
|
103
|
+
* When the cwd has project markers, returns the directory basename;
|
|
104
|
+
* otherwise returns the explicit "not bound" warning so the operator
|
|
105
|
+
* understands no real workspace was detected. The label is the only
|
|
106
|
+
* string the splash and status bar agree on, so we centralise the
|
|
107
|
+
* decision here instead of re-deriving in two places.
|
|
108
|
+
* (α6.14.2 wave 5.)
|
|
109
|
+
*/
|
|
110
|
+
export function resolveWorkspaceLabel(cwd) {
|
|
111
|
+
if (!isBoundWorkspace(cwd))
|
|
112
|
+
return UNBOUND_WORKSPACE_LABEL;
|
|
113
|
+
const normalised = resolvePath(cwd);
|
|
114
|
+
const segment = basename(normalised);
|
|
115
|
+
if (!segment || segment.length === 0)
|
|
116
|
+
return 'workspace';
|
|
117
|
+
return segment;
|
|
118
|
+
}
|
|
48
119
|
/**
|
|
49
120
|
* Read the first ~200 chars of `.pugi/PUGI.md` if the file exists. The
|
|
50
121
|
* project's own description is the highest-signal one-line summary we
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
const FRONTMATTER_DELIM = '---';
|
|
5
|
+
/**
|
|
6
|
+
* Parse a markdown file with YAML frontmatter into a structured form.
|
|
7
|
+
*
|
|
8
|
+
* Throws when:
|
|
9
|
+
* - no frontmatter delimiter pair is present
|
|
10
|
+
* - frontmatter does not parse as the minimal YAML grammar
|
|
11
|
+
* - required keys `name`, `description`, `metadata.type` are missing
|
|
12
|
+
*
|
|
13
|
+
* Frontmatter `tools` accepts either flow array (`[a, b, c]`) or block
|
|
14
|
+
* array (newline + ` - a` lines). Strings are unquoted, single, or
|
|
15
|
+
* double quoted. Multi-line scalar values are NOT supported (no `>` or
|
|
16
|
+
* `|` folding) — Skills do not need them.
|
|
17
|
+
*/
|
|
18
|
+
export function parseSkillMarkdown(source) {
|
|
19
|
+
const trimmed = source.replace(/^/, '');
|
|
20
|
+
if (!trimmed.startsWith(`${FRONTMATTER_DELIM}\n`) && !trimmed.startsWith(`${FRONTMATTER_DELIM}\r\n`)) {
|
|
21
|
+
throw new Error('SKILL_PARSE: missing opening "---" frontmatter delimiter on line 1');
|
|
22
|
+
}
|
|
23
|
+
const newlineAfterOpen = trimmed.indexOf('\n');
|
|
24
|
+
const rest = trimmed.slice(newlineAfterOpen + 1);
|
|
25
|
+
const closeIdx = findClosingDelimiter(rest);
|
|
26
|
+
if (closeIdx < 0) {
|
|
27
|
+
throw new Error('SKILL_PARSE: missing closing "---" frontmatter delimiter');
|
|
28
|
+
}
|
|
29
|
+
const frontmatterRaw = rest.slice(0, closeIdx);
|
|
30
|
+
const body = rest.slice(closeIdx + FRONTMATTER_DELIM.length).replace(/^\r?\n/, '');
|
|
31
|
+
const parsed = parseFrontmatter(frontmatterRaw);
|
|
32
|
+
const validated = validateFrontmatter(parsed);
|
|
33
|
+
return { frontmatter: validated, body, source };
|
|
34
|
+
}
|
|
35
|
+
function findClosingDelimiter(text) {
|
|
36
|
+
// Walk line-by-line so we never match `---` that appears inside a
|
|
37
|
+
// body separator or table row.
|
|
38
|
+
const lines = text.split('\n');
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (line.trimEnd() === FRONTMATTER_DELIM) {
|
|
42
|
+
return offset;
|
|
43
|
+
}
|
|
44
|
+
offset += line.length + 1; // +1 for the newline we split on
|
|
45
|
+
}
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse the minimal YAML grammar described in the file header into a
|
|
50
|
+
* shallow node tree. Two levels of nesting cover every real skill /
|
|
51
|
+
* agent we have inspected; deeper nesting throws a clear error so the
|
|
52
|
+
* operator knows the parser is the gap.
|
|
53
|
+
*/
|
|
54
|
+
function parseFrontmatter(raw) {
|
|
55
|
+
const lines = raw.split('\n');
|
|
56
|
+
const root = {};
|
|
57
|
+
let i = 0;
|
|
58
|
+
while (i < lines.length) {
|
|
59
|
+
const line = lines[i] ?? '';
|
|
60
|
+
if (line.trim() === '' || line.trim().startsWith('#')) {
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (/^\s/.test(line)) {
|
|
65
|
+
throw new Error(`SKILL_PARSE: unexpected indented line at root scope: "${line}"`);
|
|
66
|
+
}
|
|
67
|
+
const colonIdx = line.indexOf(':');
|
|
68
|
+
if (colonIdx < 0) {
|
|
69
|
+
throw new Error(`SKILL_PARSE: expected "key: value" on line "${line}"`);
|
|
70
|
+
}
|
|
71
|
+
const key = line.slice(0, colonIdx).trim();
|
|
72
|
+
const rest = line.slice(colonIdx + 1).trim();
|
|
73
|
+
if (rest === '') {
|
|
74
|
+
// Two follow-up shapes: block object (indented `key: value`) OR
|
|
75
|
+
// block array (indented `- item`). Decide by inspecting the next
|
|
76
|
+
// non-blank line.
|
|
77
|
+
const peek = peekNextNonBlank(lines, i + 1);
|
|
78
|
+
if (peek === null) {
|
|
79
|
+
root[key] = { kind: 'object', value: {} };
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const peekTrim = peek.line.trimStart();
|
|
84
|
+
if (peekTrim.startsWith('- ')) {
|
|
85
|
+
const { items, consumed } = parseBlockArray(lines, i + 1);
|
|
86
|
+
root[key] = { kind: 'array', value: items };
|
|
87
|
+
i += consumed + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const { obj, consumed } = parseBlockObject(lines, i + 1);
|
|
91
|
+
root[key] = { kind: 'object', value: obj };
|
|
92
|
+
i += consumed + 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
96
|
+
root[key] = { kind: 'array', value: parseFlowArray(rest) };
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
root[key] = { kind: 'scalar', value: stripQuotes(rest) };
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
function peekNextNonBlank(lines, from) {
|
|
106
|
+
for (let i = from; i < lines.length; i++) {
|
|
107
|
+
const line = lines[i] ?? '';
|
|
108
|
+
if (line.trim() === '')
|
|
109
|
+
continue;
|
|
110
|
+
if (line.trim().startsWith('#'))
|
|
111
|
+
continue;
|
|
112
|
+
return { line, index: i };
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
function parseBlockObject(lines, from) {
|
|
117
|
+
const obj = {};
|
|
118
|
+
let i = from;
|
|
119
|
+
let consumed = 0;
|
|
120
|
+
while (i < lines.length) {
|
|
121
|
+
const line = lines[i] ?? '';
|
|
122
|
+
if (line.trim() === '' || line.trim().startsWith('#')) {
|
|
123
|
+
i++;
|
|
124
|
+
consumed++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!/^\s/.test(line)) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const trimmed = line.trimStart();
|
|
131
|
+
const colonIdx = trimmed.indexOf(':');
|
|
132
|
+
if (colonIdx < 0) {
|
|
133
|
+
throw new Error(`SKILL_PARSE: expected "key: value" inside object, got "${line}"`);
|
|
134
|
+
}
|
|
135
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
136
|
+
const rest = trimmed.slice(colonIdx + 1).trim();
|
|
137
|
+
if (rest === '') {
|
|
138
|
+
// Nested object or block array — peek for shape.
|
|
139
|
+
const peek = peekNextNonBlank(lines, i + 1);
|
|
140
|
+
if (peek && peek.line.length > line.length - line.trimStart().length) {
|
|
141
|
+
const peekTrim = peek.line.trimStart();
|
|
142
|
+
if (peekTrim.startsWith('- ')) {
|
|
143
|
+
const arr = parseBlockArray(lines, i + 1);
|
|
144
|
+
obj[key] = { kind: 'array', value: arr.items };
|
|
145
|
+
i += arr.consumed + 1;
|
|
146
|
+
consumed += arr.consumed + 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Deeper objects are out of scope for α7.0 — keep frontmatter
|
|
150
|
+
// shape predictable. Throw with a clear pointer.
|
|
151
|
+
throw new Error(`SKILL_PARSE: nested objects deeper than 1 level are not supported (key "${key}")`);
|
|
152
|
+
}
|
|
153
|
+
obj[key] = { kind: 'object', value: {} };
|
|
154
|
+
i++;
|
|
155
|
+
consumed++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
159
|
+
obj[key] = { kind: 'array', value: parseFlowArray(rest) };
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
obj[key] = { kind: 'scalar', value: stripQuotes(rest) };
|
|
163
|
+
}
|
|
164
|
+
i++;
|
|
165
|
+
consumed++;
|
|
166
|
+
}
|
|
167
|
+
return { obj, consumed };
|
|
168
|
+
}
|
|
169
|
+
function parseBlockArray(lines, from) {
|
|
170
|
+
const items = [];
|
|
171
|
+
let i = from;
|
|
172
|
+
let consumed = 0;
|
|
173
|
+
while (i < lines.length) {
|
|
174
|
+
const line = lines[i] ?? '';
|
|
175
|
+
if (line.trim() === '' || line.trim().startsWith('#')) {
|
|
176
|
+
i++;
|
|
177
|
+
consumed++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!/^\s/.test(line)) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
const trimmed = line.trimStart();
|
|
184
|
+
if (!trimmed.startsWith('- ')) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
items.push(stripQuotes(trimmed.slice(2).trim()));
|
|
188
|
+
i++;
|
|
189
|
+
consumed++;
|
|
190
|
+
}
|
|
191
|
+
return { items, consumed };
|
|
192
|
+
}
|
|
193
|
+
function parseFlowArray(raw) {
|
|
194
|
+
const inner = raw.slice(1, -1).trim();
|
|
195
|
+
if (inner === '')
|
|
196
|
+
return [];
|
|
197
|
+
return inner.split(',').map((piece) => stripQuotes(piece.trim()));
|
|
198
|
+
}
|
|
199
|
+
function stripQuotes(value) {
|
|
200
|
+
if (value.length >= 2) {
|
|
201
|
+
const first = value[0];
|
|
202
|
+
const last = value[value.length - 1];
|
|
203
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
204
|
+
return value.slice(1, -1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
function validateFrontmatter(raw) {
|
|
210
|
+
const name = expectScalar(raw, 'name');
|
|
211
|
+
const description = expectScalar(raw, 'description');
|
|
212
|
+
// Two frontmatter dialects coexist in the wild:
|
|
213
|
+
//
|
|
214
|
+
// 1. Pugi-native — explicit `metadata: { type: skill|agent, ... }`.
|
|
215
|
+
// Future Pugi-published skills + agents use this so the kind is
|
|
216
|
+
// self-declared at parse time.
|
|
217
|
+
//
|
|
218
|
+
// 2. Anthropic Skills — flat top-level keys, no `metadata` block, no
|
|
219
|
+
// `type` field. Every file inside `github.com/anthropics/skills`
|
|
220
|
+
// follows this shape (e.g. `algorithmic-art`, `pdf`, `pptx`).
|
|
221
|
+
//
|
|
222
|
+
// We accept both. When `metadata` is absent we synthesize one with
|
|
223
|
+
// `type` defaulted to `skill` — the on-disk layout (`SKILL.md` in a
|
|
224
|
+
// directory) already disambiguates skill vs agent, and the agent
|
|
225
|
+
// loader explicitly overrides this when the file lives at
|
|
226
|
+
// `~/.pugi/agents/<slug>.md`.
|
|
227
|
+
const metadataNode = raw['metadata'];
|
|
228
|
+
let metadata;
|
|
229
|
+
if (metadataNode && metadataNode.kind === 'object') {
|
|
230
|
+
const metaRaw = metadataNode.value;
|
|
231
|
+
const typeNode = metaRaw['type'];
|
|
232
|
+
let type = 'skill';
|
|
233
|
+
if (typeNode) {
|
|
234
|
+
if (typeNode.kind !== 'scalar') {
|
|
235
|
+
throw new Error('SKILL_PARSE: metadata.type must be a scalar string');
|
|
236
|
+
}
|
|
237
|
+
if (typeNode.value !== 'skill' && typeNode.value !== 'agent') {
|
|
238
|
+
throw new Error(`SKILL_PARSE: metadata.type must be "skill" or "agent" (got "${typeNode.value}")`);
|
|
239
|
+
}
|
|
240
|
+
type = typeNode.value;
|
|
241
|
+
}
|
|
242
|
+
metadata = { type };
|
|
243
|
+
for (const [key, node] of Object.entries(metaRaw)) {
|
|
244
|
+
if (key === 'type')
|
|
245
|
+
continue;
|
|
246
|
+
if (node.kind === 'scalar') {
|
|
247
|
+
metadata[key] = node.value;
|
|
248
|
+
}
|
|
249
|
+
else if (node.kind === 'array') {
|
|
250
|
+
metadata[key] = node.value;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
metadata[key] = flattenObject(node.value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if (metadataNode) {
|
|
258
|
+
throw new Error('SKILL_PARSE: "metadata" must be an object when present');
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
metadata = { type: 'skill' };
|
|
262
|
+
}
|
|
263
|
+
const fm = { name, description, metadata };
|
|
264
|
+
for (const [key, node] of Object.entries(raw)) {
|
|
265
|
+
if (key === 'name' || key === 'description' || key === 'metadata')
|
|
266
|
+
continue;
|
|
267
|
+
if (node.kind === 'scalar') {
|
|
268
|
+
fm[key] = node.value;
|
|
269
|
+
// Anthropic Skills flat dialect — also surface select top-level
|
|
270
|
+
// keys inside metadata so downstream consumers (Mira system
|
|
271
|
+
// prompt builder, `skills list` table) have one consistent path.
|
|
272
|
+
if (key === 'license' || key === 'version' || key === 'model' || key === 'allowed-tools') {
|
|
273
|
+
const metaKey = key === 'allowed-tools' ? 'tools' : key;
|
|
274
|
+
const metaBag = metadata;
|
|
275
|
+
if (metaBag[metaKey] === undefined) {
|
|
276
|
+
if (key === 'allowed-tools') {
|
|
277
|
+
// allowed-tools is conventionally comma-separated in the flat dialect.
|
|
278
|
+
metaBag[metaKey] = node.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
metaBag[metaKey] = node.value;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else if (node.kind === 'array') {
|
|
287
|
+
fm[key] = node.value;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
fm[key] = flattenObject(node.value);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return fm;
|
|
294
|
+
}
|
|
295
|
+
function flattenObject(obj) {
|
|
296
|
+
const out = {};
|
|
297
|
+
for (const [key, node] of Object.entries(obj)) {
|
|
298
|
+
if (node.kind === 'scalar')
|
|
299
|
+
out[key] = node.value;
|
|
300
|
+
else if (node.kind === 'array')
|
|
301
|
+
out[key] = node.value;
|
|
302
|
+
else
|
|
303
|
+
out[key] = flattenObject(node.value);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
function expectScalar(obj, key) {
|
|
308
|
+
const node = obj[key.split('.').pop() ?? key];
|
|
309
|
+
if (!node) {
|
|
310
|
+
throw new Error(`SKILL_PARSE: required key "${key}" is missing`);
|
|
311
|
+
}
|
|
312
|
+
if (node.kind !== 'scalar') {
|
|
313
|
+
throw new Error(`SKILL_PARSE: key "${key}" must be a scalar string`);
|
|
314
|
+
}
|
|
315
|
+
return node.value;
|
|
316
|
+
}
|
|
317
|
+
/* ----------------------------- Slug validation ----------------------------- */
|
|
318
|
+
/**
|
|
319
|
+
* Slug grammar for skill + agent names. Allowed characters:
|
|
320
|
+
* - lowercase ASCII letters, digits, hyphen, underscore, dot
|
|
321
|
+
* - 1–64 characters total, must START with [a-z0-9]
|
|
322
|
+
*
|
|
323
|
+
* Anything outside this grammar is rejected BEFORE the name reaches a
|
|
324
|
+
* `path.join` call. Attacker payloads we close here:
|
|
325
|
+
* - `../../foo` (path traversal segment)
|
|
326
|
+
* - `/etc/passwd` (absolute path)
|
|
327
|
+
* - `..\\..\\foo` (Windows traversal)
|
|
328
|
+
* - `\0name` (null-byte truncation)
|
|
329
|
+
* - `name\\with\\backslash` (Windows separator)
|
|
330
|
+
* - `..` or `.` (dot-only segments)
|
|
331
|
+
* - empty string (matches existing skill on globbed list)
|
|
332
|
+
* - 65+ char strings (DoS via huge directory names)
|
|
333
|
+
* - uppercase / unicode (collision on case-insensitive filesystems)
|
|
334
|
+
*
|
|
335
|
+
* The CLI commands also accept `--as <slug>` so this guard runs on the
|
|
336
|
+
* operator-supplied override AND on the parsed frontmatter `name`. Both
|
|
337
|
+
* paths feed the same `path.join` so both need the same gate.
|
|
338
|
+
*/
|
|
339
|
+
const VALID_SLUG = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
340
|
+
export function assertValidSlug(name, context) {
|
|
341
|
+
if (typeof name !== 'string') {
|
|
342
|
+
throw new Error(`SLUG_INVALID: ${context} name must be a string, got ${typeof name}`);
|
|
343
|
+
}
|
|
344
|
+
// Defense in depth: separators, null bytes, dot-only get rejected
|
|
345
|
+
// ahead of the regex so the error message points at the precise vector
|
|
346
|
+
// rather than the catch-all "must match" form.
|
|
347
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
348
|
+
throw new Error(`SLUG_FORBIDDEN: ${context} name contains a path separator: ${JSON.stringify(name)}`);
|
|
349
|
+
}
|
|
350
|
+
if (name.includes('\0')) {
|
|
351
|
+
throw new Error(`SLUG_FORBIDDEN: ${context} name contains a null byte: ${JSON.stringify(name)}`);
|
|
352
|
+
}
|
|
353
|
+
if (/^\.+$/.test(name)) {
|
|
354
|
+
throw new Error(`SLUG_FORBIDDEN: ${context} name is a dot-only segment: ${JSON.stringify(name)}`);
|
|
355
|
+
}
|
|
356
|
+
if (!VALID_SLUG.test(name)) {
|
|
357
|
+
throw new Error(`SLUG_INVALID: ${context} name ${JSON.stringify(name)} must match ${VALID_SLUG.source} (lowercase letters/digits/._-, 1-64 chars, starting alphanumeric).`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
export function globalSkillsDir() {
|
|
361
|
+
const home = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
|
|
362
|
+
return join(home, 'skills');
|
|
363
|
+
}
|
|
364
|
+
export function workspaceSkillsDir(workspaceRoot) {
|
|
365
|
+
return join(workspaceRoot, '.pugi', 'skills');
|
|
366
|
+
}
|
|
367
|
+
export function globalSkillDir(name) {
|
|
368
|
+
assertValidSlug(name, 'skill');
|
|
369
|
+
return join(globalSkillsDir(), name);
|
|
370
|
+
}
|
|
371
|
+
export function workspaceSkillDir(workspaceRoot, name) {
|
|
372
|
+
assertValidSlug(name, 'skill');
|
|
373
|
+
return join(workspaceSkillsDir(workspaceRoot), name);
|
|
374
|
+
}
|
|
375
|
+
export function listSkills(scope, workspaceRoot) {
|
|
376
|
+
const dir = scope === 'global' ? globalSkillsDir() : workspaceSkillsDir(workspaceRoot);
|
|
377
|
+
if (!existsSync(dir))
|
|
378
|
+
return [];
|
|
379
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
380
|
+
.filter((entry) => entry.isDirectory())
|
|
381
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
382
|
+
.map((entry) => loadSkill(join(dir, entry.name), scope))
|
|
383
|
+
.filter((skill) => skill !== null);
|
|
384
|
+
}
|
|
385
|
+
function loadSkill(skillDir, scope) {
|
|
386
|
+
const skillMd = join(skillDir, 'SKILL.md');
|
|
387
|
+
if (!existsSync(skillMd))
|
|
388
|
+
return null;
|
|
389
|
+
try {
|
|
390
|
+
const source = readFileSync(skillMd, 'utf8');
|
|
391
|
+
const parsed = parseSkillMarkdown(source);
|
|
392
|
+
if (parsed.frontmatter.metadata.type !== 'skill') {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
// Defense in depth: frontmatter.name is operator-controlled (anyone
|
|
396
|
+
// can author a SKILL.md). Reject before it can flow into trust
|
|
397
|
+
// registry keys, log strings, or any path operation downstream.
|
|
398
|
+
assertValidSlug(parsed.frontmatter.name, 'skill');
|
|
399
|
+
return {
|
|
400
|
+
name: parsed.frontmatter.name,
|
|
401
|
+
scope,
|
|
402
|
+
dir: skillDir,
|
|
403
|
+
skillMdPath: skillMd,
|
|
404
|
+
frontmatter: parsed.frontmatter,
|
|
405
|
+
body: parsed.body,
|
|
406
|
+
source,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Copy a fetched skill payload into its install location. Caller is
|
|
415
|
+
* responsible for trust-prompting + hashing before this is invoked.
|
|
416
|
+
*
|
|
417
|
+
* Refuses to install when the payload does not contain a `SKILL.md` at
|
|
418
|
+
* its root: that file is the contract every consumer (Mira system
|
|
419
|
+
* prompt, `skills list`, `skills info`) reads from.
|
|
420
|
+
*/
|
|
421
|
+
export function installSkill(input) {
|
|
422
|
+
// assertValidSlug runs inside globalSkillDir/workspaceSkillDir below,
|
|
423
|
+
// but we call it explicitly here too so the error surfaces before any
|
|
424
|
+
// filesystem state changes (cpSync) — fail-closed ordering.
|
|
425
|
+
assertValidSlug(input.name, 'skill');
|
|
426
|
+
const skillMd = join(input.payloadDir, 'SKILL.md');
|
|
427
|
+
if (!existsSync(skillMd)) {
|
|
428
|
+
throw new Error(`SKILL_INSTALL: payload at ${input.payloadDir} has no SKILL.md at its root`);
|
|
429
|
+
}
|
|
430
|
+
const target = input.scope === 'global'
|
|
431
|
+
? globalSkillDir(input.name)
|
|
432
|
+
: workspaceSkillDir(input.workspaceRoot, input.name);
|
|
433
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
434
|
+
// Idempotent install — remove any prior install so a re-install does
|
|
435
|
+
// not leave orphaned files from the previous payload.
|
|
436
|
+
if (existsSync(target)) {
|
|
437
|
+
rmSync(target, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
// verbatimSymlinks: any symlink that slipped through (e.g. via a
|
|
440
|
+
// legitimately-symlinked local source) is copied as a symlink rather
|
|
441
|
+
// than followed — so a malicious symlink in the payload cannot exfil
|
|
442
|
+
// contents from outside the payload root into our install target.
|
|
443
|
+
cpSync(input.payloadDir, target, { recursive: true, verbatimSymlinks: true });
|
|
444
|
+
return target;
|
|
445
|
+
}
|
|
446
|
+
export function removeSkill(name, scope, workspaceRoot) {
|
|
447
|
+
assertValidSlug(name, 'skill');
|
|
448
|
+
const target = scope === 'global' ? globalSkillDir(name) : workspaceSkillDir(workspaceRoot, name);
|
|
449
|
+
if (!existsSync(target))
|
|
450
|
+
return false;
|
|
451
|
+
rmSync(target, { recursive: true, force: true });
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
//# sourceMappingURL=loader.js.map
|