prompt_objects 0.1.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 (117) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +108 -0
  3. data/Gemfile +10 -0
  4. data/Gemfile.lock +231 -0
  5. data/IMPLEMENTATION_PLAN.md +1073 -0
  6. data/LICENSE +21 -0
  7. data/README.md +73 -0
  8. data/Rakefile +27 -0
  9. data/design-doc-v2.md +1232 -0
  10. data/exe/prompt_objects +572 -0
  11. data/exe/prompt_objects_mcp +34 -0
  12. data/frontend/.gitignore +3 -0
  13. data/frontend/index.html +13 -0
  14. data/frontend/package-lock.json +4417 -0
  15. data/frontend/package.json +32 -0
  16. data/frontend/postcss.config.js +6 -0
  17. data/frontend/src/App.tsx +95 -0
  18. data/frontend/src/components/CapabilitiesPanel.tsx +44 -0
  19. data/frontend/src/components/ChatPanel.tsx +251 -0
  20. data/frontend/src/components/Dashboard.tsx +83 -0
  21. data/frontend/src/components/Header.tsx +141 -0
  22. data/frontend/src/components/MarkdownMessage.tsx +153 -0
  23. data/frontend/src/components/MessageBus.tsx +55 -0
  24. data/frontend/src/components/ModelSelector.tsx +112 -0
  25. data/frontend/src/components/NotificationPanel.tsx +134 -0
  26. data/frontend/src/components/POCard.tsx +56 -0
  27. data/frontend/src/components/PODetail.tsx +117 -0
  28. data/frontend/src/components/PromptPanel.tsx +51 -0
  29. data/frontend/src/components/SessionsPanel.tsx +174 -0
  30. data/frontend/src/components/ThreadsSidebar.tsx +119 -0
  31. data/frontend/src/components/index.ts +11 -0
  32. data/frontend/src/hooks/useWebSocket.ts +363 -0
  33. data/frontend/src/index.css +37 -0
  34. data/frontend/src/main.tsx +10 -0
  35. data/frontend/src/store/index.ts +246 -0
  36. data/frontend/src/types/index.ts +146 -0
  37. data/frontend/tailwind.config.js +25 -0
  38. data/frontend/tsconfig.json +30 -0
  39. data/frontend/vite.config.ts +29 -0
  40. data/lib/prompt_objects/capability.rb +46 -0
  41. data/lib/prompt_objects/cli.rb +431 -0
  42. data/lib/prompt_objects/connectors/base.rb +73 -0
  43. data/lib/prompt_objects/connectors/mcp.rb +524 -0
  44. data/lib/prompt_objects/environment/exporter.rb +83 -0
  45. data/lib/prompt_objects/environment/git.rb +118 -0
  46. data/lib/prompt_objects/environment/importer.rb +159 -0
  47. data/lib/prompt_objects/environment/manager.rb +401 -0
  48. data/lib/prompt_objects/environment/manifest.rb +218 -0
  49. data/lib/prompt_objects/environment.rb +283 -0
  50. data/lib/prompt_objects/human_queue.rb +144 -0
  51. data/lib/prompt_objects/llm/anthropic_adapter.rb +137 -0
  52. data/lib/prompt_objects/llm/factory.rb +84 -0
  53. data/lib/prompt_objects/llm/gemini_adapter.rb +209 -0
  54. data/lib/prompt_objects/llm/openai_adapter.rb +104 -0
  55. data/lib/prompt_objects/llm/response.rb +61 -0
  56. data/lib/prompt_objects/loader.rb +32 -0
  57. data/lib/prompt_objects/mcp/server.rb +167 -0
  58. data/lib/prompt_objects/mcp/tools/get_conversation.rb +60 -0
  59. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +54 -0
  60. data/lib/prompt_objects/mcp/tools/inspect_po.rb +73 -0
  61. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +37 -0
  62. data/lib/prompt_objects/mcp/tools/respond_to_request.rb +68 -0
  63. data/lib/prompt_objects/mcp/tools/send_message.rb +71 -0
  64. data/lib/prompt_objects/message_bus.rb +97 -0
  65. data/lib/prompt_objects/primitive.rb +13 -0
  66. data/lib/prompt_objects/primitives/http_get.rb +72 -0
  67. data/lib/prompt_objects/primitives/list_files.rb +95 -0
  68. data/lib/prompt_objects/primitives/read_file.rb +81 -0
  69. data/lib/prompt_objects/primitives/write_file.rb +73 -0
  70. data/lib/prompt_objects/prompt_object.rb +415 -0
  71. data/lib/prompt_objects/registry.rb +88 -0
  72. data/lib/prompt_objects/server/api/routes.rb +297 -0
  73. data/lib/prompt_objects/server/app.rb +174 -0
  74. data/lib/prompt_objects/server/file_watcher.rb +113 -0
  75. data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +77 -0
  76. data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +1 -0
  77. data/lib/prompt_objects/server/public/index.html +14 -0
  78. data/lib/prompt_objects/server/websocket_handler.rb +619 -0
  79. data/lib/prompt_objects/server.rb +166 -0
  80. data/lib/prompt_objects/session/store.rb +826 -0
  81. data/lib/prompt_objects/universal/add_capability.rb +74 -0
  82. data/lib/prompt_objects/universal/add_primitive.rb +113 -0
  83. data/lib/prompt_objects/universal/ask_human.rb +109 -0
  84. data/lib/prompt_objects/universal/create_capability.rb +219 -0
  85. data/lib/prompt_objects/universal/create_primitive.rb +170 -0
  86. data/lib/prompt_objects/universal/list_capabilities.rb +55 -0
  87. data/lib/prompt_objects/universal/list_primitives.rb +145 -0
  88. data/lib/prompt_objects/universal/modify_primitive.rb +180 -0
  89. data/lib/prompt_objects/universal/request_primitive.rb +287 -0
  90. data/lib/prompt_objects/universal/think.rb +41 -0
  91. data/lib/prompt_objects/universal/verify_primitive.rb +173 -0
  92. data/lib/prompt_objects.rb +62 -0
  93. data/objects/coordinator.md +48 -0
  94. data/objects/greeter.md +30 -0
  95. data/objects/reader.md +33 -0
  96. data/prompt_objects.gemspec +50 -0
  97. data/templates/basic/.gitignore +2 -0
  98. data/templates/basic/manifest.yml +7 -0
  99. data/templates/basic/objects/basic.md +32 -0
  100. data/templates/developer/.gitignore +5 -0
  101. data/templates/developer/manifest.yml +17 -0
  102. data/templates/developer/objects/code_reviewer.md +33 -0
  103. data/templates/developer/objects/coordinator.md +39 -0
  104. data/templates/developer/objects/debugger.md +35 -0
  105. data/templates/empty/.gitignore +5 -0
  106. data/templates/empty/manifest.yml +14 -0
  107. data/templates/empty/objects/.gitkeep +0 -0
  108. data/templates/empty/objects/assistant.md +41 -0
  109. data/templates/minimal/.gitignore +5 -0
  110. data/templates/minimal/manifest.yml +7 -0
  111. data/templates/minimal/objects/assistant.md +41 -0
  112. data/templates/writer/.gitignore +5 -0
  113. data/templates/writer/manifest.yml +17 -0
  114. data/templates/writer/objects/coordinator.md +33 -0
  115. data/templates/writer/objects/editor.md +33 -0
  116. data/templates/writer/objects/researcher.md +34 -0
  117. metadata +343 -0
