@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.58.0",
3
+ "version": "1.59.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 = listSkills({ projectPath: project_path });
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
- return loadSkill(slug, { projectPath: project_path });
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 for UI clients.
1
+ // `/skills` listing + enable/disable + Skill Inspector control surface.
2
2
  //
3
- // GET /skills catalog (slug + condensed description)
4
- // GET /skills/inspector inspector config + index status
5
- // PUT /skills/inspector toggle / tune inspector config
6
- // POST /skills/index (re)build the inspector vector index
7
- // POST /skills/inspect dry-run the inspector for a prompt
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
- // The listing is the same data backing `list_skills` (no auth-binding to a
10
- // project). The inspector routes mirror /embeddings/* so the web admin can
11
- // configure the skill RAG exactly like it configures the memory RAG.
12
- import { listSkills } from "#core/agent/skills/loader.js";
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 skills = listSkills({ projectPath });
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: skills.length,
50
- skills: skills.map(({ slug, source, description }) => ({
51
- slug,
52
- source,
53
- description: condenseSkillDescription(description),
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
- console.log(s.slug.padEnd(28) + (s.source || "?").padEnd(10) + desc);
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
  }