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.
- checksums.yaml +7 -0
- data/CLAUDE.md +108 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +231 -0
- data/IMPLEMENTATION_PLAN.md +1073 -0
- data/LICENSE +21 -0
- data/README.md +73 -0
- data/Rakefile +27 -0
- data/design-doc-v2.md +1232 -0
- data/exe/prompt_objects +572 -0
- data/exe/prompt_objects_mcp +34 -0
- data/frontend/.gitignore +3 -0
- data/frontend/index.html +13 -0
- data/frontend/package-lock.json +4417 -0
- data/frontend/package.json +32 -0
- data/frontend/postcss.config.js +6 -0
- data/frontend/src/App.tsx +95 -0
- data/frontend/src/components/CapabilitiesPanel.tsx +44 -0
- data/frontend/src/components/ChatPanel.tsx +251 -0
- data/frontend/src/components/Dashboard.tsx +83 -0
- data/frontend/src/components/Header.tsx +141 -0
- data/frontend/src/components/MarkdownMessage.tsx +153 -0
- data/frontend/src/components/MessageBus.tsx +55 -0
- data/frontend/src/components/ModelSelector.tsx +112 -0
- data/frontend/src/components/NotificationPanel.tsx +134 -0
- data/frontend/src/components/POCard.tsx +56 -0
- data/frontend/src/components/PODetail.tsx +117 -0
- data/frontend/src/components/PromptPanel.tsx +51 -0
- data/frontend/src/components/SessionsPanel.tsx +174 -0
- data/frontend/src/components/ThreadsSidebar.tsx +119 -0
- data/frontend/src/components/index.ts +11 -0
- data/frontend/src/hooks/useWebSocket.ts +363 -0
- data/frontend/src/index.css +37 -0
- data/frontend/src/main.tsx +10 -0
- data/frontend/src/store/index.ts +246 -0
- data/frontend/src/types/index.ts +146 -0
- data/frontend/tailwind.config.js +25 -0
- data/frontend/tsconfig.json +30 -0
- data/frontend/vite.config.ts +29 -0
- data/lib/prompt_objects/capability.rb +46 -0
- data/lib/prompt_objects/cli.rb +431 -0
- data/lib/prompt_objects/connectors/base.rb +73 -0
- data/lib/prompt_objects/connectors/mcp.rb +524 -0
- data/lib/prompt_objects/environment/exporter.rb +83 -0
- data/lib/prompt_objects/environment/git.rb +118 -0
- data/lib/prompt_objects/environment/importer.rb +159 -0
- data/lib/prompt_objects/environment/manager.rb +401 -0
- data/lib/prompt_objects/environment/manifest.rb +218 -0
- data/lib/prompt_objects/environment.rb +283 -0
- data/lib/prompt_objects/human_queue.rb +144 -0
- data/lib/prompt_objects/llm/anthropic_adapter.rb +137 -0
- data/lib/prompt_objects/llm/factory.rb +84 -0
- data/lib/prompt_objects/llm/gemini_adapter.rb +209 -0
- data/lib/prompt_objects/llm/openai_adapter.rb +104 -0
- data/lib/prompt_objects/llm/response.rb +61 -0
- data/lib/prompt_objects/loader.rb +32 -0
- data/lib/prompt_objects/mcp/server.rb +167 -0
- data/lib/prompt_objects/mcp/tools/get_conversation.rb +60 -0
- data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +54 -0
- data/lib/prompt_objects/mcp/tools/inspect_po.rb +73 -0
- data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +37 -0
- data/lib/prompt_objects/mcp/tools/respond_to_request.rb +68 -0
- data/lib/prompt_objects/mcp/tools/send_message.rb +71 -0
- data/lib/prompt_objects/message_bus.rb +97 -0
- data/lib/prompt_objects/primitive.rb +13 -0
- data/lib/prompt_objects/primitives/http_get.rb +72 -0
- data/lib/prompt_objects/primitives/list_files.rb +95 -0
- data/lib/prompt_objects/primitives/read_file.rb +81 -0
- data/lib/prompt_objects/primitives/write_file.rb +73 -0
- data/lib/prompt_objects/prompt_object.rb +415 -0
- data/lib/prompt_objects/registry.rb +88 -0
- data/lib/prompt_objects/server/api/routes.rb +297 -0
- data/lib/prompt_objects/server/app.rb +174 -0
- data/lib/prompt_objects/server/file_watcher.rb +113 -0
- data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +77 -0
- data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +1 -0
- data/lib/prompt_objects/server/public/index.html +14 -0
- data/lib/prompt_objects/server/websocket_handler.rb +619 -0
- data/lib/prompt_objects/server.rb +166 -0
- data/lib/prompt_objects/session/store.rb +826 -0
- data/lib/prompt_objects/universal/add_capability.rb +74 -0
- data/lib/prompt_objects/universal/add_primitive.rb +113 -0
- data/lib/prompt_objects/universal/ask_human.rb +109 -0
- data/lib/prompt_objects/universal/create_capability.rb +219 -0
- data/lib/prompt_objects/universal/create_primitive.rb +170 -0
- data/lib/prompt_objects/universal/list_capabilities.rb +55 -0
- data/lib/prompt_objects/universal/list_primitives.rb +145 -0
- data/lib/prompt_objects/universal/modify_primitive.rb +180 -0
- data/lib/prompt_objects/universal/request_primitive.rb +287 -0
- data/lib/prompt_objects/universal/think.rb +41 -0
- data/lib/prompt_objects/universal/verify_primitive.rb +173 -0
- data/lib/prompt_objects.rb +62 -0
- data/objects/coordinator.md +48 -0
- data/objects/greeter.md +30 -0
- data/objects/reader.md +33 -0
- data/prompt_objects.gemspec +50 -0
- data/templates/basic/.gitignore +2 -0
- data/templates/basic/manifest.yml +7 -0
- data/templates/basic/objects/basic.md +32 -0
- data/templates/developer/.gitignore +5 -0
- data/templates/developer/manifest.yml +17 -0
- data/templates/developer/objects/code_reviewer.md +33 -0
- data/templates/developer/objects/coordinator.md +39 -0
- data/templates/developer/objects/debugger.md +35 -0
- data/templates/empty/.gitignore +5 -0
- data/templates/empty/manifest.yml +14 -0
- data/templates/empty/objects/.gitkeep +0 -0
- data/templates/empty/objects/assistant.md +41 -0
- data/templates/minimal/.gitignore +5 -0
- data/templates/minimal/manifest.yml +7 -0
- data/templates/minimal/objects/assistant.md +41 -0
- data/templates/writer/.gitignore +5 -0
- data/templates/writer/manifest.yml +17 -0
- data/templates/writer/objects/coordinator.md +33 -0
- data/templates/writer/objects/editor.md +33 -0
- data/templates/writer/objects/researcher.md +34 -0
- 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
|