@@ -0,0 +1,524 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "base"
5
+
6
+ module PromptObjects
7
+ module Connectors
8
+ # MCP (Model Context Protocol) connector for exposing environments to
9
+ # Claude Desktop, Cursor, and other MCP clients.
10
+ class MCP < Base
11
+ attr_reader :mcp_server
12
+
13
+ def source_name
14
+ "mcp"
15
+ end
16
+
17
+ def start
18
+ @running = true
19
+ setup_mcp_server
20
+ run_stdio_transport
21
+ end
22
+
23
+ def stop
24
+ @running = false
25
+ # MCP stdio transport doesn't have a clean shutdown, process exit handles it
26
+ end
27
+
28
+ private
29
+
30
+ def setup_mcp_server
31
+ @mcp_server = ::MCP::Server.new(
32
+ name: "prompt_objects",
33
+ version: "0.1.0",
34
+ tools: build_tools,
35
+ server_context: {
36
+ env: @runtime,
37
+ context: @runtime.context(tui_mode: true),
38
+ connector: self
39
+ }
40
+ )
41
+
42
+ setup_resource_handlers
43
+ end
44
+
45
+ def build_tools
46
+ [
47
+ Tools::ListPromptObjects,
48
+ Tools::SendMessage,
49
+ Tools::GetConversation,
50
+ Tools::ListSessions,
51
+ Tools::GetPendingRequests,
52
+ Tools::RespondToRequest,
53
+ Tools::InspectPO
54
+ ]
55
+ end
56
+
57
+ def setup_resource_handlers
58
+ @mcp_server.resources_read_handler do |params|
59
+ handle_resource_read(params)
60
+ end
61
+ end
62
+
63
+ def handle_resource_read(params)
64
+ uri = params[:uri]
65
+
66
+ case uri
67
+ when %r{^po://([^/]+)/conversation$}
68
+ po_name = ::Regexp.last_match(1)
69
+ read_conversation(po_name)
70
+ when %r{^po://([^/]+)/config$}
71
+ po_name = ::Regexp.last_match(1)
72
+ read_config(po_name)
73
+ when %r{^po://([^/]+)/prompt$}
74
+ po_name = ::Regexp.last_match(1)
75
+ read_prompt(po_name)
76
+ when "bus://messages"
77
+ read_bus_messages
78
+ when %r{^sessions://([^/]+)$}
79
+ po_name = ::Regexp.last_match(1)
80
+ read_sessions(po_name)
81
+ when "sessions://all"
82
+ read_all_sessions
83
+ else
84
+ [{ uri: uri, mimeType: "text/plain", text: "Unknown resource: #{uri}" }]
85
+ end
86
+ end
87
+
88
+ def read_conversation(po_name)
89
+ po = @runtime.registry.get(po_name)
90
+ return [{ uri: "po://#{po_name}/conversation", mimeType: "text/plain", text: "PO not found" }] unless po
91
+
92
+ history = po.history.map do |msg|
93
+ { role: msg[:role].to_s, content: msg[:content] }
94
+ end
95
+
96
+ [{
97
+ uri: "po://#{po_name}/conversation",
98
+ mimeType: "application/json",
99
+ text: JSON.pretty_generate(history)
100
+ }]
101
+ end
102
+
103
+ def read_config(po_name)
104
+ po = @runtime.registry.get(po_name)
105
+ return [{ uri: "po://#{po_name}/config", mimeType: "text/plain", text: "PO not found" }] unless po
106
+
107
+ [{
108
+ uri: "po://#{po_name}/config",
109
+ mimeType: "application/json",
110
+ text: JSON.pretty_generate(po.config)
111
+ }]
112
+ end
113
+
114
+ def read_prompt(po_name)
115
+ po = @runtime.registry.get(po_name)
116
+ return [{ uri: "po://#{po_name}/prompt", mimeType: "text/plain", text: "PO not found" }] unless po
117
+
118
+ [{
119
+ uri: "po://#{po_name}/prompt",
120
+ mimeType: "text/markdown",
121
+ text: po.body
122
+ }]
123
+ end
124
+
125
+ def read_bus_messages
126
+ entries = @runtime.bus.recent(50).map do |entry|
127
+ {
128
+ from: entry[:from],
129
+ to: entry[:to],
130
+ message: entry[:message],
131
+ timestamp: entry[:timestamp]&.iso8601
132
+ }
133
+ end
134
+
135
+ [{
136
+ uri: "bus://messages",
137
+ mimeType: "application/json",
138
+ text: JSON.pretty_generate(entries)
139
+ }]
140
+ end
141
+
142
+ def read_sessions(po_name)
143
+ return [{ uri: "sessions://#{po_name}", mimeType: "text/plain", text: "Sessions not available" }] unless @runtime.session_store
144
+
145
+ sessions = @runtime.session_store.list_sessions(po_name: po_name)
146
+ [{
147
+ uri: "sessions://#{po_name}",
148
+ mimeType: "application/json",
149
+ text: JSON.pretty_generate(sessions)
150
+ }]
151
+ end
152
+
153
+ def read_all_sessions
154
+ return [{ uri: "sessions://all", mimeType: "text/plain", text: "Sessions not available" }] unless @runtime.session_store
155
+
156
+ sessions = @runtime.session_store.list_all_sessions
157
+ [{
158
+ uri: "sessions://all",
159
+ mimeType: "application/json",
160
+ text: JSON.pretty_generate(sessions)
161
+ }]
162
+ end
163
+
164
+ def run_stdio_transport
165
+ transport = ::MCP::Server::Transports::StdioTransport.new(@mcp_server)
166
+ transport.open
167
+ end
168
+
169
+ # MCP Tools
170
+ module Tools
171
+ # List all prompt objects in the environment
172
+ class ListPromptObjects < ::MCP::Tool
173
+ tool_name "list_prompt_objects"
174
+ description "List all available prompt objects in the environment"
175
+
176
+ input_schema(
177
+ type: "object",
178
+ properties: {},
179
+ required: []
180
+ )
181
+
182
+ def self.call(server_context:)
183
+ env = server_context[:env]
184
+
185
+ pos = env.registry.prompt_objects.map do |po|
186
+ {
187
+ name: po.name,
188
+ description: po.description,
189
+ state: po.state.to_s,
190
+ capabilities: po.config["capabilities"] || []
191
+ }
192
+ end
193
+
194
+ ::MCP::Tool::Response.new([{
195
+ type: "text",
196
+ text: JSON.generate({ prompt_objects: pos })
197
+ }])
198
+ end
199
+ end
200
+
201
+ # Send a message to a prompt object
202
+ class SendMessage < ::MCP::Tool
203
+ tool_name "send_message"
204
+ description "Send a message to a prompt object. The PO will process it (potentially calling tools) and return a response."
205
+
206
+ input_schema(
207
+ type: "object",
208
+ properties: {
209
+ po_name: {
210
+ type: "string",
211
+ description: "Name of the prompt object to message"
212
+ },
213
+ message: {
214
+ type: "string",
215
+ description: "The message to send"
216
+ },
217
+ session_id: {
218
+ type: "string",
219
+ description: "Optional session ID to continue. If not provided, uses or creates a default session."
220
+ }
221
+ },
222
+ required: %w[po_name message]
223
+ )
224
+
225
+ def self.call(po_name:, message:, session_id: nil, server_context:)
226
+ env = server_context[:env]
227
+ context = server_context[:context]
228
+ connector = server_context[:connector]
229
+
230
+ po = env.registry.get(po_name)
231
+ unless po.is_a?(PromptObjects::PromptObject)
232
+ return ::MCP::Tool::Response.new([{
233
+ type: "text",
234
+ text: JSON.generate({ error: "Prompt object '#{po_name}' not found" })
235
+ }])
236
+ end
237
+
238
+ # Handle session
239
+ current_session_id = setup_session(po, session_id, env, connector)
240
+
241
+ # Log to message bus
242
+ env.bus.publish(from: "mcp_client", to: po_name, message: message)
243
+
244
+ # Set context for this interaction
245
+ context.current_capability = po_name
246
+ po.state = :working
247
+
248
+ begin
249
+ response = po.receive(message, context: context)
250
+ po.state = :idle
251
+
252
+ # Update session source
253
+ connector&.send(:update_session_source, current_session_id) if current_session_id
254
+
255
+ # Log response
256
+ env.bus.publish(from: po_name, to: "mcp_client", message: response)
257
+
258
+ ::MCP::Tool::Response.new([{
259
+ type: "text",
260
+ text: JSON.generate({
261
+ po_name: po_name,
262
+ response: response,
263
+ session_id: current_session_id,
264
+ history_length: po.history.length
265
+ })
266
+ }])
267
+ rescue StandardError => e
268
+ po.state = :idle
269
+ ::MCP::Tool::Response.new([{
270
+ type: "text",
271
+ text: JSON.generate({ error: e.message, backtrace: e.backtrace.first(5) })
272
+ }])
273
+ end
274
+ end
275
+
276
+ def self.setup_session(po, session_id, env, connector)
277
+ return nil unless env.session_store
278
+
279
+ if session_id
280
+ # Switch to specified session
281
+ session = env.session_store.get_session(session_id)
282
+ if session && session[:po_name] == po.name
283
+ po.switch_session(session_id)
284
+ return session_id
285
+ end
286
+ end
287
+
288
+ # Use existing session or create new one
289
+ existing_id = po.instance_variable_get(:@session_id)
290
+ return existing_id if existing_id
291
+
292
+ # Create new MCP session
293
+ new_id = env.session_store.create_session(
294
+ po_name: po.name,
295
+ name: "MCP Session #{Time.now.strftime('%H:%M')}",
296
+ source: connector&.source_name || "mcp"
297
+ )
298
+ po.instance_variable_set(:@session_id, new_id)
299
+ po.send(:reload_history_from_session) if po.respond_to?(:reload_history_from_session, true)
300
+ new_id
301
+ end
302
+ end
303
+
304
+ # Get conversation history for a PO
305
+ class GetConversation < ::MCP::Tool
306
+ tool_name "get_conversation"
307
+ description "Get the conversation history for a prompt object"
308
+
309
+ input_schema(
310
+ type: "object",
311
+ properties: {
312
+ po_name: {
313
+ type: "string",
314
+ description: "Name of the prompt object"
315
+ },
316
+ session_id: {
317
+ type: "string",
318
+ description: "Optional session ID. If not provided, returns current session."
319
+ }
320
+ },
321
+ required: %w[po_name]
322
+ )
323
+
324
+ def self.call(po_name:, session_id: nil, server_context:)
325
+ env = server_context[:env]
326
+
327
+ po = env.registry.get(po_name)
328
+ unless po.is_a?(PromptObjects::PromptObject)
329
+ return ::MCP::Tool::Response.new([{
330
+ type: "text",
331
+ text: JSON.generate({ error: "Prompt object '#{po_name}' not found" })
332
+ }])
333
+ end
334
+
335
+ history = if session_id && env.session_store
336
+ messages = env.session_store.get_messages(session_id)
337
+ messages.map { |m| { role: m[:role], content: m[:content] } }
338
+ else
339
+ po.history.map { |msg| { role: msg[:role].to_s, content: msg[:content] } }
340
+ end
341
+
342
+ ::MCP::Tool::Response.new([{
343
+ type: "text",
344
+ text: JSON.generate({
345
+ po_name: po_name,
346
+ session_id: session_id || po.instance_variable_get(:@session_id),
347
+ history: history
348
+ })
349
+ }])
350
+ end
351
+ end
352
+
353
+ # List sessions for a PO or all sessions
354
+ class ListSessions < ::MCP::Tool
355
+ tool_name "list_sessions"
356
+ description "List sessions. Optionally filter by PO name or source."
357
+
358
+ input_schema(
359
+ type: "object",
360
+ properties: {
361
+ po_name: {
362
+ type: "string",
363
+ description: "Filter by prompt object name (optional)"
364
+ },
365
+ source: {
366
+ type: "string",
367
+ description: "Filter by source: tui, mcp, api, web (optional)"
368
+ }
369
+ },
370
+ required: []
371
+ )
372
+
373
+ def self.call(po_name: nil, source: nil, server_context:)
374
+ env = server_context[:env]
375
+
376
+ unless env.session_store
377
+ return ::MCP::Tool::Response.new([{
378
+ type: "text",
379
+ text: JSON.generate({ error: "Sessions not available in this environment" })
380
+ }])
381
+ end
382
+
383
+ sessions = if po_name
384
+ env.session_store.list_sessions(po_name: po_name)
385
+ else
386
+ env.session_store.list_all_sessions(source: source)
387
+ end
388
+
389
+ sessions_data = sessions.map do |s|
390
+ {
391
+ id: s[:id],
392
+ po_name: s[:po_name],
393
+ name: s[:name],
394
+ source: s[:source],
395
+ message_count: env.session_store.message_count(s[:id]),
396
+ created_at: s[:created_at]&.iso8601,
397
+ updated_at: s[:updated_at]&.iso8601
398
+ }
399
+ end
400
+
401
+ ::MCP::Tool::Response.new([{
402
+ type: "text",
403
+ text: JSON.generate({ sessions: sessions_data })
404
+ }])
405
+ end
406
+ end
407
+
408
+ # Get pending human requests
409
+ class GetPendingRequests < ::MCP::Tool
410
+ tool_name "get_pending_requests"
411
+ description "Get all pending human requests across all prompt objects"
412
+
413
+ input_schema(
414
+ type: "object",
415
+ properties: {},
416
+ required: []
417
+ )
418
+
419
+ def self.call(server_context:)
420
+ env = server_context[:env]
421
+
422
+ requests = env.human_queue.pending.map do |req|
423
+ {
424
+ id: req[:id],
425
+ from: req[:from],
426
+ question: req[:question],
427
+ timestamp: req[:timestamp]&.iso8601
428
+ }
429
+ end
430
+
431
+ ::MCP::Tool::Response.new([{
432
+ type: "text",
433
+ text: JSON.generate({ pending_requests: requests })
434
+ }])
435
+ end
436
+ end
437
+
438
+ # Respond to a human request
439
+ class RespondToRequest < ::MCP::Tool
440
+ tool_name "respond_to_request"
441
+ description "Respond to a pending human request"
442
+
443
+ input_schema(
444
+ type: "object",
445
+ properties: {
446
+ request_id: {
447
+ type: "string",
448
+ description: "ID of the request to respond to"
449
+ },
450
+ response: {
451
+ type: "string",
452
+ description: "The response to provide"
453
+ }
454
+ },
455
+ required: %w[request_id response]
456
+ )
457
+
458
+ def self.call(request_id:, response:, server_context:)
459
+ env = server_context[:env]
460
+
461
+ success = env.human_queue.respond(request_id, response)
462
+
463
+ if success
464
+ ::MCP::Tool::Response.new([{
465
+ type: "text",
466
+ text: JSON.generate({ success: true, request_id: request_id })
467
+ }])
468
+ else
469
+ ::MCP::Tool::Response.new([{
470
+ type: "text",
471
+ text: JSON.generate({ error: "Request not found or already responded", request_id: request_id })
472
+ }])
473
+ end
474
+ end
475
+ end
476
+
477
+ # Inspect a prompt object's details
478
+ class InspectPO < ::MCP::Tool
479
+ tool_name "inspect_po"
480
+ description "Get detailed information about a prompt object including its prompt and capabilities"
481
+
482
+ input_schema(
483
+ type: "object",
484
+ properties: {
485
+ po_name: {
486
+ type: "string",
487
+ description: "Name of the prompt object to inspect"
488
+ }
489
+ },
490
+ required: %w[po_name]
491
+ )
492
+
493
+ def self.call(po_name:, server_context:)
494
+ env = server_context[:env]
495
+
496
+ po = env.registry.get(po_name)
497
+ unless po.is_a?(PromptObjects::PromptObject)
498
+ return ::MCP::Tool::Response.new([{
499
+ type: "text",
500
+ text: JSON.generate({ error: "Prompt object '#{po_name}' not found" })
501
+ }])
502
+ end
503
+
504
+ info = {
505
+ name: po.name,
506
+ description: po.description,
507
+ state: po.state.to_s,
508
+ prompt: po.body,
509
+ capabilities: po.config["capabilities"] || [],
510
+ config: po.config,
511
+ session_id: po.instance_variable_get(:@session_id),
512
+ history_length: po.history.length
513
+ }
514
+
515
+ ::MCP::Tool::Response.new([{
516
+ type: "text",
517
+ text: JSON.generate(info)
518
+ }])
519
+ end
520
+ end
521
+ end
522
+ end
523
+ end
524
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module Env
5
+ # Exports an environment as a git bundle (.poenv file).
6
+ # The bundle contains all commits, objects, primitives, and manifest.
7
+ # Sessions are NOT included (private data).
8
+ class Exporter
9
+ # @param env_path [String] Path to the environment directory
10
+ def initialize(env_path)
11
+ @env_path = env_path
12
+ @name = File.basename(env_path)
13
+ end
14
+
15
+ # Export the environment to a git bundle.
16
+ # @param output_path [String] Path for the output .poenv file
17
+ # @param commit_message [String, nil] Message for any uncommitted changes
18
+ # @return [Hash] Export result with :success, :path, :stats
19
+ def export(output_path, commit_message: nil)
20
+ validate_environment!
21
+
22
+ # Ensure we have a clean state
23
+ commit_changes(commit_message) if Git.dirty?(@env_path)
24
+
25
+ # Create the bundle
26
+ output_path = normalize_output_path(output_path)
27
+ success = Git.bundle(@env_path, output_path)
28
+
29
+ unless success
30
+ return { success: false, error: "Failed to create git bundle" }
31
+ end
32
+
33
+ {
34
+ success: true,
35
+ path: output_path,
36
+ stats: gather_stats
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def validate_environment!
43
+ unless Dir.exist?(@env_path)
44
+ raise Error, "Environment not found: #{@env_path}"
45
+ end
46
+
47
+ unless Git.repo?(@env_path)
48
+ raise Error, "Environment is not a git repository: #{@env_path}"
49
+ end
50
+
51
+ # Must have at least one commit
52
+ if Git.commit_count(@env_path) == 0
53
+ raise Error, "Environment has no commits. Make at least one change first."
54
+ end
55
+ end
56
+
57
+ def commit_changes(message)
58
+ msg = message || "Export preparation"
59
+ Git.commit(@env_path, msg)
60
+ end
61
+
62
+ def normalize_output_path(path)
63
+ # Add .poenv extension if not present
64
+ path = "#{path}.poenv" unless path.end_with?(".poenv")
65
+
66
+ # Expand to absolute path
67
+ File.expand_path(path)
68
+ end
69
+
70
+ def gather_stats
71
+ objects_dir = File.join(@env_path, "objects")
72
+ primitives_dir = File.join(@env_path, "primitives")
73
+
74
+ {
75
+ name: @name,
76
+ commits: Git.commit_count(@env_path),
77
+ objects: Dir.exist?(objects_dir) ? Dir.glob(File.join(objects_dir, "*.md")).count : 0,
78
+ primitives: Dir.exist?(primitives_dir) ? Dir.glob(File.join(primitives_dir, "*.rb")).count : 0
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end