little_ghost 0.7.0 → 0.8.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 +4 -4
- data/README.md +33 -5
- data/docs/guides/code_mode.md +2 -2
- data/docs/guides/getting_started.md +26 -1
- data/docs/guides/integrations.md +20 -161
- data/docs/guides/mcp.md +288 -0
- data/docs/guides/models_and_providers.md +21 -54
- data/docs/guides/production.md +14 -5
- data/docs/guides/providers.md +275 -0
- data/docs/guides/sandboxing.md +42 -7
- data/docs/guides/skills.md +1 -3
- data/docs/guides/tools.md +2 -0
- data/exe/little_ghost +12 -0
- data/lib/little_ghost/assembly.rb +11 -3
- data/lib/little_ghost/assembly_builder.rb +2 -2
- data/lib/little_ghost/cli.rb +138 -0
- data/lib/little_ghost/console.rb +17 -0
- data/lib/little_ghost/errors.rb +1 -1
- data/lib/little_ghost/generators/application_generator.rb +179 -0
- data/lib/little_ghost/generators/templates/application/Gemfile.tt +3 -0
- data/lib/little_ghost/generators/templates/application/README.md.tt +29 -0
- data/lib/little_ghost/generators/templates/application/app/agents/application_agent.rb.tt +5 -0
- data/lib/little_ghost/generators/templates/application/app/prompts/application/system.erb.tt +1 -0
- data/lib/little_ghost/generators/templates/application/bin/application.tt +10 -0
- data/lib/little_ghost/generators/templates/application/config/little_ghost.rb.tt +5 -0
- data/lib/little_ghost/generators/templates/application/gitignore.tt +2 -0
- data/lib/little_ghost/generators/templates/application/keep.tt +0 -0
- data/lib/little_ghost/mcp/client.rb +295 -606
- data/lib/little_ghost/mcp/toolset.rb +133 -95
- data/lib/little_ghost/mcp.rb +21 -3
- data/lib/little_ghost/model_resolver.rb +2 -0
- data/lib/little_ghost/models/catalog/models_dev_source.rb +4 -1
- data/lib/little_ghost/provider_registry.rb +1 -0
- data/lib/little_ghost/providers/lm_studio/catalog_source.rb +201 -0
- data/lib/little_ghost/providers/lm_studio.rb +52 -0
- data/lib/little_ghost/providers/openai.rb +3 -75
- data/lib/little_ghost/providers/openai_compatible.rb +78 -1
- data/lib/little_ghost/sandbox/environment_policy.rb +29 -4
- data/lib/little_ghost/sandbox/policy.rb +28 -6
- data/lib/little_ghost/sandboxes/unrestricted.rb +7 -3
- data/lib/little_ghost/skills/catalog.rb +13 -3
- data/lib/little_ghost/tool.rb +6 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost.rb +1 -0
- metadata +37 -7
- data/lib/little_ghost/mcp/types.rb +0 -216
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "version"
|
|
4
|
+
require_relative "generators/application_generator"
|
|
5
|
+
require "bundler"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module LittleGhost
|
|
9
|
+
class BundleInstaller # :nodoc:
|
|
10
|
+
def initialize(stdout:, stderr:, executable: nil, process_runner: nil)
|
|
11
|
+
@stdout = stdout
|
|
12
|
+
@stderr = stderr
|
|
13
|
+
@executable = executable || -> { Gem.bin_path("bundler", "bundle") }
|
|
14
|
+
@process_runner = process_runner || method(:run_bundle)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def install(root)
|
|
18
|
+
executable = @executable.call
|
|
19
|
+
environment = {"BUNDLE_GEMFILE" => File.join(root, "Gemfile")}
|
|
20
|
+
if (bundle_path = ENV["BUNDLE_PATH"])
|
|
21
|
+
environment["BUNDLE_PATH"] = Pathname.new(bundle_path).absolute? ? bundle_path : File.expand_path(bundle_path, root)
|
|
22
|
+
end
|
|
23
|
+
status = @process_runner.call(environment, executable, root)
|
|
24
|
+
status.success?
|
|
25
|
+
rescue Gem::Exception, SystemCallError => error
|
|
26
|
+
@stderr.puts("Could not run bundle install: #{error.message}")
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run_bundle(environment, executable, root)
|
|
33
|
+
Bundler.with_unbundled_env do
|
|
34
|
+
process = Process.spawn(environment, executable, "install", chdir: root, out: @stdout, err: @stderr)
|
|
35
|
+
Process.wait2(process).last
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Command-line entrypoint for generating standalone LittleGhost applications.
|
|
41
|
+
class CLI # :nodoc:
|
|
42
|
+
USAGE = <<~TEXT
|
|
43
|
+
Usage:
|
|
44
|
+
little_ghost new APP_NAME
|
|
45
|
+
little_ghost console
|
|
46
|
+
little_ghost --version
|
|
47
|
+
little_ghost --help
|
|
48
|
+
TEXT
|
|
49
|
+
|
|
50
|
+
def initialize(
|
|
51
|
+
arguments,
|
|
52
|
+
stdout: $stdout,
|
|
53
|
+
stderr: $stderr,
|
|
54
|
+
current_directory: Dir.pwd,
|
|
55
|
+
generator_class: Generators::ApplicationGenerator,
|
|
56
|
+
bundle_installer: nil,
|
|
57
|
+
console_runner: nil,
|
|
58
|
+
source_path: nil
|
|
59
|
+
)
|
|
60
|
+
@arguments = Array(arguments).dup
|
|
61
|
+
@stdout = stdout
|
|
62
|
+
@stderr = stderr
|
|
63
|
+
@current_directory = File.expand_path(current_directory)
|
|
64
|
+
@generator_class = generator_class
|
|
65
|
+
@bundle_installer = bundle_installer || BundleInstaller.new(stdout:, stderr:)
|
|
66
|
+
@console_runner = console_runner
|
|
67
|
+
@source_path = source_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run
|
|
71
|
+
case arguments.first
|
|
72
|
+
when "--help", "-h", nil
|
|
73
|
+
return usage_error("--help does not accept arguments") if arguments.length > 1
|
|
74
|
+
|
|
75
|
+
stdout.write(USAGE)
|
|
76
|
+
0
|
|
77
|
+
when "--version", "-v"
|
|
78
|
+
return usage_error("--version does not accept arguments") if arguments.length > 1
|
|
79
|
+
|
|
80
|
+
stdout.puts("little_ghost #{VERSION}")
|
|
81
|
+
0
|
|
82
|
+
when "new"
|
|
83
|
+
generate_application
|
|
84
|
+
when "console"
|
|
85
|
+
open_console
|
|
86
|
+
else
|
|
87
|
+
usage_error("Unknown command: #{arguments.first}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
attr_reader :arguments, :stdout, :stderr, :current_directory, :generator_class, :bundle_installer, :source_path
|
|
94
|
+
|
|
95
|
+
def generate_application
|
|
96
|
+
return usage_error("APP_NAME is required") if arguments.length == 1
|
|
97
|
+
return usage_error("new accepts exactly one APP_NAME") if arguments.length > 2
|
|
98
|
+
|
|
99
|
+
application = generator_class.new(name: arguments.fetch(1), current_directory:, dependency_path: source_path)
|
|
100
|
+
stdout.puts("Running bundle install...")
|
|
101
|
+
bundle_installed = false
|
|
102
|
+
destination = application.generate do |staging|
|
|
103
|
+
bundle_installed = bundle_installer.install(staging)
|
|
104
|
+
end
|
|
105
|
+
stdout.puts("Created #{destination}")
|
|
106
|
+
unless bundle_installed
|
|
107
|
+
stderr.puts("Error: bundle install failed; the application was created at #{destination}")
|
|
108
|
+
return 1
|
|
109
|
+
end
|
|
110
|
+
stdout.puts("Run `cd #{application.directory_name} && bin/little_ghost console` to get started.")
|
|
111
|
+
0
|
|
112
|
+
rescue Generators::ApplicationGenerator::Error => error
|
|
113
|
+
stderr.puts("Error: #{error.message}")
|
|
114
|
+
1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def open_console
|
|
118
|
+
return usage_error("console does not accept arguments") if arguments.length > 1
|
|
119
|
+
|
|
120
|
+
runner = @console_runner
|
|
121
|
+
unless runner
|
|
122
|
+
require_relative "console"
|
|
123
|
+
runner = Console.method(:start)
|
|
124
|
+
end
|
|
125
|
+
runner.call(root: current_directory)
|
|
126
|
+
0
|
|
127
|
+
rescue LittleGhost::Error, LoadError, SystemCallError => error
|
|
128
|
+
stderr.puts("Error: #{error.message}")
|
|
129
|
+
1
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def usage_error(message)
|
|
133
|
+
stderr.puts("Error: #{message}")
|
|
134
|
+
stderr.write(USAGE)
|
|
135
|
+
1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "irb"
|
|
4
|
+
require_relative "../little_ghost"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
class Console # :nodoc:
|
|
8
|
+
def self.start(root:)
|
|
9
|
+
configuration = Configuration.new(root:)
|
|
10
|
+
LittleGhost.with_configuration(configuration) do
|
|
11
|
+
LittleGhost.runtime
|
|
12
|
+
IRB.setup(nil, argv: [])
|
|
13
|
+
IRB::Irb.new.run(IRB.conf)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/little_ghost/errors.rb
CHANGED
|
@@ -12,7 +12,7 @@ module LittleGhost
|
|
|
12
12
|
class SandboxConfigurationError < ConfigurationError; end
|
|
13
13
|
# Raised when a sandbox backend does not support the current operating system.
|
|
14
14
|
class UnsupportedPlatformError < SandboxConfigurationError; end
|
|
15
|
-
# Raised when an explicitly selected
|
|
15
|
+
# Raised when an explicitly selected optional integration or backend dependency is unavailable.
|
|
16
16
|
class DependencyError < SandboxConfigurationError; end
|
|
17
17
|
# Raised when a backend cannot enforce a requested sandbox capability.
|
|
18
18
|
class CapabilityError < SandboxConfigurationError; end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
module Generators # :nodoc:
|
|
8
|
+
# Creates one conventional standalone LittleGhost application.
|
|
9
|
+
class ApplicationGenerator # :nodoc:
|
|
10
|
+
class Error < StandardError; end # :nodoc:
|
|
11
|
+
class InvalidNameError < Error; end # :nodoc:
|
|
12
|
+
class DestinationExistsError < Error; end # :nodoc:
|
|
13
|
+
class GenerationError < Error; end # :nodoc:
|
|
14
|
+
|
|
15
|
+
NAME_PATTERN = /\A[A-Za-z](?:[A-Za-z0-9]|[-_](?=[A-Za-z0-9]))*\z/ # :nodoc:
|
|
16
|
+
TEMPLATE_ROOT = File.expand_path("templates/application", __dir__) # :nodoc:
|
|
17
|
+
EMPTY_DIRECTORIES = %w[app/assemblies app/tools app/skills].freeze # :nodoc:
|
|
18
|
+
|
|
19
|
+
attr_reader :directory_name
|
|
20
|
+
|
|
21
|
+
def initialize(name:, current_directory: Dir.pwd, template_root: TEMPLATE_ROOT, dependency_path: nil)
|
|
22
|
+
@name = name.to_s
|
|
23
|
+
@current_directory = File.expand_path(current_directory)
|
|
24
|
+
@template_root = File.expand_path(template_root)
|
|
25
|
+
@dependency_path = dependency_path && File.expand_path(dependency_path)
|
|
26
|
+
@directory_name = normalize_name
|
|
27
|
+
@class_name = directory_name.split("_").map(&:capitalize).join
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate
|
|
31
|
+
destination = File.join(current_directory, directory_name)
|
|
32
|
+
raise DestinationExistsError, "#{destination} already exists" if File.symlink?(destination) || File.exist?(destination)
|
|
33
|
+
|
|
34
|
+
identity = nil
|
|
35
|
+
begin
|
|
36
|
+
Dir.mkdir(destination, 0o700)
|
|
37
|
+
rescue Errno::EEXIST
|
|
38
|
+
raise DestinationExistsError, "#{destination} already exists"
|
|
39
|
+
rescue SystemCallError => error
|
|
40
|
+
raise GenerationError, "Could not create #{directory_name}: #{error.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
identity = directory_identity(destination)
|
|
45
|
+
files = rendered_files
|
|
46
|
+
create_directories(destination, identity, files)
|
|
47
|
+
create_files(destination, identity, files)
|
|
48
|
+
directory_mode = File.stat(File.join(destination, "app")).mode & 0o777
|
|
49
|
+
verify_destination!(destination, identity)
|
|
50
|
+
yield(destination) if block_given?
|
|
51
|
+
restore_directory_mode(destination, identity, directory_mode)
|
|
52
|
+
destination
|
|
53
|
+
rescue Error
|
|
54
|
+
remove_created_destination(destination, identity) if identity
|
|
55
|
+
raise
|
|
56
|
+
rescue => error
|
|
57
|
+
remove_created_destination(destination, identity) if identity
|
|
58
|
+
raise GenerationError, "Could not create #{directory_name}: #{error.message}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :name, :current_directory, :template_root, :class_name, :dependency_path
|
|
65
|
+
|
|
66
|
+
def normalize_name
|
|
67
|
+
unless NAME_PATTERN.match?(name)
|
|
68
|
+
raise InvalidNameError, "APP_NAME must begin with a letter and contain only letters, numbers, hyphens, or underscores"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
normalized = name
|
|
72
|
+
.gsub(/([A-Z\d]+)([A-Z][a-z])/, "\\1_\\2")
|
|
73
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
74
|
+
.tr("-", "_")
|
|
75
|
+
.downcase
|
|
76
|
+
raise InvalidNameError, "APP_NAME must identify a Ruby constant" unless /\A[a-z][a-z0-9_]*\z/.match?(normalized)
|
|
77
|
+
|
|
78
|
+
normalized
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def create_directories(destination, identity, files)
|
|
82
|
+
files
|
|
83
|
+
.each_key
|
|
84
|
+
.map { |relative_destination| File.dirname(relative_destination) }
|
|
85
|
+
.reject { |directory| directory == "." }
|
|
86
|
+
.flat_map { |directory| directory.split("/").each_index.map { |index| directory.split("/").first(index + 1).join("/") } }
|
|
87
|
+
.uniq
|
|
88
|
+
.sort_by { |directory| directory.count("/") }
|
|
89
|
+
.each do |directory|
|
|
90
|
+
verify_destination!(destination, identity)
|
|
91
|
+
Dir.mkdir(File.join(destination, directory))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def create_files(destination, identity, files)
|
|
96
|
+
files.each do |relative_destination, contents|
|
|
97
|
+
create_file(destination, identity, relative_destination, contents)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def create_file(destination, identity, relative_destination, contents)
|
|
102
|
+
verify_destination!(destination, identity)
|
|
103
|
+
target = File.join(destination, relative_destination)
|
|
104
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
105
|
+
file.write(contents)
|
|
106
|
+
file.chmod(0o755) if relative_destination == "bin/little_ghost"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def template_destinations
|
|
111
|
+
[
|
|
112
|
+
["Gemfile.tt", "Gemfile"],
|
|
113
|
+
["README.md.tt", "README.md"],
|
|
114
|
+
["gitignore.tt", ".gitignore"],
|
|
115
|
+
["config/little_ghost.rb.tt", "config/little_ghost.rb"],
|
|
116
|
+
["app/agents/application_agent.rb.tt", "app/agents/#{directory_name}_agent.rb"],
|
|
117
|
+
["app/prompts/application/system.erb.tt", "app/prompts/#{directory_name}/system.erb"],
|
|
118
|
+
["bin/application.tt", "bin/little_ghost"],
|
|
119
|
+
*EMPTY_DIRECTORIES.map { |directory| ["keep.tt", "#{directory}/.keep"] }
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def rendered_files
|
|
124
|
+
template_destinations.to_h do |template, relative_destination|
|
|
125
|
+
[relative_destination, render(File.binread(File.join(template_root, template)))]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render(template)
|
|
130
|
+
template
|
|
131
|
+
.gsub("{{DIRECTORY_NAME}}", directory_name)
|
|
132
|
+
.gsub("{{CLASS_NAME}}", class_name)
|
|
133
|
+
.gsub("{{LITTLE_GHOST_DEPENDENCY}}", little_ghost_dependency)
|
|
134
|
+
.gsub("{{VERSION}}", VERSION)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def little_ghost_dependency
|
|
138
|
+
return %(gem "little_ghost", path: #{dependency_path.dump}) if dependency_path
|
|
139
|
+
|
|
140
|
+
%(gem "little_ghost", "~> #{VERSION}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def directory_identity(destination)
|
|
144
|
+
stat = File.lstat(destination)
|
|
145
|
+
raise GenerationError, "Destination changed during generation" unless stat.directory? && !stat.symlink?
|
|
146
|
+
|
|
147
|
+
[stat.dev, stat.ino].freeze
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def restore_directory_mode(destination, identity, mode, nofollow_supported: File.const_defined?(:NOFOLLOW))
|
|
151
|
+
if nofollow_supported
|
|
152
|
+
File.open(destination, File::RDONLY | File::NOFOLLOW) do |directory|
|
|
153
|
+
stat = directory.stat
|
|
154
|
+
raise GenerationError, "Destination changed during generation" unless [stat.dev, stat.ino] == identity
|
|
155
|
+
|
|
156
|
+
directory.chmod(mode)
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
verify_destination!(destination, identity)
|
|
160
|
+
File.chmod(mode, destination)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def verify_destination!(destination, identity)
|
|
165
|
+
raise GenerationError, "Destination changed during generation" unless directory_identity(destination) == identity
|
|
166
|
+
rescue Errno::ENOENT
|
|
167
|
+
raise GenerationError, "Destination changed during generation"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def remove_created_destination(destination, identity)
|
|
171
|
+
return unless directory_identity(destination) == identity
|
|
172
|
+
|
|
173
|
+
FileUtils.remove_entry_secure(destination)
|
|
174
|
+
rescue Errno::ENOENT, GenerationError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# {{CLASS_NAME}}
|
|
2
|
+
|
|
3
|
+
This LittleGhost application includes one Agent, a prompt view, and a
|
|
4
|
+
bundle-aware local command.
|
|
5
|
+
|
|
6
|
+
## Open the console
|
|
7
|
+
|
|
8
|
+
Install or refresh the application's bundle whenever its dependencies change:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
$ bundle install
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then start the console:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
$ export OPENROUTER_API_KEY="..."
|
|
18
|
+
$ bin/little_ghost console
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The generator runs `bundle install` when it creates the directory. If that
|
|
22
|
+
initial install cannot finish, the application remains in place so you can fix
|
|
23
|
+
the cause and run `bundle install` again. The console uses the application's
|
|
24
|
+
bundle, loads `config/little_ghost.rb` and the classes under `app/`, and then
|
|
25
|
+
starts IRB:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
{{CLASS_NAME}}Agent.ask("Say hello").response
|
|
29
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
You are a helpful assistant. Give clear, concise answers to the user's instructions.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
app_root = File.expand_path("..", __dir__)
|
|
5
|
+
ENV["BUNDLE_GEMFILE"] = File.join(app_root, "Gemfile")
|
|
6
|
+
|
|
7
|
+
require "bundler/setup"
|
|
8
|
+
require "little_ghost/cli"
|
|
9
|
+
|
|
10
|
+
exit LittleGhost::CLI.new(ARGV, current_directory: app_root).run
|
|
File without changes
|