dsh-agent-plugins-market 0.7.2 → 0.8.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.
Files changed (36) hide show
  1. package/README.md +11 -3
  2. package/README.zh.md +11 -3
  3. package/client/client.js +47 -27
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +9 -2
  6. package/lib/catalog/component-files.js +3 -1
  7. package/lib/catalog/native-project.js +1 -1
  8. package/lib/catalog/project-config.js +0 -2
  9. package/lib/catalog/project-hooks.js +12 -7
  10. package/lib/catalog/surfaces.js +17 -10
  11. package/lib/model/command-names.js +14 -0
  12. package/lib/model/layouts.js +1 -0
  13. package/lib/runtime/commands-mounts.js +3 -2
  14. package/lib/runtime/hooks-mounts.js +7 -4
  15. package/lib/runtime/mcp-direct-config.js +18 -9
  16. package/lib/runtime/plugin-message-source.js +4 -0
  17. package/lib/runtime/project-runtime.js +10 -8
  18. package/lib/runtime/user-commands.js +23 -8
  19. package/lib/runtime/user-hooks.js +102 -0
  20. package/lib/runtime/user-panels.js +34 -22
  21. package/lib/runtime/user-store.js +81 -19
  22. package/lib/types/catalog/component-files.d.ts +1 -0
  23. package/lib/types/catalog/project-hooks.d.ts +15 -2
  24. package/lib/types/catalog/surfaces.d.ts +6 -4
  25. package/lib/types/contracts/market.d.ts +5 -1
  26. package/lib/types/model/command-names.d.ts +12 -0
  27. package/lib/types/model/layouts.d.ts +1 -0
  28. package/lib/types/model/types.d.ts +2 -1
  29. package/lib/types/runtime/mcp-direct-config.d.ts +1 -5
  30. package/lib/types/runtime/plugin-message-source.d.ts +24 -0
  31. package/lib/types/runtime/user-commands.d.ts +5 -2
  32. package/lib/types/runtime/user-hooks.d.ts +22 -0
  33. package/lib/types/runtime/user-panels.d.ts +35 -15
  34. package/lib/types/runtime/user-store.d.ts +27 -10
  35. package/package.json +32 -28
  36. package/schemas/com.deepseek.harness/spec.md +1 -1
@@ -12,6 +12,7 @@
12
12
  import { qualifiedSuiteId } from '../catalog/paths.js';
13
13
  import { loadUserMcpSuite } from '../runtime/mcp-direct-config.js';
14
14
  import { loadSuiteOverrides } from '../runtime/mcp-overrides.js';
15
+ import { loadUserHooksSuite } from '../runtime/user-hooks.js';
15
16
  import { applyLspOverrides } from '../runtime/server-config.js';
16
17
  import { buildSuiteDetail, readSkillContent } from './details.js';
17
18
  import { CatalogContext } from './catalog-context.js';
@@ -103,8 +104,14 @@ export class Catalog {
103
104
  .map(([key]) => key.slice(0, key.indexOf('/'))));
104
105
  const sources = state.sources.filter(source => enabledSources.has(source.id));
105
106
  const suites = sources.length === 0 ? [] : (await this.snapshots.build({ ...state, sources }, 'user', this.context.userRoot)).enabledSuites;
106
- const direct = await loadUserMcpSuite(this.context.agentsRoot);
107
- return applyLspOverrides(this.context.dataRoot, Object.keys(direct.mcp.servers).length === 0 ? suites : [...suites, direct]);
107
+ // The user's own Agent layout declarations ride the same mount registries
108
+ // as an installed suite: each direct suite joins the list only when it
109
+ // declares the content its surface count reports, so an absent or empty
110
+ // file costs no mount pass.
111
+ const mcp = await loadUserMcpSuite(this.context.agentsRoot);
112
+ const hooks = await loadUserHooksSuite(this.context.agentsRoot);
113
+ const direct = [...(Object.keys(mcp.mcp.servers).length === 0 ? [] : [mcp]), ...(hooks.surfaces.hooks === 0 ? [] : [hooks])];
114
+ return applyLspOverrides(this.context.dataRoot, direct.length === 0 ? suites : [...suites, ...direct]);
108
115
  }
109
116
  /** The full market overview from one user snapshot. */
