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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptObjects
4
+ module Env
5
+ # Git operations for environments.
6
+ # Each environment is a git repository for built-in versioning.
7
+ module Git
8
+ # Check if a directory is a git repository.
9
+ # @param path [String]
10
+ # @return [Boolean]
11
+ def self.repo?(path)
12
+ Dir.exist?(File.join(path, ".git"))
13
+ end
14
+
15
+ # Initialize a git repository.
16
+ # @param path [String]
17
+ def self.init(path)
18
+ Dir.chdir(path) { system("git init --quiet") }
19
+ end
20
+
21
+ # Stage all changes.
22
+ # @param path [String]
23
+ def self.add_all(path)
24
+ Dir.chdir(path) { system("git add -A") }
25
+ end
26
+
27
+ # Create a commit.
28
+ # @param path [String]
29
+ # @param message [String]
30
+ # @return [Boolean] Success
31
+ def self.commit(path, message)
32
+ Dir.chdir(path) do
33
+ system("git add -A")
34
+ system("git", "commit", "--quiet", "-m", message)
35
+ end
36
+ end
37
+
38
+ # Get list of uncommitted changes.
39
+ # @param path [String]
40
+ # @return [Array<String>] Changed file paths
41
+ def self.uncommitted_changes(path)
42
+ Dir.chdir(path) do
43
+ status = `git status --porcelain 2>/dev/null`
44
+ status.lines.map { |line| line[3..].strip }
45
+ end
46
+ end
47
+
48
+ # Check if there are uncommitted changes.
49
+ # @param path [String]
50
+ # @return [Boolean]
51
+ def self.dirty?(path)
52
+ uncommitted_changes(path).any?
53
+ end
54
+
55
+ # Get current commit hash (short).
56
+ # @param path [String]
57
+ # @return [String, nil]
58
+ def self.current_commit(path)
59
+ Dir.chdir(path) do
60
+ result = `git rev-parse --short HEAD 2>/dev/null`.strip
61
+ result.empty? ? nil : result
62
+ end
63
+ end
64
+
65
+ # Get commit count.
66
+ # @param path [String]
67
+ # @return [Integer]
68
+ def self.commit_count(path)
69
+ Dir.chdir(path) do
70
+ `git rev-list --count HEAD 2>/dev/null`.strip.to_i
71
+ end
72
+ end
73
+
74
+ # Get recent commits.
75
+ # @param path [String]
76
+ # @param limit [Integer]
77
+ # @return [Array<Hash>] Array of {hash:, message:, date:}
78
+ def self.recent_commits(path, limit: 10)
79
+ Dir.chdir(path) do
80
+ log = `git log --oneline --format="%h|%s|%ci" -n #{limit} 2>/dev/null`
81
+ log.lines.map do |line|
82
+ hash, message, date = line.strip.split("|", 3)
83
+ { hash: hash, message: message, date: date }
84
+ end
85
+ end
86
+ end
87
+
88
+ # Create a git bundle for export.
89
+ # @param path [String]
90
+ # @param output [String] Output file path
91
+ # @return [Boolean] Success
92
+ def self.bundle(path, output)
93
+ Dir.chdir(path) do
94
+ system("git bundle create #{output} --all 2>/dev/null")
95
+ end
96
+ end
97
+
98
+ # Clone from a git bundle.
99
+ # @param bundle_path [String]
100
+ # @param dest_path [String]
101
+ # @return [Boolean] Success
102
+ def self.clone_bundle(bundle_path, dest_path)
103
+ system("git clone --quiet #{bundle_path} #{dest_path} 2>/dev/null")
104
+ end
105
+
106
+ # Auto-commit if there are changes (used for saving PO modifications).
107
+ # @param path [String]
108
+ # @param message [String]
109
+ # @return [Boolean] True if committed, false if no changes
110
+ def self.auto_commit(path, message = "Auto-save")
111
+ return false unless dirty?(path)
112
+
113
+ commit(path, message)
114
+ true
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module PromptObjects
7
+ module Env
8
+ # Imports an environment from a git bundle (.poenv file).
9
+ # Handles security concerns around custom primitives.
10
+ class Importer
11
+ # Result of inspecting a bundle before import
12
+ InspectResult = Struct.new(
13
+ :valid,
14
+ :name,
15
+ :description,
16
+ :objects,
17
+ :primitives,
18
+ :commits,
19
+ :error,
20
+ keyword_init: true
21
+ )
22
+
23
+ # @param bundle_path [String] Path to the .poenv bundle file
24
+ def initialize(bundle_path)
25
+ @bundle_path = File.expand_path(bundle_path)
26
+ end
27
+
28
+ # Inspect the bundle without importing it.
29
+ # Use this to show the user what's in the bundle before importing.
30
+ # @return [InspectResult]
31
+ def inspect_bundle
32
+ unless File.exist?(@bundle_path)
33
+ return InspectResult.new(valid: false, error: "Bundle file not found: #{@bundle_path}")
34
+ end
35
+
36
+ unless valid_bundle?
37
+ return InspectResult.new(valid: false, error: "Invalid git bundle file")
38
+ end
39
+
40
+ # Clone to temp dir to inspect contents
41
+ Dir.mktmpdir("poenv_inspect_") do |temp_dir|
42
+ unless Git.clone_bundle(@bundle_path, temp_dir)
43
+ return InspectResult.new(valid: false, error: "Failed to extract bundle")
44
+ end
45
+
46
+ gather_inspect_result(temp_dir)
47
+ end
48
+ end
49
+
50
+ # Import the bundle as a new environment.
51
+ # @param manager [Manager] Environment manager
52
+ # @param name [String] Name for the new environment
53
+ # @param trust_primitives [Boolean] Trust custom primitives (skip sandboxing)
54
+ # @return [Hash] Import result with :success, :path, :warnings
55
+ def import(manager:, name:, trust_primitives: false)
56
+ inspect_result = inspect_bundle
57
+ unless inspect_result.valid
58
+ return { success: false, error: inspect_result.error }
59
+ end
60
+
61
+ # Check if environment already exists
62
+ if manager.environment_exists?(name)
63
+ return { success: false, error: "Environment '#{name}' already exists" }
64
+ end
65
+
66
+ # Clone bundle to environment location
67
+ env_path = manager.environment_path(name)
68
+ unless Git.clone_bundle(@bundle_path, env_path)
69
+ return { success: false, error: "Failed to clone bundle" }
70
+ end
71
+
72
+ # Update manifest with new name if different
73
+ update_manifest(env_path, name)
74
+
75
+ # Build result with warnings about primitives
76
+ warnings = []
77
+ if inspect_result.primitives.any? && !trust_primitives
78
+ warnings << "This environment contains #{inspect_result.primitives.count} custom primitive(s):"
79
+ inspect_result.primitives.each do |prim|
80
+ warnings << " - #{prim}"
81
+ end
82
+ warnings << "Custom primitives will be sandboxed by default."
83
+ warnings << "Review the code before trusting: #{File.join(env_path, 'primitives')}"
84
+ end
85
+
86
+ {
87
+ success: true,
88
+ path: env_path,
89
+ name: name,
90
+ objects: inspect_result.objects,
91
+ primitives: inspect_result.primitives,
92
+ warnings: warnings
93
+ }
94
+ end
95
+
96
+ # Check if a bundle contains custom primitives.
97
+ # @return [Boolean]
98
+ def has_primitives?
99
+ result = inspect_bundle
100
+ result.valid && result.primitives.any?
101
+ end
102
+
103
+ private
104
+
105
+ def valid_bundle?
106
+ # Git bundle verify returns 0 if valid
107
+ system("git bundle verify #{@bundle_path} >/dev/null 2>&1")
108
+ end
109
+
110
+ def gather_inspect_result(temp_dir)
111
+ objects_dir = File.join(temp_dir, "objects")
112
+ primitives_dir = File.join(temp_dir, "primitives")
113
+ manifest_path = File.join(temp_dir, "manifest.yml")
114
+
115
+ # Get manifest info
116
+ manifest_name = nil
117
+ manifest_desc = nil
118
+ if File.exist?(manifest_path)
119
+ manifest = YAML.safe_load(File.read(manifest_path))
120
+ manifest_name = manifest["name"]
121
+ manifest_desc = manifest["description"]
122
+ end
123
+
124
+ # List objects and primitives
125
+ objects = Dir.exist?(objects_dir) ? Dir.glob(File.join(objects_dir, "*.md")).map { |f| File.basename(f, ".md") } : []
126
+ primitives = Dir.exist?(primitives_dir) ? Dir.glob(File.join(primitives_dir, "*.rb")).map { |f| File.basename(f, ".rb") } : []
127
+
128
+ InspectResult.new(
129
+ valid: true,
130
+ name: manifest_name || File.basename(@bundle_path, ".poenv"),
131
+ description: manifest_desc,
132
+ objects: objects,
133
+ primitives: primitives,
134
+ commits: Git.commit_count(temp_dir)
135
+ )
136
+ end
137
+
138
+ def update_manifest(env_path, new_name)
139
+ manifest_path = File.join(env_path, "manifest.yml")
140
+ return unless File.exist?(manifest_path)
141
+
142
+ manifest = YAML.safe_load(File.read(manifest_path))
143
+ original_name = manifest["name"]
144
+
145
+ # Only update if name changed
146
+ return if original_name == new_name
147
+
148
+ manifest["name"] = new_name
149
+ manifest["imported_from"] = original_name
150
+ manifest["imported_at"] = Time.now.utc.iso8601
151
+
152
+ File.write(manifest_path, manifest.to_yaml)
153
+
154
+ # Commit the manifest change
155
+ Git.commit(env_path, "Imported as '#{new_name}' (from '#{original_name}')")
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,401 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module PromptObjects
7
+ module Env
8
+ # Manages multiple environments in the user's data directory.
9
+ # Handles creation, listing, opening, archiving environments.
10
+ class Manager
11
+ DEFAULT_BASE_DIR = File.expand_path("~/.prompt_objects")
12
+ ENVIRONMENTS_DIR = "environments"
13
+ ARCHIVE_DIR = "archive"
14
+ CONFIG_FILE = "config.yml"
15
+ DEV_ENV_NAME = "_development"
16
+
17
+ attr_reader :base_dir
18
+
19
+ def initialize(base_dir: nil)
20
+ @base_dir = base_dir || ENV.fetch("PROMPT_OBJECTS_HOME", DEFAULT_BASE_DIR)
21
+ end
22
+
23
+ # Ensure base directory structure exists.
24
+ def setup!
25
+ FileUtils.mkdir_p(environments_dir)
26
+ FileUtils.mkdir_p(archive_dir)
27
+ ensure_global_config
28
+ end
29
+
30
+ # Path to environments directory.
31
+ # @return [String]
32
+ def environments_dir
33
+ File.join(@base_dir, ENVIRONMENTS_DIR)
34
+ end
35
+
36
+ # Path to archive directory.
37
+ # @return [String]
38
+ def archive_dir
39
+ File.join(@base_dir, ARCHIVE_DIR)
40
+ end
41
+
42
+ # Path to global config file.
43
+ # @return [String]
44
+ def config_path
45
+ File.join(@base_dir, CONFIG_FILE)
46
+ end
47
+
48
+ # Load global config.
49
+ # @return [Hash]
50
+ def global_config
51
+ return {} unless File.exist?(config_path)
52
+
53
+ YAML.safe_load(File.read(config_path)) || {}
54
+ end
55
+
56
+ # Save global config.
57
+ # @param config [Hash]
58
+ def save_global_config(config)
59
+ File.write(config_path, config.to_yaml)
60
+ end
61
+
62
+ # Check if any environments exist.
63
+ # @return [Boolean]
64
+ def any_environments?
65
+ list.any?
66
+ end
67
+
68
+ # Check if first-run wizard should be shown.
69
+ # @return [Boolean]
70
+ def first_run?
71
+ !any_environments?
72
+ end
73
+
74
+ # List all environment names.
75
+ # @return [Array<String>]
76
+ def list
77
+ return [] unless Dir.exist?(environments_dir)
78
+
79
+ Dir.children(environments_dir)
80
+ .select { |name| environment_exists?(name) }
81
+ .reject { |name| name.start_with?("_") } # Hide dev environments
82
+ .sort
83
+ end
84
+
85
+ # List all environments with their manifests.
86
+ # @return [Array<Manifest>]
87
+ def list_with_manifests
88
+ list.map { |name| manifest_for(name) }.compact
89
+ end
90
+
91
+ # Check if an environment exists.
92
+ # @param name [String]
93
+ # @return [Boolean]
94
+ def environment_exists?(name)
95
+ path = environment_path(name)
96
+ Dir.exist?(path) && File.exist?(File.join(path, Env::Manifest::FILENAME))
97
+ end
98
+
99
+ # Get path to an environment.
100
+ # @param name [String]
101
+ # @return [String]
102
+ def environment_path(name)
103
+ File.join(environments_dir, name)
104
+ end
105
+
106
+ # Get manifest for an environment.
107
+ # @param name [String]
108
+ # @return [Manifest, nil]
109
+ def manifest_for(name)
110
+ return nil unless environment_exists?(name)
111
+
112
+ Manifest.load_from_dir(environment_path(name))
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
117
+ # Create a new environment from a template.
118
+ # @param name [String] Environment name
119
+ # @param template [String, nil] Template name (from templates/)
120
+ # @param description [String, nil] Environment description
121
+ # @return [String] Path to created environment
122
+ def create(name:, template: nil, description: nil)
123
+ raise Error, "Environment '#{name}' already exists" if environment_exists?(name)
124
+ raise Error, "Invalid environment name: #{name}" unless valid_name?(name)
125
+
126
+ env_path = environment_path(name)
127
+ FileUtils.mkdir_p(env_path)
128
+ FileUtils.mkdir_p(File.join(env_path, "objects"))
129
+ FileUtils.mkdir_p(File.join(env_path, "primitives"))
130
+
131
+ # Copy template if specified
132
+ template_manifest = copy_template(env_path, template) if template
133
+
134
+ # Create manifest
135
+ manifest = Manifest.new(
136
+ name: name,
137
+ description: description || template_manifest&.dig("description"),
138
+ icon: template_manifest&.dig("icon") || "📦",
139
+ color: template_manifest&.dig("color") || "#4A90D9"
140
+ )
141
+ manifest.save_to_dir(env_path)
142
+
143
+ # Create .gitignore
144
+ create_gitignore(env_path)
145
+
146
+ # Initialize git repo
147
+ init_git(env_path)
148
+
149
+ env_path
150
+ end
151
+
152
+ # Create the development environment (for --dev flag).
153
+ # Uses minimal template to ensure at least one PO exists for bootstrapping.
154
+ # @return [String] Path to dev environment
155
+ def create_dev_environment
156
+ return environment_path(DEV_ENV_NAME) if environment_exists?(DEV_ENV_NAME)
157
+
158
+ env_path = environment_path(DEV_ENV_NAME)
159
+ FileUtils.mkdir_p(env_path)
160
+ FileUtils.mkdir_p(File.join(env_path, "objects"))
161
+ FileUtils.mkdir_p(File.join(env_path, "primitives"))
162
+
163
+ # Copy minimal template objects for bootstrapping
164
+ copy_template(env_path, "minimal")
165
+
166
+ manifest = Manifest.new(
167
+ name: DEV_ENV_NAME,
168
+ description: "Development environment",
169
+ icon: "🔧"
170
+ )
171
+ manifest.save_to_dir(env_path)
172
+
173
+ create_gitignore(env_path)
174
+ init_git(env_path)
175
+
176
+ env_path
177
+ end
178
+
179
+ # Get path to development environment.
180
+ # @return [String]
181
+ def dev_environment_path
182
+ create_dev_environment
183
+ end
184
+
185
+ # Archive (soft delete) an environment.
186
+ # @param name [String]
187
+ # @return [String] Path to archived environment
188
+ def archive(name)
189
+ raise Error, "Environment '#{name}' not found" unless environment_exists?(name)
190
+ raise Error, "Cannot archive development environment" if name == DEV_ENV_NAME
191
+
192
+ src = environment_path(name)
193
+
194
+ # Mark as archived in manifest before moving
195
+ manifest = Manifest.load_from_dir(src)
196
+ manifest.mark_archived!
197
+ manifest.save_to_dir(src)
198
+
199
+ # Commit the archive marker
200
+ Git.commit(src, "Archived environment")
201
+
202
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
203
+ dest = File.join(archive_dir, "#{name}_#{timestamp}")
204
+
205
+ FileUtils.mv(src, dest)
206
+ dest
207
+ end
208
+
209
+ # List archived environments.
210
+ # @return [Array<String>]
211
+ def list_archived
212
+ return [] unless Dir.exist?(archive_dir)
213
+
214
+ Dir.children(archive_dir).sort
215
+ end
216
+
217
+ # Restore an archived environment.
218
+ # @param archived_name [String] Name with timestamp suffix
219
+ # @param restore_as [String, nil] New name (defaults to original name)
220
+ # @return [String] Path to restored environment
221
+ def restore(archived_name, restore_as: nil)
222
+ src = File.join(archive_dir, archived_name)
223
+ raise Error, "Archived environment not found: #{archived_name}" unless Dir.exist?(src)
224
+
225
+ # Extract original name (remove timestamp suffix)
226
+ original_name = archived_name.sub(/_\d{8}_\d{6}$/, "")
227
+ new_name = restore_as || original_name
228
+
229
+ raise Error, "Environment '#{new_name}' already exists" if environment_exists?(new_name)
230
+
231
+ dest = environment_path(new_name)
232
+ FileUtils.mv(src, dest)
233
+
234
+ # Update manifest: clear archived_at, update name if needed
235
+ manifest = Manifest.load_from_dir(dest)
236
+ manifest.archived_at = nil
237
+ manifest.name = new_name if new_name != original_name
238
+ manifest.save_to_dir(dest)
239
+
240
+ # Commit the restore
241
+ Git.commit(dest, "Restored environment#{new_name != original_name ? " as '#{new_name}'" : ""}")
242
+
243
+ dest
244
+ end
245
+
246
+ # Permanently delete an archived environment.
247
+ # @param archived_name [String]
248
+ def delete_archived(archived_name)
249
+ path = File.join(archive_dir, archived_name)
250
+ raise Error, "Archived environment not found: #{archived_name}" unless Dir.exist?(path)
251
+
252
+ FileUtils.rm_rf(path)
253
+ end
254
+
255
+ # Clone an environment.
256
+ # @param source_name [String]
257
+ # @param new_name [String]
258
+ # @return [String] Path to cloned environment
259
+ def clone(source_name, new_name)
260
+ raise Error, "Source environment '#{source_name}' not found" unless environment_exists?(source_name)
261
+ raise Error, "Environment '#{new_name}' already exists" if environment_exists?(new_name)
262
+ raise Error, "Invalid environment name: #{new_name}" unless valid_name?(new_name)
263
+
264
+ src = environment_path(source_name)
265
+ dest = environment_path(new_name)
266
+
267
+ # Copy directory (excluding sessions.db)
268
+ FileUtils.mkdir_p(dest)
269
+ Dir.glob(File.join(src, "**", "*"), File::FNM_DOTMATCH).each do |path|
270
+ relative = path.sub("#{src}/", "")
271
+ next if relative == "." || relative == ".."
272
+ next if relative.include?("sessions.db")
273
+ next if relative.start_with?(".git/")
274
+
275
+ target = File.join(dest, relative)
276
+ if File.directory?(path)
277
+ FileUtils.mkdir_p(target)
278
+ else
279
+ FileUtils.cp(path, target)
280
+ end
281
+ end
282
+
283
+ # Update manifest
284
+ manifest = Manifest.load_from_dir(dest)
285
+ manifest.name = new_name
286
+ manifest.instance_variable_set(:@created_at, Time.now)
287
+ manifest.instance_variable_set(:@stats, { "total_messages" => 0, "total_sessions" => 0, "po_count" => manifest.stats["po_count"] })
288
+ manifest.save_to_dir(dest)
289
+
290
+ # Initialize fresh git repo
291
+ init_git(dest)
292
+
293
+ dest
294
+ end
295
+
296
+ # Get default environment name from config.
297
+ # @return [String, nil]
298
+ def default_environment
299
+ global_config["default_environment"]
300
+ end
301
+
302
+ # Set default environment.
303
+ # @param name [String]
304
+ def set_default_environment(name)
305
+ raise Error, "Environment '#{name}' not found" unless environment_exists?(name)
306
+
307
+ config = global_config
308
+ config["default_environment"] = name
309
+ save_global_config(config)
310
+ end
311
+
312
+ private
313
+
314
+ # Validate environment name.
315
+ # @param name [String]
316
+ # @return [Boolean]
317
+ def valid_name?(name)
318
+ return false if name.nil? || name.empty?
319
+ return false if name.start_with?("_") # Reserved for system envs
320
+ return false unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
321
+
322
+ true
323
+ end
324
+
325
+ # Copy template files to environment.
326
+ # @param env_path [String]
327
+ # @param template_name [String]
328
+ # @return [Hash, nil] Template manifest data
329
+ def copy_template(env_path, template_name)
330
+ template_path = find_template(template_name)
331
+ raise Error, "Template '#{template_name}' not found" unless template_path
332
+
333
+ # Read template manifest
334
+ template_manifest_path = File.join(template_path, "manifest.yml")
335
+ template_manifest = if File.exist?(template_manifest_path)
336
+ YAML.safe_load(File.read(template_manifest_path))
337
+ end
338
+
339
+ # Copy objects
340
+ template_objects = File.join(template_path, "objects")
341
+ if Dir.exist?(template_objects)
342
+ FileUtils.cp_r(Dir.glob("#{template_objects}/*"), File.join(env_path, "objects"))
343
+ end
344
+
345
+ # Copy primitives if present
346
+ template_primitives = File.join(template_path, "primitives")
347
+ if Dir.exist?(template_primitives)
348
+ FileUtils.cp_r(Dir.glob("#{template_primitives}/*"), File.join(env_path, "primitives"))
349
+ end
350
+
351
+ template_manifest
352
+ end
353
+
354
+ # Find template directory.
355
+ # @param name [String]
356
+ # @return [String, nil]
357
+ def find_template(name)
358
+ # Look in gem's templates directory
359
+ gem_templates = File.expand_path("../../../../templates", __FILE__)
360
+ path = File.join(gem_templates, name)
361
+ return path if Dir.exist?(path)
362
+
363
+ nil
364
+ end
365
+
366
+ # Create .gitignore for environment.
367
+ # @param env_path [String]
368
+ def create_gitignore(env_path)
369
+ gitignore_content = <<~GITIGNORE
370
+ # Session data (private)
371
+ sessions.db
372
+ sessions.db-journal
373
+ sessions.db-wal
374
+ sessions.db-shm
375
+ GITIGNORE
376
+
377
+ File.write(File.join(env_path, ".gitignore"), gitignore_content)
378
+ end
379
+
380
+ # Initialize git repository.
381
+ # @param env_path [String]
382
+ def init_git(env_path)
383
+ Dir.chdir(env_path) do
384
+ system("git init --quiet")
385
+ system("git add .")
386
+ system("git commit --quiet -m 'Initial environment setup'")
387
+ end
388
+ end
389
+
390
+ # Ensure global config file exists.
391
+ def ensure_global_config
392
+ return if File.exist?(config_path)
393
+
394
+ save_global_config({
395
+ "default_environment" => nil,
396
+ "trusted_primitives" => []
397
+ })
398
+ end
399
+ end
400
+ end
401
+ end