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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -5
  3. data/docs/guides/code_mode.md +2 -2
  4. data/docs/guides/getting_started.md +26 -1
  5. data/docs/guides/integrations.md +20 -161
  6. data/docs/guides/mcp.md +288 -0
  7. data/docs/guides/models_and_providers.md +21 -54
  8. data/docs/guides/production.md +14 -5
  9. data/docs/guides/providers.md +275 -0
  10. data/docs/guides/sandboxing.md +42 -7
  11. data/docs/guides/skills.md +1 -3
  12. data/docs/guides/tools.md +2 -0
  13. data/exe/little_ghost +12 -0
  14. data/lib/little_ghost/assembly.rb +11 -3
  15. data/lib/little_ghost/assembly_builder.rb +2 -2
  16. data/lib/little_ghost/cli.rb +138 -0
  17. data/lib/little_ghost/console.rb +17 -0
  18. data/lib/little_ghost/errors.rb +1 -1
  19. data/lib/little_ghost/generators/application_generator.rb +179 -0
  20. data/lib/little_ghost/generators/templates/application/Gemfile.tt +3 -0
  21. data/lib/little_ghost/generators/templates/application/README.md.tt +29 -0
  22. data/lib/little_ghost/generators/templates/application/app/agents/application_agent.rb.tt +5 -0
  23. data/lib/little_ghost/generators/templates/application/app/prompts/application/system.erb.tt +1 -0
  24. data/lib/little_ghost/generators/templates/application/bin/application.tt +10 -0
  25. data/lib/little_ghost/generators/templates/application/config/little_ghost.rb.tt +5 -0
  26. data/lib/little_ghost/generators/templates/application/gitignore.tt +2 -0
  27. data/lib/little_ghost/generators/templates/application/keep.tt +0 -0
  28. data/lib/little_ghost/mcp/client.rb +295 -606
  29. data/lib/little_ghost/mcp/toolset.rb +133 -95
  30. data/lib/little_ghost/mcp.rb +21 -3
  31. data/lib/little_ghost/model_resolver.rb +2 -0
  32. data/lib/little_ghost/models/catalog/models_dev_source.rb +4 -1
  33. data/lib/little_ghost/provider_registry.rb +1 -0
  34. data/lib/little_ghost/providers/lm_studio/catalog_source.rb +201 -0
  35. data/lib/little_ghost/providers/lm_studio.rb +52 -0
  36. data/lib/little_ghost/providers/openai.rb +3 -75
  37. data/lib/little_ghost/providers/openai_compatible.rb +78 -1
  38. data/lib/little_ghost/sandbox/environment_policy.rb +29 -4
  39. data/lib/little_ghost/sandbox/policy.rb +28 -6
  40. data/lib/little_ghost/sandboxes/unrestricted.rb +7 -3
  41. data/lib/little_ghost/skills/catalog.rb +13 -3
  42. data/lib/little_ghost/tool.rb +6 -1
  43. data/lib/little_ghost/version.rb +1 -1
  44. data/lib/little_ghost.rb +1 -0
  45. metadata +37 -7
  46. 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
@@ -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 sandbox backend dependency is unavailable.
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,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ {{LITTLE_GHOST_DEPENDENCY}}
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class {{CLASS_NAME}}Agent < LittleGhost::Agent
4
+ model "openrouter:openai/gpt-5.6-luna"
5
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ LittleGhost.configure do |config|
4
+ config.service_name "{{DIRECTORY_NAME}}"
5
+ end
@@ -0,0 +1,2 @@
1
+ .bundle/
2
+ vendor/bundle/