110
117
  async overview() {
@@ -1,6 +1,7 @@
1
1
  /** Resolve declared component files once; all consumers use the same contained resource paths. */
2
2
  import { readFile, readdir, realpath, stat } from 'node:fs/promises';
3
3
  import { extname, join, relative, resolve } from 'node:path';
4
+ import { commandCallName } from '../model/command-names.js';
4
5
  import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
5
6
  import { isFile } from './fs-probes.js';
6
7
  import { isWithin } from './paths.js';
@@ -154,6 +155,7 @@ export async function defaultMarkdownResources(root, component) {
154
155
  export async function resourceText(resource) {
155
156
  return resource.content ?? (await readFile(resource.file, 'utf8'));
156
157
  }
158
+ /** Registration name of a command resource; one rule governs suite, project-native, and user-panel commands. */
157
159
  export function resourceCommandName(name) {
158
- return name.replaceAll('/', '-').replaceAll('\\', '-').toLowerCase();
160
+ return commandCallName(name);
159
161
  }
@@ -47,7 +47,7 @@ export async function discoverNativeProjectSuites(projectRoot, dimension) {
47
47
  lspFiles.push('lsp.json');
48
48
  for (const path of lspFiles) {
49
49
  if (await isFile(join(projectRoot, path))) {
50
- errors.push(`${path}: project LSP configuration is not mounted; the host LSP registry does not isolate projects`);
50
+ errors.push(`${path.replace(/\\/g, '/')}: project LSP configuration is not mounted; the host LSP registry does not isolate projects`);
51
51
  }
52
52
  }
53
53
  // Skills live in `<dir>/skills/<name>/SKILL.md` — the same shape as the
@@ -47,8 +47,6 @@ export async function discoverProjectMcp(projectRoot, files, errors, format = 'm
47
47
  found = true;
48
48
  Object.assign(servers, table);
49
49
  }
50
- if (format === 'zcode' && Object.keys(servers).length === 0)
51
- return discoverProjectMcp(projectRoot, ['.agents/mcp.json'], errors);
52
50
  if (!found)
53
51
  return undefined;
54
52
  for (const [key, server] of Object.entries(servers)) {
@@ -16,9 +16,10 @@ function declarationText(value) {
16
16
  * a standalone hook file may carry the bare event table instead of a `hooks`
17
17
  * key. A recognized top-level event name is the marker — a document with no
18
18
  * event name at the top stays verbatim, so an ordinary settings file missing
19
- * `hooks` is still not accepted as a bare event table.
19
+ * `hooks` is still not accepted as a bare event table. Exported because the
20
+ * user Agent layout root reads the same standalone file shapes.
20
21
  */
21
- function settingsDocument(settings, format) {
22
+ export function standaloneHookDocument(settings, format = 'claude') {
22
23
  if (format !== 'claude' || settings.hooks !== undefined)
23
24
  return settings;
24
25
  const isEventTable = Object.keys(settings).some(key => EVENTS.has(key));
@@ -32,11 +33,15 @@ export async function discoverProjectHooks(projectRoot, files, errors, format =
32
33
  if (settings === null)
33
34
  return undefined;
34
35
  if (settings !== undefined)
35
- documents.push({ file, settings: settingsDocument(settings, format) });
36
+ documents.push({ file, settings: standaloneHookDocument(settings, format) });
36
37
  }
37
38
  return normalizeHookDocuments(projectRoot, documents, errors, format);
38
39
  }
39
- /** Pure validation shared by native settings and plugin file/inline declarations. */
40
+ /**
41
+ * Pure validation shared by native settings and plugin file/inline
42
+ * declarations. `projectRoot` is absent for hooks declared in the user Agent
43
+ * layout root, which belong to no project.
44
+ */
40
45
  export function normalizeHookDocuments(projectRoot, documents, errors, format = 'claude') {
41
46
  const events = Object.create(null);
42
47
  const seen = new Set();
@@ -64,7 +69,7 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
64
69
  }
65
70
  for (const [event, groups] of Object.entries(eventTable)) {
66
71
  if (!EVENTS.has(event)) {
67
- errors.push(`${file}: unsupported project hook event ${event}`);
72
+ errors.push(`${file}: unsupported hook event ${event}`);
68
73
  continue;
69
74
  }
70
75
  if (!Array.isArray(groups)) {
@@ -105,7 +110,7 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
105
110
  return undefined;
106
111
  }
107
112
  if (hook.async === true || hook.asyncRewake === true) {
108
- errors.push(`${file}: asynchronous project hooks are unsupported`);
113
+ errors.push(`${file}: asynchronous hooks are unsupported`);
109
114
  continue;
110
115
  }
111
116
  const key = JSON.stringify([event, matcher, hook.command]);
@@ -125,5 +130,5 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
125
130
  }
126
131
  }
127
132
  }
128
- return !disabled && Object.keys(events).length > 0 ? { projectRoot, events } : undefined;
133
+ return !disabled && Object.keys(events).length > 0 ? { ...(projectRoot === undefined ? {} : { projectRoot }), events } : undefined;
129
134
  }
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Skills honor any path the winning manifest declares (`skills` may be a
6
6
  * string, an array, or absent), scan up to three levels of nesting, and
7
- * dedupe by frontmatter name. MCP reads both `mcp.json` and the dot-prefixed
7
+ * dedupe by frontmatter name. A path carrying its own `SKILL.md` is one skill
8
+ * and nothing else inside it is read. MCP reads both `mcp.json` and the dot-prefixed
8
9
  * `.mcp.json`, tolerating unknown transports per server. Every read is
9
10
  * fail-closed: broken files produce a diagnostic and are skipped, never a
10
11
  * thrown discovery.
@@ -40,10 +41,12 @@ async function declaredSkillDirs(root, declared, errors) {
40
41
  }
41
42
  /**
42
43
  * Discover SKILL.md files under the suite's skills directory, up to 3 levels
43
- * deep. Portable v1 suites (§7.1) instead discover exactly one level of
44
- * `skills/` subdirectories each carrying a `SKILL.md` — the spec forbids
45
- * recursive deeper search and root-level or flat skill files are outside the
46
- * portable discovery shape.
44
+ * deep. A path that carries `SKILL.md` is one skill directory — a manifest may
45
+ * list those individually — and only that document is read from it. Portable
46
+ * v1 suites (§7.1) instead discover exactly one level of `skills/`
47
+ * subdirectories each carrying a `SKILL.md` — the spec forbids recursive
48
+ * deeper search and root-level or flat skill files are outside the portable
49
+ * discovery shape.
47
50
  */
48
51
  export async function discoverSkills(root, errors, declared, portable = false) {
49
52
  const skills = [];
@@ -96,16 +99,20 @@ export async function discoverSkills(root, errors, declared, portable = false) {
96
99
  pushUnique(await parseOneSkill(skillsDir, dirname(skillsDir), '', errors));
97
100
  continue;
98
101
  }
99
- for (const name of await listMdFiles(skillsDir)) {
100
- pushUnique(await parseOneSkill(join(skillsDir, name), skillsDir, name.replace(/\.md$/, ''), errors));
101
- }
102
- // A declared path may be one skill directory (a manifest listing
103
- // individual skills, e.g. mattpocock) or a container of skills.
102
+ // A directory carrying its own `SKILL.md` is one skill, whether a manifest
103
+ // declared it individually (mattpocock lists every skill directory) or the
104
+ // conventional container happens to be one. Everything beside that
105
+ // `SKILL.md` — `references/`, `scripts/`, the notes and formats a skill
106
+ // attaches — belongs to it, so those documents are neither parsed as flat
107
+ // skills nor reported as malformed ones.
104
108
  if (await isFile(join(skillsDir, 'SKILL.md'))) {
105
109
  const name = skillsDir.split(/[\\/]/).at(-1) ?? '';
106
110
  pushUnique(await parseOneSkill(join(skillsDir, 'SKILL.md'), skillsDir, name, errors));
107
111
  continue;
108
112
  }
113
+ for (const name of await listMdFiles(skillsDir)) {
114
+ pushUnique(await parseOneSkill(join(skillsDir, name), skillsDir, name.replace(/\.md$/, ''), errors));
115
+ }
109
116
  for (const child of await listChildDirs(skillsDir)) {
110
117
  const name = child.split(/[\\/]/).at(-1) ?? '';
111
118
  pushUnique(await parseOneSkill(join(child, 'SKILL.md'), child, name, errors));
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The call name a command resource registers under.
3
+ *
4
+ * A command resource may live at any depth under its `commands/` directory,
5
+ * but the host's slash-command grammar accepts single-segment names only, so
6
+ * registration flattens the path separators: `git/commit` registers as
7
+ * `git-commit`. The user panel renders the same call name, so a card never
8
+ * advertises a slash name the host cannot accept.
9
+ * @module model/command-names
10
+ */
11
+ /** Flatten a resource path into the single-segment name a slash command registers under. */
12
+ export function commandCallName(name) {
13
+ return name.replaceAll('/', '-').replaceAll('\\', '-').toLowerCase();
14
+ }
@@ -47,6 +47,7 @@ export const PROJECT_LAYOUTS = [
47
47
  dirName: '.agents',
48
48
  label: 'agents',
49
49
  subdirs: ['skills', 'agents', 'commands'],
50
+ mcpFiles: ['.agents/mcp.json'],
50
51
  hookFiles: ['.agents/hooks/hooks.json', '.agents/hooks.json']
51
52
  },
52
53
  { dirName: '.codex', label: 'Codex', subdirs: ['skills'], mcpFiles: ['.codex/config.toml'], mcpFormat: 'codex' },
@@ -7,6 +7,7 @@ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js'
7
7
  import { defaultMarkdownResources, resourceText, resourceCommandName } from '../catalog/component-files.js';
8
8
  import { bindHostLocale } from './host-locale.js';
9
9
  import { injectDynamicContext, shellSeamOf } from './dynamic-context.js';
10
+ import { pluginMarketSource } from './plugin-message-source.js';
10
11
  const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
11
12
  export class CommandMountRegistry {
12
13
  ctx;
@@ -89,7 +90,7 @@ export class CommandMountRegistry {
89
90
  if (shell === undefined) {
90
91
  agent.followup(createUserMessage({
91
92
  content: [{ type: 'text', text: body }],
92
- source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
93
+ source: pluginMarketSource()
93
94
  }));
94
95
  return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
95
96
  }
@@ -102,7 +103,7 @@ export class CommandMountRegistry {
102
103
  }
103
104
  agent.followup(createUserMessage({
104
105
  content: [{ type: 'text', text }],
105
- source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
106
+ source: pluginMarketSource()
106
107
  }));
107
108
  return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
108
109
  }
@@ -6,9 +6,12 @@
6
6
  *
7
7
  * The bridge is a cordis function plugin (`inject: ['shell']`) whose config
8
8
  * is a single `configPath` plus `pluginRoot` for `${CLAUDE_PLUGIN_ROOT}`
9
- * substitution — exactly the shape a suite hook file needs. Mounts reconcile
10
- * on every enable/disable/install/uninstall; a missing bridge package, a
11
- * broken hook file, or a mount failure is contained per suite.
9
+ * substitution — exactly the shape a suite hook file needs. `projectDir` is
10
+ * forwarded only for hooks declared by a project layout; user Agent layout
11
+ * hooks carry none, so the bridge defaults `${CLAUDE_PROJECT_DIR}` to the
12
+ * calling session's workspace. Mounts reconcile on every
13
+ * enable/disable/install/uninstall; a missing bridge package, a broken hook
14
+ * file, or a mount failure is contained per suite.
12
15
  */
13
16
  import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
14
17
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
@@ -96,7 +99,7 @@ export class HooksMountRegistry {
96
99
  handle = mountCtx.plugin(bridge, {
97
100
  configPath,
98
101
  pluginRoot: suite.root,
99
- ...(suite.hooks === undefined ? {} : { projectDir: suite.hooks.projectRoot })
102
+ ...(suite.hooks?.projectRoot === undefined ? {} : { projectDir: suite.hooks.projectRoot })
100
103
  });
101
104
  await handle.await();
102
105
  this.live.set(key, handle);
@@ -6,11 +6,7 @@ import { effectiveSurfaces } from '../model/types.js';
6
6
  export const USER_MCP_SOURCE = '@user-mcp';
7
7
  export const USER_MCP_SUITE = 'user-mcp';
8
8
  /**
9
- * User-created services reuse the owned bridge lifecycle without changing host configuration.
10
- *
11
- * The declarations live in the shared Agent layout root
12
- * (`~/.agents/mcp.json`), so a service the user adds by hand is ordinary
13
- * `mcpServers` JSON in the same place other Agent tools look.
9
+ * Load the user's own MCP declaration file.
14
10
  *
15
11
  * The suite always carries an `mcp` document — an absent or malformed
16
12
  * `mcp.json` leaves an empty one — so callers read `.mcp.servers` directly
@@ -37,9 +33,10 @@ export async function loadUserMcpSuite(agentsRoot) {
37
33
  surfaces: { skills: 0, mcp: Object.keys(mcp.servers).length, commands: 0, agents: 0, hooks: 0, lsp: 0 },
38
34
  dimension: 'user',
39
35
  enabled: true,
40
- // A user-created suite has no install entry and no overrides, so every
41
- // surface keeps its enabled default.
42
- activeSurfaces: effectiveSurfaces(undefined),
36
+ // The suite declares MCP only: the other surfaces would otherwise make the
37
+ // command registry read this root a second time beside the user-command
38
+ // panel registry.
39
+ activeSurfaces: effectiveSurfaces({ skills: false, hooks: false, commands: false, agents: false, lsp: false }),
43
40
  installedAt: 'user',
44
41
  errors
45
42
  };
@@ -49,13 +46,25 @@ export async function loadUserMcpSuite(agentsRoot) {
49
46
  * keys this client does not know ride along untouched rather than failing the
50
47
  * whole file, and the closed-set rule the package schema applies to suites
51
48
  * does not apply here.
49
+ *
50
+ * A hand-written file may omit `$schema` entirely, so the baseline identifier
51
+ * is filled in before validation: the document then reads as the baseline
52
+ * release while every shape and required field the client does know is still
53
+ * validated. A rejected document keeps the file's own declarations out of the
54
+ * mount and reports the reason, the same fail-closed rule the write paths
55
+ * apply.
52
56
  */
53
57
  async function validateUserMcp(agentsRoot, raw) {
54
- const result = await validateMcpJson(agentsRoot, raw, { packageRules: false });
58
+ const document = isRecord(raw) && raw['$schema'] === undefined ? { ...raw, $schema: MCP_SCHEMA_ID } : raw;
59
+ const result = await validateMcpJson(agentsRoot, document, { packageRules: false });
55
60
  if (result.config === undefined || result.errors.length > 0)
56
61
  throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
57
62
  return result.config;
58
63
  }
64
+ /** Whether a parsed user document is the JSON object the MCP validator reads. */
65
+ function isRecord(value) {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
67
+ }
59
68
  /** Add one service atomically; reject collisions and malformed config before writing. */
60
69
  export async function addUserMcpServer(agentsRoot, name, server) {
61
70
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
@@ -0,0 +1,4 @@
1
+ /** Build the source for one follow-up message; fresh per message, no shared state. */
2
+ export function pluginMarketSource() {
3
+ return { kind: 'plugin-market', form: 'instructions' };
4
+ }
@@ -125,16 +125,19 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
125
125
  mounts.delete(agent);
126
126
  await mount?.fiber.dispose();
127
127
  };
128
- const unwatchCreated = ctx.on('agent/created', ({ agent }) => attach(agent));
129
- const unwatchDisposed = ctx.on('agent/disposed', ({ agent }) => {
130
- void detach(agent).catch(warn);
131
- });
132
- const unwatchStart = ctx.on('agent/session-start', ({ agent }) => {
133
- attach(agent);
128
+ // One lifecycle listener covers every startup path: the host merged the old
129
+ // agent/session-start emit into agent/created (which now carries the
130
+ // startup/resume/clear/compact source) and fires it serially.
131
+ const unwatchCreated = ctx.on('agent/created', ({ agent }) => {
132
+ const projectAgent = agent;
133
+ attach(projectAgent);
134
134
  // The refresh chain can reject — its queue body re-reads the project
135
135
  // catalog — and the host emits this event without awaiting the listener,
136
136
  // so an unhandled rejection here would surface at every session start.
137
- void mounts.get(agent)?.refresh().catch(warn);
137
+ void mounts.get(projectAgent)?.refresh().catch(warn);
138
+ });
139
+ const unwatchDisposed = ctx.on('agent/disposed', ({ agent }) => {
140
+ void detach(agent).catch(warn);
138
141
  });
139
142
  for (const agent of host.agents.list())
140
143
  attach(agent);
@@ -146,7 +149,6 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
146
149
  disposed = true;
147
150
  unwatchCreated();
148
151
  unwatchDisposed();
149
- unwatchStart();
150
152
  await Promise.all([...mounts.keys()].map(detach));
151
153
  }
152
154
  };
@@ -2,12 +2,17 @@
2
2
  * User command mounting: every enabled entry of the user commands panel
3
3
  * registers as a dsh slash command, so user-authored quick replies behave
4
4
  * exactly like suite commands — the body with `$ARGUMENTS` substituted rides
5
- * one follow-up message on the receiving agent. Reconciled on every catalog
6
- * change, keyed `user/<name>`.
5
+ * one follow-up message on the receiving agent. A nested entry registers
6
+ * under its flattened call name (`git/commit` → `git-commit`) while the panel
7
+ * keeps addressing it by path; when two entries flatten to one call name the
8
+ * first registers and the other is reported. Reconciled on every catalog
9
+ * change, keyed by the call name.
7
10
  * @module runtime/user-commands
8
11
  */
9
12
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
10
- import { USER_ENTRY_NAME } from './user-store.js';
13
+ import { commandCallName } from '../model/command-names.js';
14
+ import { USER_ENTRY_PATH } from './user-store.js';
15
+ import { pluginMarketSource } from './plugin-message-source.js';
11
16
  /** Register/unregister user-panel commands to match the panel's enabled entries. */
12
17
  export class UserCommandMountRegistry {
13
18
  ctx;
@@ -25,14 +30,24 @@ export class UserCommandMountRegistry {
25
30
  const diagnostics = [];
26
31
  const entries = await this.store.list();
27
32
  const wanted = new Map();
33
+ // Registration is keyed by call name, so two entries that flatten to the
34
+ // same one cannot silently shadow each other.
35
+ const owners = new Map();
28
36
  for (const entry of entries) {
29
37
  if (entry.disabled)
30
38
  continue;
31
- if (!USER_ENTRY_NAME.test(entry.name))
39
+ if (!USER_ENTRY_PATH.test(entry.name))
32
40
  continue;
33
- wanted.set(entry.name, {
34
- name: entry.name,
35
- description: entry.description === '' ? `[${this.t('userCommandSourceLabel')}] ${entry.name}` : `[${this.t('userCommandSourceLabel')}] ${entry.description}`,
41
+ const callName = commandCallName(entry.name);
42
+ const owner = owners.get(callName);
43
+ if (owner !== undefined) {
44
+ diagnostics.push(`command "${callName}": "${entry.name}" is shadowed by "${owner}" — both flatten to the same call name`);
45
+ continue;
46
+ }
47
+ owners.set(callName, entry.name);
48
+ wanted.set(callName, {
49
+ name: callName,
50
+ description: entry.description === '' ? `[${this.t('userCommandSourceLabel')}] ${callName}` : `[${this.t('userCommandSourceLabel')}] ${entry.description}`,
36
51
  ...(typeof entry.metadata['argument-hint'] === 'string' && entry.metadata['argument-hint'] !== '' ? { hint: entry.metadata['argument-hint'] } : {}),
37
52
  body: entry.content
38
53
  });
@@ -66,7 +81,7 @@ export class UserCommandMountRegistry {
66
81
  const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
67
82
  agent.followup(createUserMessage({
68
83
  content: [{ type: 'text', text }],
69
- source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
84
+ source: pluginMarketSource()
70
85
  }));
71
86
  return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
72
87
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Direct user configuration for command hooks: the Agent layout root's own
3
+ * hook files, mounted through the same bridge lifecycle a suite's hooks use.
4
+ *
5
+ * The files live at `~/.agents/hooks/hooks.json` and `~/.agents/hooks.json`
6
+ * and are read in that order, merged additively exactly like the project
7
+ * `.agents` layout reads them. A malformed file fails the suite closed with a
8
+ * diagnostic, never a thrown discovery, and the hooks carry no
9
+ * `projectRoot`: the bridge defaults `${CLAUDE_PROJECT_DIR}` to the calling
10
+ * session's workspace.
11
+ */
12
+ import { readFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { normalizeHookDocuments, standaloneHookDocument } from '../catalog/project-hooks.js';
15
+ import { effectiveSurfaces } from '../model/types.js';
16
+ export const USER_HOOKS_SOURCE = '@user-hooks';
17
+ export const USER_HOOKS_SUITE = 'user-hooks';
18
+ /**
19
+ * The Agent layout root's hook documents, relative to that root and read in
20
+ * order. Later files merge additively over earlier ones, the same order and
21
+ * semantics the project `.agents` layout applies. The first entry is also the
22
+ * suite's manifest path and the file a suite with no documents is addressed by.
23
+ */
24
+ export const USER_HOOK_FILES = [join('hooks', 'hooks.json'), 'hooks.json'];
25
+ /** Resolve one Agent-layout-relative hook document against the layout root. */
26
+ export function userHookPath(agentsRoot, file) {
27
+ return join(agentsRoot, file);
28
+ }
29
+ /**
30
+ * Load the user's Agent layout hooks as one synthetic suite.
31
+ *
32
+ * The suite is always built, so one call answers with both the events and the
33
+ * reasons a document was rejected: an absent, malformed, or event-free set of
34
+ * hook files leaves `surfaces.hooks` at 0 and carries the diagnostics.
35
+ * {@link ../application/catalog.ts} merges the suite into the enabled set only
36
+ * when it declares events.
37
+ */
38
+ export async function loadUserHooksSuite(agentsRoot) {
39
+ const errors = [];
40
+ const documents = [];
41
+ for (const file of USER_HOOK_FILES) {
42
+ const settings = await readHookDocument(agentsRoot, file, errors);
43
+ // A malformed file fails the whole set closed, the same way one malformed
44
+ // project hook layer drops every hook the layout declared.
45
+ if (settings === null)
46
+ return userHooksSuite(agentsRoot, undefined, errors);
47
+ // A standalone hook file may carry the bare event table; the shared rule
48
+ // that recognizes it is the same one the project `.agents` layout applies.
49
+ if (settings !== undefined)
50
+ documents.push({ file, settings: standaloneHookDocument(settings) });
51
+ }
52
+ return userHooksSuite(agentsRoot, normalizeHookDocuments(undefined, documents, errors), errors);
53
+ }
54
+ /** One synthetic suite for the Agent layout root's hook files. */
55
+ function userHooksSuite(agentsRoot, hooks, errors) {
56
+ return {
57
+ sourceId: USER_HOOKS_SOURCE,
58
+ id: USER_HOOKS_SUITE,
59
+ root: agentsRoot,
60
+ manifest: { layout: 'agent-plugin-v1', path: userHookPath(agentsRoot, USER_HOOK_FILES[0]), id: USER_HOOKS_SUITE, name: USER_HOOKS_SUITE },
61
+ skills: [],
62
+ ...(hooks === undefined ? {} : { hooks }),
63
+ surfaces: { skills: 0, mcp: 0, hooks: hookCount(hooks), commands: 0, agents: 0, lsp: 0 },
64
+ dimension: 'user',
65
+ enabled: true,
66
+ // The suite declares hooks only; the other surfaces would otherwise make
67
+ // the command and LSP registries read this root a second time.
68
+ activeSurfaces: effectiveSurfaces({ skills: false, mcp: false, commands: false, agents: false, lsp: false }),
69
+ installedAt: 'user',
70
+ errors
71
+ };
72
+ }
73
+ /** One hook file's parsed document: absent is undefined, malformed is null plus a diagnostic. */
74
+ async function readHookDocument(agentsRoot, file, errors) {
75
+ let text;
76
+ try {
77
+ text = await readFile(userHookPath(agentsRoot, file), 'utf8');
78
+ }
79
+ catch (error) {
80
+ if (error.code === 'ENOENT')
81
+ return undefined;
82
+ errors.push(`${file}: hook file is unreadable`);
83
+ return null;
84
+ }
85
+ let raw;
86
+ try {
87
+ raw = JSON.parse(text);
88
+ }
89
+ catch {
90
+ errors.push(`${file}: hook file is invalid JSON`);
91
+ return null;
92
+ }
93
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
94
+ errors.push(`${file}: hook file must be an object`);
95
+ return null;
96
+ }
97
+ return raw;
98
+ }
99
+ /** Declared hook commands, the same count a native suite reports for `surfaces.hooks`. */
100
+ function hookCount(hooks) {
101
+ return Object.values(hooks?.events ?? {}).reduce((total, groups) => total + groups.reduce((count, group) => count + group.hooks.length, 0), 0);
102
+ }