dsh-managed-workspaces 0.0.0-stage → 1.0.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/tool.js ADDED
@@ -0,0 +1,246 @@
1
+ // dsh-managed-workspaces: the model-facing tool.
2
+ //
3
+ // The tool calls exactly the store methods the Web UI calls through HTTP, so an
4
+ // operation has one implementation and two callers.
5
+
6
+ import { ManagedWorkspaceError } from './store.js'
7
+
8
+ const ACTIONS = ['status', 'list', 'create', 'adopt', 'attach', 'detach', 'register', 'forget']
9
+
10
+ const PARAMETERS = {
11
+ type: 'object',
12
+ additionalProperties: false,
13
+ properties: {
14
+ action: {
15
+ type: 'string',
16
+ enum: ACTIONS,
17
+ description: 'status reads the current session workspace; list reads every managed workspace and the existing workspaces that are not managed yet; create makes a new workspace folder; adopt registers an existing folder (or every existing sidebar workspace) without moving it; attach/detach add or remove one linked folder; register re-registers a workspace in the sidebar; forget removes the plugin record without deleting any folder.',
18
+ },
19
+ name: {
20
+ type: 'string',
21
+ description: 'Folder name for action=create, created under the configured base directory.',
22
+ },
23
+ folder: {
24
+ type: 'string',
25
+ description: 'Absolute path of an existing folder: the folder to link for action=attach, or the existing workspace to adopt for action=adopt.',
26
+ },
27
+ all: {
28
+ type: 'boolean',
29
+ description: 'For action=adopt: adopt every sidebar workspace this plugin does not manage yet.',
30
+ },
31
+ alias: {
32
+ type: 'string',
33
+ description: 'Link name under the workspace _roots directory. Defaults to the folder name; ignored for detach when omitted.',
34
+ },
35
+ workspace: {
36
+ type: 'string',
37
+ description: 'Managed workspace id or name. Omit to use the workspace containing the current session directory.',
38
+ },
39
+ },
40
+ required: ['action'],
41
+ }
42
+
43
+ const OUTPUT = {
44
+ type: 'object',
45
+ additionalProperties: false,
46
+ properties: {
47
+ summary: { type: 'string', description: 'Human-readable result of the action.' },
48
+ },
49
+ required: ['summary'],
50
+ }
51
+
52
+ function statusLabel(view) {
53
+ const registration = view.registered ? 'registered' : 'not registered'
54
+ const directory = view.directory === 'ok' ? 'folder ok' : 'folder missing'
55
+ const origin = view.origin === 'adopted' ? 'adopted' : 'created by DSH'
56
+ return `${origin}, ${registration}, ${directory}`
57
+ }
58
+
59
+ function linkLines(view) {
60
+ if (view.links.length === 0) return [' (no linked folders)']
61
+ return view.links.map((link) => ` - ${link.alias} -> ${link.target} [${link.health}]`)
62
+ }
63
+
64
+ function workspaceBlock(view) {
65
+ return [`Workspace "${view.name}" at ${view.path} [${statusLabel(view)}]`, 'Linked folders:', ...linkLines(view)].join('\n')
66
+ }
67
+
68
+ function listBlock(state) {
69
+ const candidates = Array.isArray(state.candidates) ? state.candidates : []
70
+ const sections = []
71
+ if (state.workspaces.length === 0) {
72
+ sections.push(`No managed workspaces yet. Base directory: ${state.baseDirectory}`)
73
+ } else {
74
+ sections.push(`Base directory: ${state.baseDirectory}`, ...state.workspaces.map(workspaceBlock))
75
+ }
76
+ if (candidates.length > 0) {
77
+ sections.push(
78
+ 'Existing sidebar workspaces that this plugin does not manage yet (adopt them with action=adopt, all=true, or action=adopt with "folder"):',
79
+ ...candidates.map((candidate) => ` - ${candidate.title} (${candidate.path})`),
80
+ )
81
+ }
82
+ return sections.join('\n\n')
83
+ }
84
+
85
+ /**
86
+ * Build the `managed_workspace` tool definition.
87
+ * @param {object} ctx - plugin context used to read the calling session.
88
+ * @param {import('./store.js').ManagedWorkspaceStore} store - state owner.
89
+ * @returns {object} registry-ready tool definition.
90
+ */
91
+ export function createManagedWorkspaceTool(ctx, store) {
92
+ function currentCwd(exec) {
93
+ const session = exec.agent?.session
94
+ if (session === undefined) return undefined
95
+ try {
96
+ return ctx.get('workingDirectory')?.get(session) ?? session.header.cwd
97
+ } catch {
98
+ return session.header.cwd
99
+ }
100
+ }
101
+
102
+ function resolveTarget(args, exec) {
103
+ const records = store.listSync()
104
+ const requested = typeof args.workspace === 'string' ? args.workspace.trim() : ''
105
+ if (requested.length > 0) {
106
+ const match =
107
+ records.find((record) => record.id === requested) ??
108
+ records.find((record) => record.name.toLowerCase() === requested.toLowerCase())
109
+ if (match === undefined) {
110
+ throw new ManagedWorkspaceError(
111
+ 'not-found',
112
+ `unknown managed workspace "${requested}"; available: ${records.map((record) => record.name).join(', ') || '(none)'}`,
113
+ )
114
+ }
115
+ return match
116
+ }
117
+ const cwd = currentCwd(exec)
118
+ const found = cwd === undefined ? null : store.findWorkspaceForPath(cwd)
119
+ if (found !== null) return found.workspace
120
+ throw new ManagedWorkspaceError(
121
+ 'workspace-required',
122
+ records.length === 0
123
+ ? 'no managed workspace exists yet; create one with action=create'
124
+ : `the current directory is not inside a managed workspace; name one with "workspace" (available: ${records.map((record) => record.name).join(', ')})`,
125
+ )
126
+ }
127
+
128
+ return {
129
+ name: 'managed_workspace',
130
+ description: [
131
+ 'Manage DSH workspaces: folders the harness creates under a configured base directory, each of which can own several linked folders, plus existing folders adopted into the same management.',
132
+ 'A linked folder appears inside the workspace as _roots/<alias> and its absolute path stays valid.',
133
+ 'status reports the workspace of the current session; list reports managed workspaces and the existing sidebar workspaces that are not managed yet; create makes a workspace folder and registers it in the sidebar; adopt registers an existing folder (or every existing sidebar workspace with all=true) without moving or creating anything; attach/detach add or remove a linked folder; register restores a sidebar registration; forget removes only the plugin record and never deletes files.',
134
+ ].join(' '),
135
+ parameters: PARAMETERS,
136
+ output: {
137
+ schema: OUTPUT,
138
+ render(_args, value) {
139
+ return [{ type: 'text', text: String(value.summary) }]
140
+ },
141
+ },
142
+ async execute(args, exec) {
143
+ const action = args.action
144
+ if (!ACTIONS.includes(action)) {
145
+ throw new ManagedWorkspaceError('invalid-action', `unknown action: ${JSON.stringify(action)}`)
146
+ }
147
+ await store.load()
148
+
149
+ if (action === 'list') {
150
+ const state = await store.state()
151
+ return { summary: listBlock(state) }
152
+ }
153
+
154
+ if (action === 'status') {
155
+ const cwd = currentCwd(exec)
156
+ const found = cwd === undefined ? null : store.findWorkspaceForPath(cwd)
157
+ if (found === null) {
158
+ const state = await store.state()
159
+ return {
160
+ summary: `The current directory (${cwd ?? 'unknown'}) is not inside a managed workspace.\n\n${listBlock(state)}`,
161
+ }
162
+ }
163
+ const view = await store.view(found.workspace)
164
+ const route = found.via === 'link' ? `through linked folder "${found.alias}"` : 'through the workspace root'
165
+ return { summary: `${workspaceBlock(view)}\nCurrent directory resolves to this workspace ${route}.` }
166
+ }
167
+
168
+ if (action === 'create') {
169
+ const result = await store.create(args.name)
170
+ const warning = result.warning === undefined ? '' : `\nWarning: ${result.warning}`
171
+ return {
172
+ summary: `Created ${workspaceBlock(result.workspace)}${warning}\nStart a session from this workspace in the sidebar to work in it.`,
173
+ }
174
+ }
175
+
176
+ if (action === 'adopt') {
177
+ if (args.all === true) {
178
+ const result = await store.adoptAll()
179
+ const lines = []
180
+ lines.push(
181
+ result.adopted.length === 0
182
+ ? 'No existing workspace needed adoption.'
183
+ : `Adopted ${result.adopted.length} workspace(s): ${result.adopted.join(', ')}.`,
184
+ )
185
+ if (result.skipped.length > 0) lines.push(`Already managed: ${result.skipped.join(', ')}.`)
186
+ if (result.failed.length > 0) lines.push(`Failed:\n${result.failed.map((entry) => ` - ${entry}`).join('\n')}`)
187
+ lines.push('Adoption created no folders and moved nothing; links can be added with action=attach.')
188
+ return { summary: lines.join('\n') }
189
+ }
190
+ if (typeof args.folder !== 'string' || args.folder.trim().length === 0) {
191
+ throw new ManagedWorkspaceError('invalid-target', 'action=adopt requires "folder", or "all": true')
192
+ }
193
+ const result = await store.adopt(args.folder)
194
+ const headline = result.adopted
195
+ ? `Adopted the existing folder as a managed workspace without moving or creating anything inside it.`
196
+ : 'This folder is already managed.'
197
+ const warning = result.warning === undefined ? '' : `\nWarning: ${result.warning}`
198
+ return { summary: `${headline}\n${workspaceBlock(result.workspace)}${warning}` }
199
+ }
200
+
201
+ if (action === 'register') {
202
+ const record = resolveTarget(args, exec)
203
+ const result = await store.register(record.id)
204
+ return { summary: `Re-registered the workspace.\n${workspaceBlock(result.workspace)}` }
205
+ }
206
+
207
+ if (action === 'forget') {
208
+ const record = resolveTarget(args, exec)
209
+ const result = await store.forget(record.id)
210
+ const registration = result.keptRegistration
211
+ ? ' Its sidebar registration was kept because the folder existed before the plugin.'
212
+ : result.unregistered
213
+ ? ' Its sidebar registration was removed.'
214
+ : ''
215
+ return {
216
+ summary: `Removed workspace "${result.removed}" from the plugin record${registration} Files were not deleted: ${result.path}`,
217
+ }
218
+ }
219
+
220
+ if (action === 'attach') {
221
+ const record = resolveTarget(args, exec)
222
+ if (typeof args.folder !== 'string' || args.folder.trim().length === 0) {
223
+ throw new ManagedWorkspaceError('invalid-target', 'action=attach requires "folder"')
224
+ }
225
+ const result = await store.attach(record.id, args.folder, args.alias)
226
+ const outcome = result.created ? 'Linked' : 'Already linked'
227
+ return {
228
+ summary: `${outcome} ${result.link.target} as _roots/${result.link.alias} [${result.link.health}].\n${workspaceBlock(result.workspace)}`,
229
+ }
230
+ }
231
+
232
+ if (action === 'detach') {
233
+ const record = resolveTarget(args, exec)
234
+ if (typeof args.alias !== 'string' || args.alias.trim().length === 0) {
235
+ throw new ManagedWorkspaceError('invalid-alias', 'action=detach requires "alias"')
236
+ }
237
+ const result = await store.detach(record.id, args.alias)
238
+ return {
239
+ summary: `Removed the link "${result.removed}" from the workspace. The linked folder itself was not touched.\n${workspaceBlock(result.workspace)}`,
240
+ }
241
+ }
242
+
243
+ throw new ManagedWorkspaceError('invalid-action', `action ${JSON.stringify(action)} is not executable`)
244
+ },
245
+ }
246
+ }