teamcopilot 0.0.1

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 (209) hide show
  1. package/.env.example +10 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +131 -0
  4. package/bin/teamcopilot.js +281 -0
  5. package/dist/auth/index.js +189 -0
  6. package/dist/change-user-role.js +77 -0
  7. package/dist/chat/index.js +849 -0
  8. package/dist/constants.js +2 -0
  9. package/dist/create-user.js +98 -0
  10. package/dist/cronjob/index.js +16 -0
  11. package/dist/cronjob/resource-reconciliation.js +33 -0
  12. package/dist/delete-user.js +66 -0
  13. package/dist/frontend/assets/abap-CRCWOmpq.js +1 -0
  14. package/dist/frontend/assets/apex-DnsZk_dE.js +1 -0
  15. package/dist/frontend/assets/azcli-1IWB1ccx.js +1 -0
  16. package/dist/frontend/assets/bat-DPkNLes8.js +1 -0
  17. package/dist/frontend/assets/bicep-Corcdgou.js +2 -0
  18. package/dist/frontend/assets/cameligo-CGrWLZr3.js +1 -0
  19. package/dist/frontend/assets/clojure-D9WOWImG.js +1 -0
  20. package/dist/frontend/assets/codicon-DCmgc-ay.ttf +0 -0
  21. package/dist/frontend/assets/coffee-B7EJu28W.js +1 -0
  22. package/dist/frontend/assets/cpp-SEyurbux.js +1 -0
  23. package/dist/frontend/assets/csharp-BoL64M5l.js +1 -0
  24. package/dist/frontend/assets/csp-C46ZqvIl.js +1 -0
  25. package/dist/frontend/assets/css-DQU6DXDx.js +3 -0
  26. package/dist/frontend/assets/cssMode-BDT3WbVs.js +4 -0
  27. package/dist/frontend/assets/cypher-D84EuPTj.js +1 -0
  28. package/dist/frontend/assets/dart-D8lhlL1r.js +1 -0
  29. package/dist/frontend/assets/dockerfile-DLk6rpji.js +1 -0
  30. package/dist/frontend/assets/ecl-BO6FnfXk.js +1 -0
  31. package/dist/frontend/assets/editor.worker-B4pQIWZD.js +12 -0
  32. package/dist/frontend/assets/elixir-BRjLKONM.js +1 -0
  33. package/dist/frontend/assets/flow9-Cac8vKd7.js +1 -0
  34. package/dist/frontend/assets/freemarker2-C7-hEgID.js +3 -0
  35. package/dist/frontend/assets/fsharp-fd1GTHhf.js +1 -0
  36. package/dist/frontend/assets/go-O9LJTZXk.js +1 -0
  37. package/dist/frontend/assets/graphql-LQdxqEYJ.js +1 -0
  38. package/dist/frontend/assets/handlebars-4cwTkPir.js +1 -0
  39. package/dist/frontend/assets/hcl-DxDQ3s82.js +1 -0
  40. package/dist/frontend/assets/html-YNfE1Q0A.js +1 -0
  41. package/dist/frontend/assets/htmlMode-opTQ1HoB.js +4 -0
  42. package/dist/frontend/assets/index-DWyaVa1h.js +782 -0
  43. package/dist/frontend/assets/index-lXrsgeTF.css +1 -0
  44. package/dist/frontend/assets/ini-BvajGCUy.js +1 -0
  45. package/dist/frontend/assets/java-SYsfObOQ.js +1 -0
  46. package/dist/frontend/assets/javascript-BEwGzk7T.js +1 -0
  47. package/dist/frontend/assets/jsonMode-CGhIS5Al.js +10 -0
  48. package/dist/frontend/assets/julia-DQXNmw_w.js +1 -0
  49. package/dist/frontend/assets/kotlin-qQ0MG-9I.js +1 -0
  50. package/dist/frontend/assets/less-GGFNNJHn.js +2 -0
  51. package/dist/frontend/assets/lexon-Canl7DCW.js +1 -0
  52. package/dist/frontend/assets/liquid-QekTGCGJ.js +1 -0
  53. package/dist/frontend/assets/lua-D28Ae8-K.js +1 -0
  54. package/dist/frontend/assets/m3-DPitgjJI.js +1 -0
  55. package/dist/frontend/assets/markdown-B811l8j2.js +1 -0
  56. package/dist/frontend/assets/mdx-BAVDaB7v.js +1 -0
  57. package/dist/frontend/assets/mips-CdjsipkG.js +1 -0
  58. package/dist/frontend/assets/msdax-CYqgjx_P.js +1 -0
  59. package/dist/frontend/assets/mysql-BHd6q0vd.js +1 -0
  60. package/dist/frontend/assets/objective-c-B1aVtJYH.js +1 -0
  61. package/dist/frontend/assets/pascal-BhNW15KB.js +1 -0
  62. package/dist/frontend/assets/pascaligo-5jv8CcQD.js +1 -0
  63. package/dist/frontend/assets/perl-DlYyT36c.js +1 -0
  64. package/dist/frontend/assets/pgsql-Dy0bjov7.js +1 -0
  65. package/dist/frontend/assets/php-120yhfDK.js +1 -0
  66. package/dist/frontend/assets/pla-CjnFlu4u.js +1 -0
  67. package/dist/frontend/assets/postiats-CQpG440k.js +1 -0
  68. package/dist/frontend/assets/powerquery-DdJtto1Z.js +1 -0
  69. package/dist/frontend/assets/powershell-Bu_VLpJB.js +1 -0
  70. package/dist/frontend/assets/protobuf-IBS6jZEB.js +2 -0
  71. package/dist/frontend/assets/pug-kFxLfcjb.js +1 -0
  72. package/dist/frontend/assets/python-BQlHw7XO.js +1 -0
  73. package/dist/frontend/assets/qsharp-q7JyzKFN.js +1 -0
  74. package/dist/frontend/assets/r-BIFz-_sK.js +1 -0
  75. package/dist/frontend/assets/razor-Be3Wwc2E.js +1 -0
  76. package/dist/frontend/assets/redis-CHOsPHWR.js +1 -0
  77. package/dist/frontend/assets/redshift-CBifECDb.js +1 -0
  78. package/dist/frontend/assets/restructuredtext-CghPJEOS.js +1 -0
  79. package/dist/frontend/assets/ruby-CYWGW-b1.js +1 -0
  80. package/dist/frontend/assets/rust-DMDD0SHb.js +1 -0
  81. package/dist/frontend/assets/sb-BYAiYHFx.js +1 -0
  82. package/dist/frontend/assets/scala-Bqvq8jcR.js +1 -0
  83. package/dist/frontend/assets/scheme-Dhb-2j9p.js +1 -0
  84. package/dist/frontend/assets/scss-CTwUZ5N7.js +3 -0
  85. package/dist/frontend/assets/shell-CsDZo4DB.js +1 -0
  86. package/dist/frontend/assets/solidity-CME5AdoB.js +1 -0
  87. package/dist/frontend/assets/sophia-RYC1BQQz.js +1 -0
  88. package/dist/frontend/assets/sparql-KEyrF7De.js +1 -0
  89. package/dist/frontend/assets/sql-BdTr02Mf.js +1 -0
  90. package/dist/frontend/assets/st-C7iG7M4S.js +1 -0
  91. package/dist/frontend/assets/swift-D7IUmUK8.js +1 -0
  92. package/dist/frontend/assets/systemverilog-DgMryOEJ.js +1 -0
  93. package/dist/frontend/assets/tcl-PloMZuKG.js +1 -0
  94. package/dist/frontend/assets/tsMode-CIBFoN3z.js +11 -0
  95. package/dist/frontend/assets/twig-BfRIq3la.js +1 -0
  96. package/dist/frontend/assets/typescript-BuV9wEIE.js +1 -0
  97. package/dist/frontend/assets/typespec-CzxlYoT_.js +1 -0
  98. package/dist/frontend/assets/vb-BwAE3J76.js +1 -0
  99. package/dist/frontend/assets/wgsl-B_1kOXbF.js +298 -0
  100. package/dist/frontend/assets/xml-DcDKYaM4.js +1 -0
  101. package/dist/frontend/assets/yaml-CuBNmOuI.js +1 -0
  102. package/dist/frontend/index.html +14 -0
  103. package/dist/frontend/logo.svg +50 -0
  104. package/dist/index.js +169 -0
  105. package/dist/logging.js +30 -0
  106. package/dist/opencode-auth/index.js +122 -0
  107. package/dist/opencode-server.js +91 -0
  108. package/dist/prisma/client.js +38 -0
  109. package/dist/reset-password.js +73 -0
  110. package/dist/rotate-jwt-secret.js +20 -0
  111. package/dist/scripts/prisma-workspace.js +34 -0
  112. package/dist/skills/index.js +311 -0
  113. package/dist/types/permissions.js +2 -0
  114. package/dist/types/shared/permissions.js +17 -0
  115. package/dist/types/shared/skill.js +17 -0
  116. package/dist/types/shared/workflow-files.js +17 -0
  117. package/dist/types/shared/workflow.js +17 -0
  118. package/dist/types/skill.js +2 -0
  119. package/dist/types/workflow-files.js +2 -0
  120. package/dist/types/workflow.js +2 -0
  121. package/dist/users/index.js +22 -0
  122. package/dist/utils/approval-snapshot-common.js +596 -0
  123. package/dist/utils/assert.js +20 -0
  124. package/dist/utils/chat-session.js +44 -0
  125. package/dist/utils/cli-bootstrap.js +26 -0
  126. package/dist/utils/index.js +95 -0
  127. package/dist/utils/jwt-secret.js +63 -0
  128. package/dist/utils/opencode-auth.js +126 -0
  129. package/dist/utils/opencode-client.js +109 -0
  130. package/dist/utils/password-policy.js +12 -0
  131. package/dist/utils/permission-common.js +280 -0
  132. package/dist/utils/redact.js +108 -0
  133. package/dist/utils/resource-access.js +37 -0
  134. package/dist/utils/resource-file-routes.js +115 -0
  135. package/dist/utils/resource-files.js +572 -0
  136. package/dist/utils/runtime-paths.js +61 -0
  137. package/dist/utils/session-abort.js +52 -0
  138. package/dist/utils/skill-approval-snapshot.js +39 -0
  139. package/dist/utils/skill-files.js +17 -0
  140. package/dist/utils/skill-permissions.js +15 -0
  141. package/dist/utils/skill.js +217 -0
  142. package/dist/utils/user-role.js +14 -0
  143. package/dist/utils/workflow-approval-snapshot.js +38 -0
  144. package/dist/utils/workflow-files.js +17 -0
  145. package/dist/utils/workflow-interruption.js +50 -0
  146. package/dist/utils/workflow-permissions.js +27 -0
  147. package/dist/utils/workflow-runner.js +414 -0
  148. package/dist/utils/workflow.js +158 -0
  149. package/dist/utils/workspace-sync.js +204 -0
  150. package/dist/workflows/index.js +751 -0
  151. package/dist/workspace_files/.opencode/opencode.json +17 -0
  152. package/dist/workspace_files/.opencode/package.json +14 -0
  153. package/dist/workspace_files/.opencode/plugins/createSkill.ts +339 -0
  154. package/dist/workspace_files/.opencode/plugins/createWorkflow.ts +345 -0
  155. package/dist/workspace_files/.opencode/plugins/findSimilarWorkflow.ts +173 -0
  156. package/dist/workspace_files/.opencode/plugins/findSkill.ts +211 -0
  157. package/dist/workspace_files/.opencode/plugins/getSkillContent.ts +135 -0
  158. package/dist/workspace_files/.opencode/plugins/honeytoken-protection.ts +64 -0
  159. package/dist/workspace_files/.opencode/plugins/listAvailableSkills.ts +93 -0
  160. package/dist/workspace_files/.opencode/plugins/listAvailableWorkflows.ts +93 -0
  161. package/dist/workspace_files/.opencode/plugins/python-protection.ts +184 -0
  162. package/dist/workspace_files/.opencode/plugins/runWorkflow.ts +168 -0
  163. package/dist/workspace_files/.opencode/tsconfig.json +16 -0
  164. package/dist/workspace_files/AGENTS.md +483 -0
  165. package/dist/workspace_files/package-lock.json +167 -0
  166. package/dist/workspace_files/package.json +5 -0
  167. package/package.json +86 -0
  168. package/prisma/migrations/20260203040755_init/migration.sql +20 -0
  169. package/prisma/migrations/20260204034845_replace_google_auth_with_email_password/migration.sql +25 -0
  170. package/prisma/migrations/20260207022226_add_user_role/migration.sql +25 -0
  171. package/prisma/migrations/20260210161254_add_workflow_runs/migration.sql +16 -0
  172. package/prisma/migrations/20260211050606_adds_workflow_table/migration.sql +40 -0
  173. package/prisma/migrations/20260211050750_adds_fkey_constraint/migration.sql +21 -0
  174. package/prisma/migrations/20260211051912_removes_workflow_table/migration.sql +34 -0
  175. package/prisma/migrations/20260211052238_changes_workflow_id_to_slug/migration.sql +27 -0
  176. package/prisma/migrations/20260212051912_add_output_to_workflow_runs/migration.sql +2 -0
  177. package/prisma/migrations/20260213073006_add_chat_sessions/migration.sql +13 -0
  178. package/prisma/migrations/20260216053202_add_chat_sessions_opencode_session_id_idx/migration.sql +2 -0
  179. package/prisma/migrations/20260216053237_drop_redundant_chat_sessions_opencode_idx/migration.sql +2 -0
  180. package/prisma/migrations/20260219060705_makes/migration.sql +24 -0
  181. package/prisma/migrations/20260222040542_add_workflow_execution_permissions/migration.sql +18 -0
  182. package/prisma/migrations/20260222040815_remove_workflow_execution_permissions/migration.sql +10 -0
  183. package/prisma/migrations/20260222041348_add_workflow_execution_permissions_final/migration.sql +17 -0
  184. package/prisma/migrations/20260222041741_rename_to_tool_execution_permissions/migration.sql +30 -0
  185. package/prisma/migrations/20260222041826_simplify_tool_execution_permissions/migration.sql +29 -0
  186. package/prisma/migrations/20260222041950_add_fields_for_standalone_permissions/migration.sql +32 -0
  187. package/prisma/migrations/20260222042954_simplify_tool_permissions_table/migration.sql +27 -0
  188. package/prisma/migrations/20260223073902_add_workflow_run_permissions_tables/migration.sql +23 -0
  189. package/prisma/migrations/20260225025151_add_workflow_metadata/migration.sql +16 -0
  190. package/prisma/migrations/20260225031035_merge_workflow_permissions_into_metadata/migration.sql +44 -0
  191. package/prisma/migrations/20260225031752_removes_default_for_run_permission_mode/migration.sql +20 -0
  192. package/prisma/migrations/20260225033603_remove_workflow_metadata_user_fkeys/migration.sql +18 -0
  193. package/prisma/migrations/20260225043032_restore_workflow_metadata_user_fkeys/migration.sql +20 -0
  194. package/prisma/migrations/20260225091423_add_workflow_approved_snapshots/migration.sql +28 -0
  195. package/prisma/migrations/20260226032121_add_is_approved_to_workflow_metadata/migration.sql +21 -0
  196. package/prisma/migrations/20260226032444_undoes_last_db_change/migration.sql +26 -0
  197. package/prisma/migrations/20260227120000_remove_snapshot_hash_from_approved_snapshots/migration.sql +16 -0
  198. package/prisma/migrations/20260228071125_adds_workspace_path_to_snapshot_table/migration.sql +22 -0
  199. package/prisma/migrations/20260228071217_modifies_index_and_removes_default_value/migration.sql +22 -0
  200. package/prisma/migrations/20260228071710_undoes_previous/migration.sql +27 -0
  201. package/prisma/migrations/20260228105022_add_must_change_password_first_login/migration.sql +20 -0
  202. package/prisma/migrations/20260301115439_add_workflow_run_log_refs/migration.sql +8 -0
  203. package/prisma/migrations/20260301122557_add_workflow_aborted_sessions/migration.sql +5 -0
  204. package/prisma/migrations/20260302045545_move_workflow_run_log_refs_into_workflow_runs/migration.sql +17 -0
  205. package/prisma/migrations/20260303040318_add_skill_tables/migration.sql +61 -0
  206. package/prisma/migrations/20260303051533_unify_resource_permissions/migration.sql +97 -0
  207. package/prisma/migrations/20260303064255_unify_resource_metadata_and_snapshots/migration.sql +179 -0
  208. package/prisma/migrations/migration_lock.toml +3 -0
  209. package/prisma/schema.prisma +147 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "mcp": {
4
+ "brave-search": {
5
+ "type": "local",
6
+ "command": [
7
+ "npx",
8
+ "-y",
9
+ "@brave/brave-search-mcp-server"
10
+ ],
11
+ "enabled": false,
12
+ "environment": {
13
+ "BRAVE_API_KEY": "YOUR_API_KEY_HERE"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "flowpal-opencode-plugins",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugins for FlowPal workflow management",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@huggingface/transformers": "^3.0.0",
8
+ "@opencode-ai/plugin": "1.1.65"
9
+ },
10
+ "devDependencies": {
11
+ "@types/node": "^20.0.0",
12
+ "typescript": "^5.0.0"
13
+ }
14
+ }
@@ -0,0 +1,339 @@
1
+ import { type Plugin, tool } from "@opencode-ai/plugin"
2
+ import * as fs from "fs/promises"
3
+ import * as path from "path"
4
+
5
+ function getApiBaseUrl(): string {
6
+ const port = process.env.TEAMCOPILOT_PORT?.trim()
7
+ if (!port) {
8
+ throw new Error("TEAMCOPILOT_PORT must be set.")
9
+ }
10
+ return `http://localhost:${port}`
11
+ }
12
+
13
+
14
+ const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
15
+
16
+ interface SkillFileContentResponse {
17
+ path: string
18
+ kind: "text" | "binary"
19
+ content?: string
20
+ etag: string
21
+ }
22
+
23
+ interface PermissionResponse {
24
+ approved: boolean
25
+ }
26
+
27
+ async function readErrorMessageFromResponse(
28
+ response: Response,
29
+ fallbackMessage: string
30
+ ): Promise<string> {
31
+ try {
32
+ const text = await response.text()
33
+ if (!text) return fallbackMessage
34
+ try {
35
+ const parsed: unknown = JSON.parse(text)
36
+ if (parsed && typeof parsed === "object" && "message" in parsed) {
37
+ const msg = (parsed as { message?: unknown }).message
38
+ if (typeof msg === "string" && msg.trim().length > 0) return msg
39
+ }
40
+ } catch {
41
+ // fall back to plain text
42
+ }
43
+ return text.trim().length > 0 ? text : fallbackMessage
44
+ } catch {
45
+ return fallbackMessage
46
+ }
47
+ }
48
+
49
+ function extractMessageId(context: unknown): string | null {
50
+ if (!context || typeof context !== "object") {
51
+ return null
52
+ }
53
+
54
+ const candidate = (context as { messageID?: unknown; messageId?: unknown; message_id?: unknown })
55
+ const raw = candidate.messageID ?? candidate.messageId ?? candidate.message_id
56
+ if (typeof raw === "string" && raw.trim().length > 0) {
57
+ return raw
58
+ }
59
+ if (typeof raw === "number" && Number.isFinite(raw)) {
60
+ return String(raw)
61
+ }
62
+ return null
63
+ }
64
+
65
+ function extractCallId(context: unknown): string | null {
66
+ if (!context || typeof context !== "object") {
67
+ return null
68
+ }
69
+
70
+ const candidate = (context as { callID?: unknown; callId?: unknown; call_id?: unknown })
71
+ const raw = candidate.callID ?? candidate.callId ?? candidate.call_id
72
+ if (typeof raw === "string" && raw.trim().length > 0) {
73
+ return raw
74
+ }
75
+ return null
76
+ }
77
+
78
+ async function pathExists(p: string): Promise<boolean> {
79
+ try {
80
+ await fs.access(p)
81
+ return true
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ function isPathInside(childPath: string, parentPath: string): boolean {
88
+ const parent = path.resolve(parentPath) + path.sep
89
+ const child = path.resolve(childPath) + path.sep
90
+ return child.startsWith(parent)
91
+ }
92
+
93
+ async function rejectCreationPermission(
94
+ sessionID: string,
95
+ permissionId: string
96
+ ): Promise<void> {
97
+ await fetch(`${getApiBaseUrl()}/api/workflows/permission-reject/${permissionId}`, {
98
+ method: "POST",
99
+ headers: {
100
+ Authorization: `Bearer ${sessionID}`,
101
+ },
102
+ })
103
+ }
104
+
105
+ async function requestCreationPermission(
106
+ sessionID: string,
107
+ messageID: string,
108
+ callID: string
109
+ ): Promise<void> {
110
+ const response = await fetch(`${getApiBaseUrl()}/api/workflows/request-permission`, {
111
+ method: "POST",
112
+ headers: {
113
+ "Content-Type": "application/json",
114
+ Authorization: `Bearer ${sessionID}`,
115
+ },
116
+ body: JSON.stringify({
117
+ opencode_session_id: sessionID,
118
+ message_id: messageID,
119
+ call_id: callID,
120
+ }),
121
+ })
122
+
123
+ if (!response.ok) {
124
+ const message = await readErrorMessageFromResponse(
125
+ response,
126
+ `Failed to request permission (HTTP ${response.status})`
127
+ )
128
+ throw new Error(message)
129
+ }
130
+
131
+ const data = (await response.json()) as { permission_id: string }
132
+ const permissionId = data.permission_id
133
+
134
+ const maxAttempts = 300
135
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
136
+ await new Promise((resolve) => setTimeout(resolve, 1000))
137
+
138
+ const statusResponse = await fetch(
139
+ `${getApiBaseUrl()}/api/workflows/permission-status/${permissionId}`,
140
+ {
141
+ headers: {
142
+ Authorization: `Bearer ${sessionID}`,
143
+ },
144
+ }
145
+ )
146
+
147
+ if (!statusResponse.ok) {
148
+ continue
149
+ }
150
+
151
+ const statusData = (await statusResponse.json()) as PermissionResponse & {
152
+ status: string
153
+ }
154
+
155
+ if (statusData.status === "approved") {
156
+ return
157
+ }
158
+ if (statusData.status === "rejected") {
159
+ throw new Error("User denied permission to create this skill.")
160
+ }
161
+ }
162
+
163
+ try {
164
+ await rejectCreationPermission(sessionID, permissionId)
165
+ } catch {
166
+ // Best-effort cleanup only; preserve the timeout error below.
167
+ }
168
+ throw new Error("Permission request timed out")
169
+ }
170
+
171
+ function stripLeadingFrontmatter(markdown: string): string {
172
+ const trimmedStart = markdown.trimStart()
173
+ if (!trimmedStart.startsWith("---\n")) {
174
+ return markdown.trim()
175
+ }
176
+
177
+ const frontmatterEnd = trimmedStart.indexOf("\n---\n", 4)
178
+ if (frontmatterEnd < 0) {
179
+ return markdown.trim()
180
+ }
181
+
182
+ return trimmedStart.slice(frontmatterEnd + "\n---\n".length).trim()
183
+ }
184
+
185
+ function buildSkillMarkdown(
186
+ slug: string,
187
+ description: string,
188
+ markdownContent: string
189
+ ): string {
190
+ const body = stripLeadingFrontmatter(markdownContent)
191
+ return `---\nname: ${JSON.stringify(slug)}\ndescription: ${JSON.stringify(description)}\n---\n\n${body}\n`
192
+ }
193
+
194
+ export const CreateSkillPlugin: Plugin = async (_ctx) => {
195
+ return {
196
+ tool: {
197
+ createSkill: tool({
198
+ description:
199
+ "Create a new custom skill. Creates the skill directory, updates database metadata, and writes SKILL.md with provided description and markdown content.",
200
+ args: {
201
+ slug: tool.schema
202
+ .string()
203
+ .describe("Skill slug (lowercase letters/numbers with hyphens)"),
204
+ description: tool.schema
205
+ .string()
206
+ .describe("Short description of what this skill does"),
207
+ content: tool.schema
208
+ .string()
209
+ .describe("Markdown content to store in SKILL.md"),
210
+ },
211
+ async execute(args, context) {
212
+ const { directory, sessionID } = context
213
+ const slug = args.slug.trim()
214
+ const description = args.description.trim()
215
+ const content = args.content.trim()
216
+ const messageId = extractMessageId(context)
217
+ const callId = extractCallId(context)
218
+
219
+ if (!SLUG_REGEX.test(slug)) {
220
+ throw new Error(
221
+ `Invalid slug format: "${slug}". Slug must be lowercase alphanumeric with hyphens (e.g., "my-skill-name").`
222
+ )
223
+ }
224
+
225
+ if (!description) {
226
+ throw new Error("description is required")
227
+ }
228
+
229
+ if (!content) {
230
+ throw new Error("content is required")
231
+ }
232
+
233
+ if (!messageId) {
234
+ throw new Error("Could not determine message id from tool context.")
235
+ }
236
+
237
+ if (!callId) {
238
+ throw new Error("Could not determine call id from tool context.")
239
+ }
240
+
241
+ const skillsDir = path.join(directory, "custom-skills")
242
+ const skillDir = path.join(skillsDir, slug)
243
+
244
+ if (!isPathInside(skillDir, skillsDir)) {
245
+ throw new Error("Path traversal detected. Invalid slug.")
246
+ }
247
+
248
+ if (await pathExists(skillDir)) {
249
+ throw new Error(`Skill "${slug}" already exists at ${skillDir}`)
250
+ }
251
+
252
+ await requestCreationPermission(
253
+ sessionID,
254
+ messageId,
255
+ callId
256
+ )
257
+
258
+ const createResponse = await fetch(`${getApiBaseUrl()}/api/skills`, {
259
+ method: "POST",
260
+ headers: {
261
+ "Content-Type": "application/json",
262
+ Authorization: `Bearer ${sessionID}`,
263
+ },
264
+ body: JSON.stringify({
265
+ name: slug,
266
+ }),
267
+ })
268
+
269
+ if (!createResponse.ok) {
270
+ const errorMessage = await readErrorMessageFromResponse(
271
+ createResponse,
272
+ `Failed to create skill (HTTP ${createResponse.status})`
273
+ )
274
+ throw new Error(errorMessage)
275
+ }
276
+
277
+ const readResponse = await fetch(
278
+ `${getApiBaseUrl()}/api/skills/${encodeURIComponent(slug)}/files/content?path=${encodeURIComponent("SKILL.md")}`,
279
+ {
280
+ headers: {
281
+ Authorization: `Bearer ${sessionID}`,
282
+ },
283
+ }
284
+ )
285
+
286
+ if (!readResponse.ok) {
287
+ const errorMessage = await readErrorMessageFromResponse(
288
+ readResponse,
289
+ `Failed to load SKILL.md for ${slug} (HTTP ${readResponse.status})`
290
+ )
291
+ throw new Error(errorMessage)
292
+ }
293
+
294
+ const filePayload = (await readResponse.json()) as SkillFileContentResponse
295
+ if (filePayload.kind !== "text") {
296
+ throw new Error(`SKILL.md for ${slug} is not a text file.`)
297
+ }
298
+
299
+ const nextContent = buildSkillMarkdown(slug, description, content)
300
+
301
+ const saveResponse = await fetch(`${getApiBaseUrl()}/api/skills/${encodeURIComponent(slug)}/files/content`, {
302
+ method: "PUT",
303
+ headers: {
304
+ "Content-Type": "application/json",
305
+ Authorization: `Bearer ${sessionID}`,
306
+ },
307
+ body: JSON.stringify({
308
+ path: "SKILL.md",
309
+ content: nextContent,
310
+ base_etag: filePayload.etag,
311
+ }),
312
+ })
313
+
314
+ if (!saveResponse.ok) {
315
+ const errorMessage = await readErrorMessageFromResponse(
316
+ saveResponse,
317
+ `Failed to save SKILL.md for ${slug} (HTTP ${saveResponse.status})`
318
+ )
319
+ throw new Error(errorMessage)
320
+ }
321
+
322
+ return JSON.stringify(
323
+ {
324
+ skill: {
325
+ slug,
326
+ description,
327
+ file_path: `custom-skills/${slug}/SKILL.md`,
328
+ },
329
+ },
330
+ null,
331
+ 2
332
+ )
333
+ },
334
+ }),
335
+ },
336
+ }
337
+ }
338
+
339
+ export default CreateSkillPlugin
@@ -0,0 +1,345 @@
1
+ import { type Plugin, tool } from "@opencode-ai/plugin"
2
+ import * as fs from "fs/promises"
3
+ import * as path from "path"
4
+
5
+ function getApiBaseUrl(): string {
6
+ const port = process.env.TEAMCOPILOT_PORT?.trim()
7
+ if (!port) {
8
+ throw new Error("TEAMCOPILOT_PORT must be set.")
9
+ }
10
+ return `http://localhost:${port}`
11
+ }
12
+
13
+
14
+ // ============================================================================
15
+ // Types
16
+ // ============================================================================
17
+
18
+ interface WorkflowInput {
19
+ type: "string" | "number" | "boolean"
20
+ required?: boolean
21
+ default?: string | number | boolean
22
+ description?: string
23
+ }
24
+
25
+ interface WorkflowManifest {
26
+ intent_summary: string
27
+ inputs?: Record<string, WorkflowInput>
28
+ triggers?: {
29
+ manual?: boolean
30
+ }
31
+ runtime?: {
32
+ timeout_seconds?: number
33
+ }
34
+ }
35
+
36
+ interface PermissionResponse {
37
+ approved: boolean
38
+ }
39
+
40
+ // ============================================================================
41
+ // Constants
42
+ // ============================================================================
43
+
44
+ const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
45
+
46
+ // ============================================================================
47
+ // Helper Functions
48
+ // ============================================================================
49
+
50
+ async function pathExists(p: string): Promise<boolean> {
51
+ try {
52
+ await fs.access(p)
53
+ return true
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function isPathInside(childPath: string, parentPath: string): boolean {
60
+ const parent = path.resolve(parentPath) + path.sep
61
+ const child = path.resolve(childPath) + path.sep
62
+ return child.startsWith(parent)
63
+ }
64
+
65
+ async function readErrorMessageFromResponse(
66
+ response: Response,
67
+ fallbackMessage: string
68
+ ): Promise<string> {
69
+ try {
70
+ const text = await response.text()
71
+ if (!text) return fallbackMessage
72
+ try {
73
+ const parsed: unknown = JSON.parse(text)
74
+ if (parsed && typeof parsed === "object" && "message" in parsed) {
75
+ const msg = (parsed as { message?: unknown }).message
76
+ if (typeof msg === "string" && msg.trim().length > 0) return msg
77
+ }
78
+ } catch {
79
+ // Non-JSON body, fall back to plain text below.
80
+ }
81
+ return text.trim().length > 0 ? text : fallbackMessage
82
+ } catch {
83
+ return fallbackMessage
84
+ }
85
+ }
86
+
87
+ function extractMessageId(context: unknown): string | null {
88
+ if (!context || typeof context !== "object") {
89
+ return null
90
+ }
91
+
92
+ const candidate = (context as { messageID?: unknown; messageId?: unknown; message_id?: unknown })
93
+ const raw = candidate.messageID ?? candidate.messageId ?? candidate.message_id
94
+ if (typeof raw === "string" && raw.trim().length > 0) {
95
+ return raw
96
+ }
97
+ if (typeof raw === "number" && Number.isFinite(raw)) {
98
+ return String(raw)
99
+ }
100
+ return null
101
+ }
102
+
103
+ function extractCallId(context: unknown): string | null {
104
+ if (!context || typeof context !== "object") {
105
+ return null
106
+ }
107
+
108
+ const candidate = (context as { callID?: unknown; callId?: unknown; call_id?: unknown })
109
+ const raw = candidate.callID ?? candidate.callId ?? candidate.call_id
110
+ if (typeof raw === "string" && raw.trim().length > 0) {
111
+ return raw
112
+ }
113
+ return null
114
+ }
115
+
116
+ async function rejectWorkflowPermission(
117
+ sessionID: string,
118
+ permissionId: string
119
+ ): Promise<void> {
120
+ await fetch(`${getApiBaseUrl()}/api/workflows/permission-reject/${permissionId}`, {
121
+ method: "POST",
122
+ headers: {
123
+ Authorization: `Bearer ${sessionID}`,
124
+ },
125
+ })
126
+ }
127
+
128
+ async function requestWorkflowPermission(
129
+ sessionID: string,
130
+ messageID: string,
131
+ callID: string
132
+ ): Promise<void> {
133
+ const response = await fetch(`${getApiBaseUrl()}/api/workflows/request-permission`, {
134
+ method: "POST",
135
+ headers: {
136
+ "Content-Type": "application/json",
137
+ Authorization: `Bearer ${sessionID}`,
138
+ },
139
+ body: JSON.stringify({
140
+ opencode_session_id: sessionID,
141
+ message_id: messageID,
142
+ call_id: callID,
143
+ }),
144
+ })
145
+
146
+ if (!response.ok) {
147
+ const message = await readErrorMessageFromResponse(
148
+ response,
149
+ `Failed to request permission (HTTP ${response.status})`
150
+ )
151
+ throw new Error(message)
152
+ }
153
+
154
+ const data = (await response.json()) as { permission_id: string }
155
+ const permissionId = data.permission_id
156
+
157
+ const maxAttempts = 300
158
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
159
+ await new Promise((resolve) => setTimeout(resolve, 1000))
160
+
161
+ const statusResponse = await fetch(
162
+ `${getApiBaseUrl()}/api/workflows/permission-status/${permissionId}`,
163
+ {
164
+ headers: {
165
+ Authorization: `Bearer ${sessionID}`,
166
+ },
167
+ }
168
+ )
169
+
170
+ if (!statusResponse.ok) {
171
+ continue
172
+ }
173
+
174
+ const statusData = (await statusResponse.json()) as PermissionResponse & {
175
+ status: string
176
+ }
177
+
178
+ if (statusData.status === "approved") {
179
+ return
180
+ }
181
+ if (statusData.status === "rejected") {
182
+ throw new Error("User denied permission to create this workflow.")
183
+ }
184
+ }
185
+
186
+ try {
187
+ await rejectWorkflowPermission(sessionID, permissionId)
188
+ } catch {
189
+ // Best-effort cleanup only; preserve the timeout error below.
190
+ }
191
+ throw new Error("Permission request timed out")
192
+ }
193
+
194
+ // ============================================================================
195
+ // Plugin
196
+ // ============================================================================
197
+
198
+ export const CreateWorkflowPlugin: Plugin = async (_ctx) => {
199
+ return {
200
+ tool: {
201
+ createWorkflow: tool({
202
+ description:
203
+ "Create a new workflow with the specified slug and configuration. Creates the workflow folder with all required files (workflow.json, run.py, requirements.txt, etc.). The workflow will need admin approval before it can be executed.",
204
+ args: {
205
+ slug: tool.schema
206
+ .string()
207
+ .describe(
208
+ "The workflow slug (lowercase letters/numbers with hyphens, e.g., 'failed-stripe-payments')"
209
+ ),
210
+ intent_summary: tool.schema
211
+ .string()
212
+ .describe(
213
+ "Human-readable description of what this workflow does"
214
+ ),
215
+ inputs: tool.schema
216
+ .record(
217
+ tool.schema.string(),
218
+ tool.schema.object({
219
+ type: tool.schema.enum(["string", "number", "boolean"]),
220
+ required: tool.schema.boolean().optional(),
221
+ default: tool.schema.unknown().optional(),
222
+ description: tool.schema.string().optional(),
223
+ })
224
+ )
225
+ .optional()
226
+ .default({})
227
+ .describe(
228
+ "Input parameter schema defining the workflow's expected inputs"
229
+ ),
230
+ timeout_seconds: tool.schema
231
+ .number()
232
+ .optional()
233
+ .default(300)
234
+ .describe(
235
+ "Maximum execution time in seconds (1-86400, default: 300)"
236
+ ),
237
+ },
238
+ async execute(args, context) {
239
+ const { directory, sessionID } = context
240
+ const { slug, intent_summary, inputs = {}, timeout_seconds = 300 } = args
241
+ const messageId = extractMessageId(context)
242
+ const callId = extractCallId(context)
243
+
244
+ if (!messageId) {
245
+ throw new Error("Could not determine message id from tool context.")
246
+ }
247
+
248
+ if (!callId) {
249
+ throw new Error("Could not determine call id from tool context.")
250
+ }
251
+
252
+ // Validate slug format
253
+ if (!SLUG_REGEX.test(slug)) {
254
+ throw new Error(
255
+ `Invalid slug format: "${slug}". Slug must be lowercase, alphanumeric with hyphens (e.g., "my-workflow-name").`
256
+ )
257
+ }
258
+
259
+ // Validate timeout
260
+ if (timeout_seconds < 1 || timeout_seconds > 86400) {
261
+ throw new Error(
262
+ `Invalid timeout: ${timeout_seconds}. Must be between 1 and 86400 seconds.`
263
+ )
264
+ }
265
+
266
+ // Path traversal protection
267
+ const workflowsDir = path.join(directory, "workflows")
268
+ const workflowDir = path.join(workflowsDir, slug)
269
+
270
+ if (!isPathInside(workflowDir, workflowsDir)) {
271
+ throw new Error("Path traversal detected. Invalid slug.")
272
+ }
273
+
274
+ // Check if workflow already exists
275
+ if (await pathExists(workflowDir)) {
276
+ throw new Error(`Workflow "${slug}" already exists at ${workflowDir}`)
277
+ }
278
+
279
+ // Request permission after basic validation and existence checks pass.
280
+ await requestWorkflowPermission(
281
+ sessionID,
282
+ messageId,
283
+ callId
284
+ )
285
+
286
+ // Ensure workflows directory exists
287
+ await fs.mkdir(workflowsDir, { recursive: true })
288
+
289
+ // Create workflow directory
290
+ await fs.mkdir(workflowDir, { recursive: true })
291
+
292
+ // Create workflow.json
293
+ const workflowJson: WorkflowManifest = {
294
+ intent_summary,
295
+ inputs: inputs as Record<string, WorkflowInput>,
296
+ triggers: { manual: true },
297
+ runtime: { timeout_seconds },
298
+ }
299
+ await fs.writeFile(
300
+ path.join(workflowDir, "workflow.json"),
301
+ JSON.stringify(workflowJson, null, 2),
302
+ "utf-8"
303
+ )
304
+
305
+ // Create empty files
306
+ await fs.writeFile(path.join(workflowDir, "run.py"), "", "utf-8")
307
+ await fs.writeFile(path.join(workflowDir, "requirements.txt"), "", "utf-8")
308
+ await fs.writeFile(path.join(workflowDir, "requirements.lock.txt"), "", "utf-8")
309
+ await fs.writeFile(path.join(workflowDir, ".env"), "", "utf-8")
310
+ await fs.writeFile(path.join(workflowDir, ".env.example"), "", "utf-8")
311
+ await fs.writeFile(path.join(workflowDir, "README.md"), "", "utf-8")
312
+
313
+ const creatorResponse = await fetch(
314
+ `${getApiBaseUrl()}/api/workflows/${encodeURIComponent(slug)}/creator`,
315
+ {
316
+ method: "POST",
317
+ headers: {
318
+ "Content-Type": "application/json",
319
+ Authorization: `Bearer ${sessionID}`,
320
+ },
321
+ }
322
+ )
323
+
324
+ if (!creatorResponse.ok) {
325
+ const message = await readErrorMessageFromResponse(
326
+ creatorResponse,
327
+ `Failed to set workflow creator (HTTP ${creatorResponse.status})`
328
+ )
329
+ throw new Error(
330
+ `Workflow "${slug}" was created, but saving creator metadata failed: ${message}`
331
+ )
332
+ }
333
+
334
+ return JSON.stringify({
335
+ success: true,
336
+ message: `Workflow "${slug}" created successfully. Remember: this workflow needs admin approval before it can be executed.`,
337
+ workflow_path: path.relative(directory, workflowDir),
338
+ })
339
+ },
340
+ }),
341
+ },
342
+ }
343
+ }
344
+
345
+ export default CreateWorkflowPlugin