@auggieteo/dsh-mcp-adapter 0.2.0 → 0.2.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/src/host/skill.js CHANGED
@@ -1,4 +1,12 @@
1
- import { readFileSync as defaultReadFileSync } from 'node:fs'
1
+ import {
2
+ mkdirSync as defaultMkdirSync,
3
+ readFileSync as defaultReadFileSync,
4
+ renameSync as defaultRenameSync,
5
+ writeFileSync as defaultWriteFileSync,
6
+ } from 'node:fs'
7
+ import { createRequire } from 'node:module'
8
+ import { homedir as defaultHomedir } from 'node:os'
9
+ import { join } from 'node:path'
2
10
  import { fileURLToPath } from 'node:url'
3
11
 
4
12
  import { errorMessage } from './errors.js'
@@ -7,8 +15,26 @@ import { errorMessage } from './errors.js'
7
15
  export const SKILL_DIR = fileURLToPath(new URL('../../skills/mcp-adapter/', import.meta.url))
8
16
 
9
17
  const SKILL_FILE = 'SKILL.md'
18
+ const SKILL_TEMP_FILE = '.SKILL.md.tmp'
10
19
  const SKILL_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
11
20
 
21
+ /** The Adapter package version, read lazily so a missing manifest never breaks load. */
22
+ function packageVersion() {
23
+ try {
24
+ return createRequire(import.meta.url)('../../package.json').version
25
+ } catch {
26
+ return 'unknown'
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Resolve the DSH skills root the same way the harness does: `$DSH_HOME`
32
+ * when set, otherwise `.dsh` under the OS home directory.
33
+ */
34
+ export function resolveSkillsRoot(env = process.env, homedir = defaultHomedir) {
35
+ return join(env.DSH_HOME ?? join(homedir(), '.dsh'), 'skills')
36
+ }
37
+
12
38
  /**
13
39
  * Split one `SKILL.md` into its frontmatter fields and body. The block must
14
40
  * open the file: `---`, flat `key: value` lines, then a closing `---`. Only
@@ -34,16 +60,96 @@ export function parseSkillFrontmatter(text) {
34
60
  }
35
61
 
36
62
  /**
37
- * Register the bundled `mcp-adapter` skill on the `skills` service so agents
38
- * discover how to drive the Proxy Tool. The body lives in
39
- * `skills/mcp-adapter/SKILL.md` next to the package root, so a human can edit
40
- * the wording without touching code; frontmatter still drives routing.
63
+ * Copy the bundled `SKILL.md` to `$DSH_HOME/skills/<name>/SKILL.md` so the
64
+ * filesystem skill provider (and with it Settings > Skills) discovers it.
65
+ * The copy is atomic — temp file in the target directory, then rename — and
66
+ * never a symlink or hardlink. An installed file is never overwritten: when
67
+ * it exists and matches, nothing happens; when it differs, the human's edit
68
+ * wins and one warning names the path and the package version. Returns true
69
+ * when the file now carries the skill (install done or already present).
70
+ */
71
+ function installSkillFile(ctx, text, name, {
72
+ readInstalledFileSync,
73
+ writeFileSync,
74
+ mkdirSync,
75
+ renameSync,
76
+ env,
77
+ homedir,
78
+ }) {
79
+ const warn = (reason) => {
80
+ ctx.logger?.warn?.(`dsh-mcp-adapter: ${reason}`)
81
+ }
82
+ const targetDir = join(resolveSkillsRoot(env, homedir), name)
83
+ const target = join(targetDir, SKILL_FILE)
84
+ let installed
85
+ try {
86
+ installed = String(readInstalledFileSync(target, 'utf8'))
87
+ } catch (error) {
88
+ if (error?.code !== 'ENOENT') {
89
+ warn(
90
+ `cannot read the installed skill file ${target} (${errorMessage(error)}); `
91
+ + 'registering the bundled skill at runtime instead',
92
+ )
93
+ return false
94
+ }
95
+ }
96
+ if (installed !== undefined) {
97
+ if (installed === text) return true
98
+ // The human's edit wins: leave the file untouched and treat it as the
99
+ // one definition for this name.
100
+ warn(
101
+ `the installed skill file ${target} differs from the bundled copy `
102
+ + `(dsh-mcp-adapter ${packageVersion()}); delete ${targetDir} to refresh `
103
+ + 'it from the package on the next start',
104
+ )
105
+ return true
106
+ }
107
+ try {
108
+ mkdirSync(targetDir, { recursive: true })
109
+ const temp = join(targetDir, SKILL_TEMP_FILE)
110
+ writeFileSync(temp, text, 'utf8')
111
+ renameSync(temp, target)
112
+ return true
113
+ } catch (error) {
114
+ warn(
115
+ `cannot install the skill file at ${target} (${errorMessage(error)}); `
116
+ + 'registering the bundled skill at runtime instead',
117
+ )
118
+ return false
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Install the bundled `mcp-adapter` skill so agents discover how to drive the
124
+ * Proxy Tool. The body lives in `skills/mcp-adapter/SKILL.md` next to the
125
+ * package root, so a human can edit the wording without touching code;
126
+ * frontmatter still drives routing.
41
127
  *
42
- * The skill is a convenience, never a dependency: an unreadable file or bad
43
- * frontmatter logs a warning and registers nothing. `readFileSync` and `dir`
44
- * are injectable for tests.
128
+ * The `skillInstall` mode comes from the Adapter Config (default `'file'`).
129
+ * `'file'` copies the skill to `$DSH_HOME/skills/<name>/SKILL.md` and lets
130
+ * the filesystem provider pick it up — one definition per name, so the
131
+ * runtime registration is skipped. `'runtime'` registers directly on the
132
+ * `skills` service as before. `'off'` installs nothing.
133
+ *
134
+ * The skill is a convenience, never a dependency: an unreadable file, bad
135
+ * frontmatter, or a failed install logs a warning and never throws; a failed
136
+ * file install falls back to the runtime registration. Every filesystem
137
+ * access (`readFileSync` for the bundled copy, `readInstalledFileSync` for
138
+ * the installed one, plus `writeFileSync`, `mkdirSync`, and `renameSync`)
139
+ * and `dir`, `env`, and `homedir` are injectable for tests.
45
140
  */
46
- export function installMcpSkill(ctx, { readFileSync = defaultReadFileSync, dir = SKILL_DIR } = {}) {
141
+ export function installMcpSkill(ctx, {
142
+ readFileSync = defaultReadFileSync,
143
+ readInstalledFileSync = defaultReadFileSync,
144
+ writeFileSync = defaultWriteFileSync,
145
+ mkdirSync = defaultMkdirSync,
146
+ renameSync = defaultRenameSync,
147
+ env = process.env,
148
+ homedir = defaultHomedir,
149
+ dir = SKILL_DIR,
150
+ skillInstall = 'file',
151
+ } = {}) {
152
+ if (skillInstall === 'off') return undefined
47
153
  const fail = (reason) => {
48
154
  ctx.logger?.warn?.(`dsh-mcp-adapter: bundled skill unavailable (${reason})`)
49
155
  return undefined
@@ -57,7 +163,7 @@ export function installMcpSkill(ctx, { readFileSync = defaultReadFileSync, dir =
57
163
  if (typeof description !== 'string' || description === '') {
58
164
  return fail('frontmatter requires a non-empty description')
59
165
  }
60
- return ctx.skills.register({
166
+ const register = () => ctx.skills.register({
61
167
  name,
62
168
  description,
63
169
  ...(whenToUse === undefined ? {} : { whenToUse }),
@@ -65,6 +171,15 @@ export function installMcpSkill(ctx, { readFileSync = defaultReadFileSync, dir =
65
171
  content: body,
66
172
  resourceBase: { kind: 'directory', path: dir },
67
173
  })
174
+ if (skillInstall !== 'runtime') {
175
+ const installed = installSkillFile(ctx, text, name, {
176
+ readInstalledFileSync, writeFileSync, mkdirSync, renameSync, env, homedir,
177
+ })
178
+ // One definition per name: the filesystem provider owns the skill once
179
+ // the file is in place (or the human's edit already covers it).
180
+ if (installed) return undefined
181
+ }
182
+ return register()
68
183
  } catch (error) {
69
184
  return fail(errorMessage(error))
70
185
  }