@agentprojectcontext/apx 1.58.0 → 1.59.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.
- package/package.json +1 -1
- package/src/core/agent/skills/index.js +9 -0
- package/src/core/agent/skills/inspector.js +7 -2
- package/src/core/agent/skills/policy.js +128 -0
- package/src/core/agent/super-agent.js +8 -1
- package/src/core/agent/tools/handlers/list-skills.js +6 -3
- package/src/core/agent/tools/handlers/load-skill.js +9 -2
- package/src/host/daemon/api/skills.js +301 -17
- package/src/interfaces/cli/commands/skills.js +3 -2
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js +731 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
- package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
- package/src/interfaces/web/src/i18n/en.ts +67 -0
- package/src/interfaces/web/src/i18n/es.ts +67 -0
- package/src/interfaces/web/src/lib/api/skills.ts +79 -8
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +6 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
- package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
- package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +0 -1
- package/src/interfaces/web/dist/assets/index-DPAuXATr.js +0 -705
- package/src/interfaces/web/dist/assets/index-DPAuXATr.js.map +0 -1
package/package.json
CHANGED
|
@@ -3,6 +3,15 @@ export { condenseSkillDescription, buildSkillsHintBlock } from "./catalog.js";
|
|
|
3
3
|
export { tryResolveSkillCommand } from "./trigger.js";
|
|
4
4
|
export { suggestSkillForPrompt, clearSkillVectorCache } from "./rag.js";
|
|
5
5
|
export { listSkills, loadSkill, SKILL_LOCATIONS } from "./loader.js";
|
|
6
|
+
export {
|
|
7
|
+
isPrivateSkill,
|
|
8
|
+
isSkillEnabled,
|
|
9
|
+
filterEnabledSkills,
|
|
10
|
+
annotateSkills,
|
|
11
|
+
setSkillEnabled,
|
|
12
|
+
resolveScopeKey,
|
|
13
|
+
DEFAULT_SCOPE,
|
|
14
|
+
} from "./policy.js";
|
|
6
15
|
export {
|
|
7
16
|
inspectPromptForSkills,
|
|
8
17
|
isInspectorEnabled,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { embedOne, cosineSim } from "#core/memory/embeddings.js";
|
|
27
27
|
import { listSkills, loadSkill } from "./loader.js";
|
|
28
|
+
import { filterEnabledSkills, isSkillEnabled } from "./policy.js";
|
|
28
29
|
import { readIndex, backgroundRefreshIfStale } from "./index-store.js";
|
|
29
30
|
|
|
30
31
|
// Defaults — exported so the CLI/web can render them.
|
|
@@ -187,7 +188,8 @@ export async function inspectPromptForSkills({ prompt, projectPath, globalConfig
|
|
|
187
188
|
};
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
const scored = scoreAgainstIndex(probe.vector, items)
|
|
191
|
+
const scored = scoreAgainstIndex(probe.vector, items).filter((s) =>
|
|
192
|
+
isSkillEnabled(s, { config: globalConfig, projectPath }));
|
|
191
193
|
return await pickAndRender({ scored, projectPath, probe, cfg });
|
|
192
194
|
}
|
|
193
195
|
|
|
@@ -196,7 +198,10 @@ export async function inspectPromptForSkills({ prompt, projectPath, globalConfig
|
|
|
196
198
|
// ---------------------------------------------------------------------------
|
|
197
199
|
|
|
198
200
|
async function inspectFromLive({ text, projectPath, cfg, globalConfig, embedOpts }) {
|
|
199
|
-
const skills = listSkills({ projectPath })
|
|
201
|
+
const skills = filterEnabledSkills(listSkills({ projectPath }), {
|
|
202
|
+
config: globalConfig,
|
|
203
|
+
projectPath,
|
|
204
|
+
});
|
|
200
205
|
if (!skills.length) {
|
|
201
206
|
return { contextNote: "", trace: { enabled: true, reason: "no_skills" } };
|
|
202
207
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Skills enable/disable policy — scope-aware gating shared by every consumer.
|
|
2
|
+
//
|
|
3
|
+
// A skill can be turned off per scope. A "scope" is either the super-agent /
|
|
4
|
+
// no-project baseline ("default") or a specific project (keyed by its absolute
|
|
5
|
+
// path). Config lives at:
|
|
6
|
+
//
|
|
7
|
+
// config.skills.policy["default"] = { "<slug>": true|false, ... }
|
|
8
|
+
// config.skills.policy["<projectPath>"] = { "<slug>": true|false, ... }
|
|
9
|
+
//
|
|
10
|
+
// A boolean under a scope is an explicit override; an absent slug inherits.
|
|
11
|
+
// Empty/missing policy = every skill enabled (the pre-feature behavior), so this
|
|
12
|
+
// is fully backward compatible.
|
|
13
|
+
//
|
|
14
|
+
// PRIVATE skills (source "builtin") are APX's own shipped skills. They are
|
|
15
|
+
// always active and can never be disabled or deleted — the UI shows them locked.
|
|
16
|
+
//
|
|
17
|
+
// Effective enabled(skill, projectPath):
|
|
18
|
+
// 1. builtin source → true (private, locked)
|
|
19
|
+
// 2. project scope explicit value → that value
|
|
20
|
+
// 3. "default" scope explicit value→ that value
|
|
21
|
+
// 4. otherwise → true
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SCOPE = "default";
|
|
24
|
+
|
|
25
|
+
// Sources whose skills ship with apx and are always active.
|
|
26
|
+
const PRIVATE_SOURCES = new Set(["builtin"]);
|
|
27
|
+
|
|
28
|
+
/** True for APX's own built-in skills — always active, never disableable. */
|
|
29
|
+
export function isPrivateSkill(skill) {
|
|
30
|
+
return PRIVATE_SOURCES.has(skill?.source);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalize a project path (or nothing) into a policy scope key. */
|
|
34
|
+
export function resolveScopeKey(projectPath) {
|
|
35
|
+
const p = typeof projectPath === "string" ? projectPath.trim() : "";
|
|
36
|
+
return p || DEFAULT_SCOPE;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function policyMap(config) {
|
|
40
|
+
const p = config?.skills?.policy;
|
|
41
|
+
return p && typeof p === "object" ? p : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function scopeOverride(config, scopeKey, slug) {
|
|
45
|
+
const scope = policyMap(config)[scopeKey];
|
|
46
|
+
if (!scope || typeof scope !== "object") return undefined;
|
|
47
|
+
const v = scope[slug];
|
|
48
|
+
return typeof v === "boolean" ? v : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve whether a skill is enabled for the given scope.
|
|
53
|
+
* @param {{slug:string, source?:string}} skill
|
|
54
|
+
* @param {{config?:object, projectPath?:string}} ctx
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function isSkillEnabled(skill, { config, projectPath } = {}) {
|
|
58
|
+
if (isPrivateSkill(skill)) return true;
|
|
59
|
+
const slug = skill?.slug;
|
|
60
|
+
if (!slug) return true;
|
|
61
|
+
|
|
62
|
+
const scopeKey = resolveScopeKey(projectPath);
|
|
63
|
+
if (scopeKey !== DEFAULT_SCOPE) {
|
|
64
|
+
const own = scopeOverride(config, scopeKey, slug);
|
|
65
|
+
if (own !== undefined) return own;
|
|
66
|
+
}
|
|
67
|
+
const base = scopeOverride(config, DEFAULT_SCOPE, slug);
|
|
68
|
+
if (base !== undefined) return base;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Keep only the skills enabled for the given scope. */
|
|
73
|
+
export function filterEnabledSkills(skills, ctx = {}) {
|
|
74
|
+
if (!Array.isArray(skills)) return [];
|
|
75
|
+
return skills.filter((s) => isSkillEnabled(s, ctx));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Annotate skills for a UI client: adds `enabled`, `private`, and `overridden`
|
|
80
|
+
* (whether *this* scope holds an explicit override, ignoring inheritance).
|
|
81
|
+
*/
|
|
82
|
+
export function annotateSkills(skills, { config, projectPath } = {}) {
|
|
83
|
+
if (!Array.isArray(skills)) return [];
|
|
84
|
+
const scopeKey = resolveScopeKey(projectPath);
|
|
85
|
+
return skills.map((s) => {
|
|
86
|
+
const priv = isPrivateSkill(s);
|
|
87
|
+
const own = priv ? undefined : scopeOverride(config, scopeKey, s.slug);
|
|
88
|
+
return {
|
|
89
|
+
...s,
|
|
90
|
+
private: priv,
|
|
91
|
+
enabled: isSkillEnabled(s, { config, projectPath }),
|
|
92
|
+
overridden: own !== undefined,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set (or clear) a skill's enabled override for a scope. Mutates and returns the
|
|
99
|
+
* config object. `enabled === null|undefined` clears the override (back to
|
|
100
|
+
* inherit). Private/builtin skills cannot be overridden.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} config the global config (mutated in place)
|
|
103
|
+
* @param {object} args
|
|
104
|
+
* @param {string} args.slug
|
|
105
|
+
* @param {boolean|null} args.enabled
|
|
106
|
+
* @param {string=} args.scope scope key ("default" or a project path)
|
|
107
|
+
* @param {string=} args.projectPath alternative to scope; normalized to a key
|
|
108
|
+
*/
|
|
109
|
+
export function setSkillEnabled(config, { slug, enabled, scope, projectPath } = {}) {
|
|
110
|
+
if (!slug) throw new Error("setSkillEnabled: slug required");
|
|
111
|
+
const scopeKey = scope ? resolveScopeKey(scope) : resolveScopeKey(projectPath);
|
|
112
|
+
|
|
113
|
+
config.skills = config.skills || {};
|
|
114
|
+
config.skills.policy = config.skills.policy || {};
|
|
115
|
+
const map = config.skills.policy;
|
|
116
|
+
|
|
117
|
+
if (enabled === null || enabled === undefined) {
|
|
118
|
+
if (map[scopeKey]) {
|
|
119
|
+
delete map[scopeKey][slug];
|
|
120
|
+
if (Object.keys(map[scopeKey]).length === 0) delete map[scopeKey];
|
|
121
|
+
}
|
|
122
|
+
return config;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
map[scopeKey] = map[scopeKey] || {};
|
|
126
|
+
map[scopeKey][slug] = !!enabled;
|
|
127
|
+
return config;
|
|
128
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Super-agent: daemon-level action agent for Telegram, TUI, desktop, routines.
|
|
2
2
|
import { createToolSession, buildLazyToolsBlock, makeToolHandlers } from "#core/agent/tools/registry.js";
|
|
3
3
|
import { listSkills } from "#core/agent/skills/loader.js";
|
|
4
|
+
import { filterEnabledSkills } from "#core/agent/skills/policy.js";
|
|
4
5
|
import {
|
|
5
6
|
runAgent,
|
|
6
7
|
buildSuperAgentSystem,
|
|
@@ -97,10 +98,16 @@ export async function runSuperAgent({
|
|
|
97
98
|
// noTools callers (summarize/ask) get no session — text only.
|
|
98
99
|
const toolSession = noTools ? null : createToolSession(channel, { allowedTools });
|
|
99
100
|
|
|
101
|
+
// Scope the catalog hint to the skills enabled for this project (or the
|
|
102
|
+
// super-agent baseline when no project). Built-in/private skills always pass.
|
|
103
|
+
const projectPath = channelMeta?.projectPath;
|
|
104
|
+
const scopedListSkills = (opts = {}) =>
|
|
105
|
+
filterEnabledSkills(listSkills(opts), { config: globalConfig, projectPath });
|
|
106
|
+
|
|
100
107
|
const system = buildSuperAgentSystem({
|
|
101
108
|
globalConfig,
|
|
102
109
|
projects,
|
|
103
|
-
listSkills,
|
|
110
|
+
listSkills: scopedListSkills,
|
|
104
111
|
contextNote,
|
|
105
112
|
channel,
|
|
106
113
|
channelMeta,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { listSkills, SKILL_LOCATIONS } from "#core/agent/skills/loader.js";
|
|
2
|
-
import { condenseSkillDescription } from "#core/agent/skills/index.js";
|
|
2
|
+
import { condenseSkillDescription, filterEnabledSkills } from "#core/agent/skills/index.js";
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
5
|
name: "list_skills",
|
|
@@ -20,8 +20,11 @@ export default {
|
|
|
20
20
|
},
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
|
-
makeHandler: () => ({ project_path } = {}) => {
|
|
24
|
-
const skills =
|
|
23
|
+
makeHandler: (ctx = {}) => ({ project_path } = {}) => {
|
|
24
|
+
const skills = filterEnabledSkills(
|
|
25
|
+
listSkills({ projectPath: project_path }),
|
|
26
|
+
{ config: ctx.globalConfig, projectPath: project_path },
|
|
27
|
+
);
|
|
25
28
|
return {
|
|
26
29
|
ok: true,
|
|
27
30
|
count: skills.length,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadSkill } from "#core/agent/skills/loader.js";
|
|
2
|
+
import { isSkillEnabled } from "#core/agent/skills/policy.js";
|
|
2
3
|
|
|
3
4
|
export default {
|
|
4
5
|
name: "load_skill",
|
|
@@ -24,8 +25,14 @@ export default {
|
|
|
24
25
|
},
|
|
25
26
|
},
|
|
26
27
|
},
|
|
27
|
-
makeHandler: () => ({ slug, project_path } = {}) => {
|
|
28
|
+
makeHandler: (ctx = {}) => ({ slug, project_path } = {}) => {
|
|
28
29
|
if (!slug) throw new Error("load_skill: slug required");
|
|
29
|
-
|
|
30
|
+
const skill = loadSkill(slug, { projectPath: project_path });
|
|
31
|
+
if (!isSkillEnabled(skill, { config: ctx.globalConfig, projectPath: project_path })) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`skill "${slug}" is disabled for this scope. Enable it in Settings → Skills, or pick another.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return skill;
|
|
30
37
|
},
|
|
31
38
|
};
|
|
@@ -1,16 +1,36 @@
|
|
|
1
|
-
// `/skills` listing + Skill Inspector control surface
|
|
1
|
+
// `/skills` listing + enable/disable + Skill Inspector control surface.
|
|
2
2
|
//
|
|
3
|
-
// GET
|
|
4
|
-
// GET
|
|
5
|
-
// PUT
|
|
6
|
-
// POST
|
|
7
|
-
// POST
|
|
3
|
+
// GET /skills catalog annotated with enabled/private per scope
|
|
4
|
+
// GET /skills/:slug/detail full body + frontmatter for the viewer
|
|
5
|
+
// PUT /skills/enabled toggle a skill on/off (or clear) for a scope
|
|
6
|
+
// POST /skills create a user skill (online editor)
|
|
7
|
+
// POST /skills/import/zip import a skill from an uploaded .zip
|
|
8
|
+
// POST /skills/import/repo import a skill by cloning a git repo
|
|
9
|
+
// DELETE /skills/:slug delete a user skill
|
|
10
|
+
// GET /skills/inspector inspector config + index status
|
|
11
|
+
// PUT /skills/inspector toggle / tune inspector config
|
|
12
|
+
// POST /skills/index (re)build the inspector vector index
|
|
13
|
+
// POST /skills/inspect dry-run the inspector for a prompt
|
|
8
14
|
//
|
|
9
|
-
//
|
|
10
|
-
// project
|
|
11
|
-
//
|
|
12
|
-
|
|
15
|
+
// A "scope" is either "default" (the super-agent / no-project baseline) or a
|
|
16
|
+
// project's absolute path. Creating/importing with a project_path targets that
|
|
17
|
+
// project's <project>/.apc/skills/ (source "project"); without it, skills land
|
|
18
|
+
// in ~/.apx/skills/ (source "global"). Built-in skills are private: always
|
|
19
|
+
// active, never disableable or deletable. The inspector routes mirror
|
|
20
|
+
// /embeddings/* so the web admin configures the skill RAG like the memory RAG.
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { listSkills, loadSkill, SKILL_LOCATIONS } from "#core/agent/skills/loader.js";
|
|
13
26
|
import { condenseSkillDescription } from "#core/agent/skills/catalog.js";
|
|
27
|
+
import { apcSkillsDir } from "#core/apc/paths.js";
|
|
28
|
+
import {
|
|
29
|
+
annotateSkills,
|
|
30
|
+
setSkillEnabled,
|
|
31
|
+
resolveScopeKey,
|
|
32
|
+
isPrivateSkill,
|
|
33
|
+
} from "#core/agent/skills/policy.js";
|
|
14
34
|
import {
|
|
15
35
|
inspectPromptForSkills,
|
|
16
36
|
INSPECTOR_DEFAULTS,
|
|
@@ -22,8 +42,63 @@ import {
|
|
|
22
42
|
} from "#core/agent/skills/index-store.js";
|
|
23
43
|
import { readConfig, writeConfig } from "#core/config/index.js";
|
|
24
44
|
|
|
45
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
46
|
+
// Only http(s)/ssh/git git remotes — never a local path or shell metachar.
|
|
47
|
+
const REPO_URL_RE = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)[\w.@:/\-~]+$/;
|
|
48
|
+
|
|
25
49
|
const KNOWN_KEYS = Object.keys(INSPECTOR_DEFAULTS);
|
|
26
50
|
|
|
51
|
+
// Where a newly created/imported skill lands, given a scope. A project_path
|
|
52
|
+
// targets that project's .apc/skills/ (source "project"); otherwise the global
|
|
53
|
+
// ~/.apx/skills/ (source "global").
|
|
54
|
+
function targetSkillsDir(projectPath) {
|
|
55
|
+
return projectPath ? apcSkillsDir(projectPath) : SKILL_LOCATIONS.global;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function skillExists(slug, projectPath) {
|
|
59
|
+
return listSkills({ projectPath }).some((s) => s.slug === slug);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeSkillFile(dir, slug, description, body) {
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
const fmDesc = String(description || "").replace(/\r?\n/g, " ").trim();
|
|
65
|
+
const content =
|
|
66
|
+
`---\nname: ${slug}\ndescription: ${fmDesc}\n---\n\n${String(body || "").trim()}\n`;
|
|
67
|
+
fs.writeFileSync(path.join(dir, "SKILL.md"), content, "utf8");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Find the skill root inside an extracted/cloned tree: the dir that directly
|
|
71
|
+
// contains a SKILL.md (the tree itself, or its single top-level subdir).
|
|
72
|
+
function findSkillRoot(root) {
|
|
73
|
+
if (fs.existsSync(path.join(root, "SKILL.md"))) return root;
|
|
74
|
+
let entries;
|
|
75
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
|
|
76
|
+
const dirs = entries.filter((e) => e.isDirectory() && e.name !== "__MACOSX");
|
|
77
|
+
for (const d of dirs) {
|
|
78
|
+
const sub = path.join(root, d.name);
|
|
79
|
+
if (fs.existsSync(path.join(sub, "SKILL.md"))) return sub;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readSlugFromSkill(dir, fallback) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = fs.readFileSync(path.join(dir, "SKILL.md"), "utf8");
|
|
87
|
+
const m = raw.match(/^---[\s\S]*?\bname\s*:\s*(.+?)\s*$/m);
|
|
88
|
+
if (m && SLUG_RE.test(m[1].trim())) return m[1].trim();
|
|
89
|
+
} catch { /* ignore */ }
|
|
90
|
+
return fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Copy an extracted skill dir into the target skills location under <slug>/.
|
|
94
|
+
function installSkillDir(srcDir, slug, projectPath) {
|
|
95
|
+
const destBase = targetSkillsDir(projectPath);
|
|
96
|
+
const dest = path.join(destBase, slug);
|
|
97
|
+
fs.mkdirSync(destBase, { recursive: true });
|
|
98
|
+
fs.cpSync(srcDir, dest, { recursive: true });
|
|
99
|
+
return dest;
|
|
100
|
+
}
|
|
101
|
+
|
|
27
102
|
function mergedInspectorConfig(cfg) {
|
|
28
103
|
return { ...INSPECTOR_DEFAULTS, ...(cfg?.skills?.inspector || {}) };
|
|
29
104
|
}
|
|
@@ -40,17 +115,31 @@ function indexStatus() {
|
|
|
40
115
|
|
|
41
116
|
export function register(app /*, ctx */) {
|
|
42
117
|
app.get("/skills", (req, res) => {
|
|
43
|
-
const projectPath = typeof req.query?.project_path === "string"
|
|
118
|
+
const projectPath = typeof req.query?.project_path === "string" && req.query.project_path
|
|
44
119
|
? req.query.project_path
|
|
45
120
|
: undefined;
|
|
121
|
+
// A caller may ask about the "default" (super-agent) scope while still
|
|
122
|
+
// wanting project-scoped skills scanned — keep the two concerns separate.
|
|
123
|
+
const scope = typeof req.query?.scope === "string" && req.query.scope
|
|
124
|
+
? req.query.scope
|
|
125
|
+
: undefined;
|
|
46
126
|
try {
|
|
47
|
-
const
|
|
127
|
+
const cfg = readConfig();
|
|
128
|
+
const scopeKey = resolveScopeKey(scope || projectPath);
|
|
129
|
+
const annotated = annotateSkills(listSkills({ projectPath }), {
|
|
130
|
+
config: cfg,
|
|
131
|
+
projectPath: scopeKey === "default" ? undefined : scopeKey,
|
|
132
|
+
});
|
|
48
133
|
res.json({
|
|
49
|
-
count:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
134
|
+
count: annotated.length,
|
|
135
|
+
scope: scopeKey,
|
|
136
|
+
skills: annotated.map((s) => ({
|
|
137
|
+
slug: s.slug,
|
|
138
|
+
source: s.source,
|
|
139
|
+
description: condenseSkillDescription(s.description),
|
|
140
|
+
enabled: s.enabled,
|
|
141
|
+
private: s.private,
|
|
142
|
+
overridden: s.overridden,
|
|
54
143
|
})),
|
|
55
144
|
});
|
|
56
145
|
} catch (e) {
|
|
@@ -58,6 +147,201 @@ export function register(app /*, ctx */) {
|
|
|
58
147
|
}
|
|
59
148
|
});
|
|
60
149
|
|
|
150
|
+
// ---- Full detail (viewer) ----------------------------------------------
|
|
151
|
+
|
|
152
|
+
app.get("/skills/:slug/detail", (req, res) => {
|
|
153
|
+
try {
|
|
154
|
+
const projectPath = typeof req.query?.project_path === "string" && req.query.project_path
|
|
155
|
+
? req.query.project_path
|
|
156
|
+
: undefined;
|
|
157
|
+
const skill = loadSkill(req.params.slug, { projectPath });
|
|
158
|
+
const cfg = readConfig();
|
|
159
|
+
const [annotated] = annotateSkills([skill], { config: cfg, projectPath });
|
|
160
|
+
res.json({
|
|
161
|
+
slug: skill.slug,
|
|
162
|
+
source: skill.source,
|
|
163
|
+
description: skill.description,
|
|
164
|
+
frontmatter: skill.frontmatter,
|
|
165
|
+
body: skill.body,
|
|
166
|
+
file: skill.file,
|
|
167
|
+
enabled: annotated?.enabled ?? true,
|
|
168
|
+
private: annotated?.private ?? false,
|
|
169
|
+
overridden: annotated?.overridden ?? false,
|
|
170
|
+
});
|
|
171
|
+
} catch (e) {
|
|
172
|
+
res.status(404).json({ error: e.message });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---- Enable / disable per scope ----------------------------------------
|
|
177
|
+
|
|
178
|
+
app.put("/skills/enabled", (req, res) => {
|
|
179
|
+
try {
|
|
180
|
+
const { slug, enabled, scope, project_path } = req.body || {};
|
|
181
|
+
if (!slug || typeof slug !== "string") {
|
|
182
|
+
return res.status(400).json({ error: "slug required" });
|
|
183
|
+
}
|
|
184
|
+
const cfg = readConfig();
|
|
185
|
+
const all = listSkills({ projectPath: project_path });
|
|
186
|
+
const target = all.find((s) => s.slug === slug);
|
|
187
|
+
if (!target) return res.status(404).json({ error: `skill "${slug}" not found` });
|
|
188
|
+
if (isPrivateSkill(target)) {
|
|
189
|
+
return res.status(403).json({ error: `skill "${slug}" is private (built-in) and always active` });
|
|
190
|
+
}
|
|
191
|
+
// enabled: boolean sets an override; null/undefined clears it (inherit).
|
|
192
|
+
const value = enabled === null || enabled === undefined ? null : !!enabled;
|
|
193
|
+
setSkillEnabled(cfg, { slug, enabled: value, scope, projectPath: project_path });
|
|
194
|
+
writeConfig(cfg);
|
|
195
|
+
const scopeKey = resolveScopeKey(scope || project_path);
|
|
196
|
+
res.json({ ok: true, slug, scope: scopeKey, enabled: value });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
res.status(500).json({ error: e.message });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---- Create / import user skills ---------------------------------------
|
|
203
|
+
|
|
204
|
+
// Online editor: write a SKILL.md from slug + description + body.
|
|
205
|
+
app.post("/skills", (req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
const { slug, description, body, project_path } = req.body || {};
|
|
208
|
+
if (!slug || typeof slug !== "string" || !SLUG_RE.test(slug)) {
|
|
209
|
+
return res.status(400).json({ error: "slug required (lowercase letters, digits, dashes)" });
|
|
210
|
+
}
|
|
211
|
+
if (skillExists(slug, project_path)) {
|
|
212
|
+
return res.status(409).json({ error: `a skill named "${slug}" already exists in this scope` });
|
|
213
|
+
}
|
|
214
|
+
const dir = path.join(targetSkillsDir(project_path), slug);
|
|
215
|
+
writeSkillFile(dir, slug, description, body);
|
|
216
|
+
res.status(201).json({ ok: true, slug, source: project_path ? "project" : "global" });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
res.status(500).json({ error: e.message });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Import from an uploaded .zip (sent as base64 in JSON — skills are tiny).
|
|
223
|
+
app.post("/skills/import/zip", (req, res) => {
|
|
224
|
+
let tmp;
|
|
225
|
+
try {
|
|
226
|
+
const { data, project_path } = req.body || {};
|
|
227
|
+
if (!data || typeof data !== "string") {
|
|
228
|
+
return res.status(400).json({ error: "zip data (base64) required" });
|
|
229
|
+
}
|
|
230
|
+
const buf = Buffer.from(data.replace(/^data:.*;base64,/, ""), "base64");
|
|
231
|
+
if (!buf.length) return res.status(400).json({ error: "empty zip" });
|
|
232
|
+
|
|
233
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "apx-skill-zip-"));
|
|
234
|
+
const zipPath = path.join(tmp, "skill.zip");
|
|
235
|
+
fs.writeFileSync(zipPath, buf);
|
|
236
|
+
const out = path.join(tmp, "out");
|
|
237
|
+
fs.mkdirSync(out);
|
|
238
|
+
const unzip = spawnSync("unzip", ["-qq", "-o", zipPath, "-d", out], { encoding: "utf8" });
|
|
239
|
+
if (unzip.status !== 0) {
|
|
240
|
+
return res.status(400).json({ error: `unzip failed: ${(unzip.stderr || "bad archive").trim()}` });
|
|
241
|
+
}
|
|
242
|
+
const root = findSkillRoot(out);
|
|
243
|
+
if (!root) return res.status(400).json({ error: "no SKILL.md found in the zip" });
|
|
244
|
+
const slug = readSlugFromSkill(root, path.basename(root));
|
|
245
|
+
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: `invalid skill name "${slug}"` });
|
|
246
|
+
if (skillExists(slug, project_path)) {
|
|
247
|
+
return res.status(409).json({ error: `a skill named "${slug}" already exists in this scope` });
|
|
248
|
+
}
|
|
249
|
+
installSkillDir(root, slug, project_path);
|
|
250
|
+
res.status(201).json({ ok: true, slug, source: project_path ? "project" : "global" });
|
|
251
|
+
} catch (e) {
|
|
252
|
+
res.status(500).json({ error: e.message });
|
|
253
|
+
} finally {
|
|
254
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Import by cloning a git repo. The repo root (or its single skill subdir)
|
|
259
|
+
// must contain a SKILL.md.
|
|
260
|
+
app.post("/skills/import/repo", (req, res) => {
|
|
261
|
+
let tmp;
|
|
262
|
+
try {
|
|
263
|
+
const { url, project_path } = req.body || {};
|
|
264
|
+
if (!url || typeof url !== "string" || !REPO_URL_RE.test(url.trim())) {
|
|
265
|
+
return res.status(400).json({ error: "a valid git URL (https/ssh/git) is required" });
|
|
266
|
+
}
|
|
267
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "apx-skill-repo-"));
|
|
268
|
+
const clone = path.join(tmp, "repo");
|
|
269
|
+
// Array args (no shell) — url is regex-validated above.
|
|
270
|
+
const git = spawnSync("git", ["clone", "--depth", "1", url.trim(), clone], {
|
|
271
|
+
encoding: "utf8",
|
|
272
|
+
timeout: 60_000,
|
|
273
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
274
|
+
});
|
|
275
|
+
if (git.status !== 0) {
|
|
276
|
+
return res.status(400).json({ error: `git clone failed: ${(git.stderr || "").trim().slice(0, 300)}` });
|
|
277
|
+
}
|
|
278
|
+
const root = findSkillRoot(clone);
|
|
279
|
+
if (!root) return res.status(400).json({ error: "no SKILL.md found in the repo" });
|
|
280
|
+
const slug = readSlugFromSkill(root, path.basename(root));
|
|
281
|
+
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: `invalid skill name "${slug}"` });
|
|
282
|
+
if (skillExists(slug, project_path)) {
|
|
283
|
+
return res.status(409).json({ error: `a skill named "${slug}" already exists in this scope` });
|
|
284
|
+
}
|
|
285
|
+
// Strip the repo's .git before installing so we don't nest a git dir.
|
|
286
|
+
fs.rmSync(path.join(root, ".git"), { recursive: true, force: true });
|
|
287
|
+
installSkillDir(root, slug, project_path);
|
|
288
|
+
res.status(201).json({ ok: true, slug, source: project_path ? "project" : "global" });
|
|
289
|
+
} catch (e) {
|
|
290
|
+
res.status(500).json({ error: e.message });
|
|
291
|
+
} finally {
|
|
292
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
app.delete("/skills/:slug", (req, res) => {
|
|
297
|
+
try {
|
|
298
|
+
const slug = req.params.slug;
|
|
299
|
+
if (!slug || !SLUG_RE.test(slug)) {
|
|
300
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
301
|
+
}
|
|
302
|
+
const projectPath = typeof req.query?.project_path === "string" && req.query.project_path
|
|
303
|
+
? req.query.project_path
|
|
304
|
+
: undefined;
|
|
305
|
+
let entry;
|
|
306
|
+
try { entry = loadSkill(slug, { projectPath }); } catch { entry = null; }
|
|
307
|
+
if (!entry) return res.status(404).json({ error: `skill "${slug}" not found` });
|
|
308
|
+
// Only user-managed skills (global ~/.apx/skills or project .apc/skills)
|
|
309
|
+
// can be deleted; built-in ones ship with apx.
|
|
310
|
+
if (entry.source !== "global" && entry.source !== "project") {
|
|
311
|
+
return res.status(403).json({ error: `built-in skill "${slug}" cannot be deleted (it ships with apx)` });
|
|
312
|
+
}
|
|
313
|
+
// The skill dir is the parent of its SKILL.md (dir-style) — never the
|
|
314
|
+
// shared skills root itself.
|
|
315
|
+
const dir = path.dirname(entry.file);
|
|
316
|
+
const roots = [SKILL_LOCATIONS.global, projectPath ? apcSkillsDir(projectPath) : null]
|
|
317
|
+
.filter(Boolean)
|
|
318
|
+
.map((p) => path.resolve(p));
|
|
319
|
+
if (roots.includes(path.resolve(dir))) {
|
|
320
|
+
// Flat-style <slug>.md — remove just the file.
|
|
321
|
+
fs.rmSync(entry.file, { force: true });
|
|
322
|
+
} else {
|
|
323
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
// Drop any dangling enable/disable overrides for this slug.
|
|
326
|
+
const cfg = readConfig();
|
|
327
|
+
const pol = cfg?.skills?.policy;
|
|
328
|
+
if (pol && typeof pol === "object") {
|
|
329
|
+
let touched = false;
|
|
330
|
+
for (const scopeKey of Object.keys(pol)) {
|
|
331
|
+
if (pol[scopeKey] && slug in pol[scopeKey]) {
|
|
332
|
+
delete pol[scopeKey][slug];
|
|
333
|
+
if (Object.keys(pol[scopeKey]).length === 0) delete pol[scopeKey];
|
|
334
|
+
touched = true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (touched) writeConfig(cfg);
|
|
338
|
+
}
|
|
339
|
+
res.json({ ok: true, slug });
|
|
340
|
+
} catch (e) {
|
|
341
|
+
res.status(500).json({ error: e.message });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
61
345
|
// ---- Inspector config + status -----------------------------------------
|
|
62
346
|
|
|
63
347
|
app.get("/skills/inspector", (_req, res) => {
|
|
@@ -215,10 +215,11 @@ export async function cmdSkillsList(args = {}) {
|
|
|
215
215
|
console.log("(no skills available)");
|
|
216
216
|
return;
|
|
217
217
|
}
|
|
218
|
-
console.log(`SLUG`.padEnd(28) + "SOURCE".padEnd(10) + "DESCRIPTION");
|
|
218
|
+
console.log(`SLUG`.padEnd(28) + "SOURCE".padEnd(10) + "STATE".padEnd(10) + "DESCRIPTION");
|
|
219
219
|
for (const s of out.skills) {
|
|
220
220
|
const desc = (s.description || "").slice(0, 70);
|
|
221
|
-
|
|
221
|
+
const state = s.private ? "private" : s.enabled === false ? "off" : "on";
|
|
222
|
+
console.log(s.slug.padEnd(28) + (s.source || "?").padEnd(10) + state.padEnd(10) + desc);
|
|
222
223
|
}
|
|
223
224
|
return;
|
|
224
225
|
}
|