lyman 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.
@@ -0,0 +1,98 @@
1
+ module Lyman
2
+ module CLI
3
+ # What lyman installs. Everything the generator plants into a client
4
+ # project is declared here — this list, not directory layout, is the
5
+ # boundary between what lyman *does* (lib/lyman/cli) and what lyman
6
+ # *installs* (everything named below).
7
+ module Registry
8
+ GEM_ROOT = File.expand_path("../../..", __dir__)
9
+
10
+ ARTIFACTS = {
11
+ "lyman_entry" => {
12
+ source: "templates/lib_lyman.rb",
13
+ dest: "lib/lyman.rb",
14
+ role: :managed,
15
+ description: "Library entry point; requires shifty and every planted module"
16
+ },
17
+ "conversation" => {
18
+ source: "lib/lyman/conversation.rb",
19
+ dest: "lib/lyman/conversation.rb",
20
+ role: :managed,
21
+ description: "The item that flows through pipelines"
22
+ },
23
+ "chat_completion" => {
24
+ source: "lib/lyman/workers/chat_completion.rb",
25
+ dest: "lib/lyman/workers/chat_completion.rb",
26
+ role: :managed,
27
+ description: "Relay worker: OpenAI-compatible chat completions (streaming + blocking)"
28
+ },
29
+ "tool_execution" => {
30
+ source: "lib/lyman/workers/tool_execution.rb",
31
+ dest: "lib/lyman/workers/tool_execution.rb",
32
+ role: :managed,
33
+ description: "Relay worker: executes pending tool calls"
34
+ },
35
+ "harness" => {
36
+ source: "harness/chat.rb",
37
+ dest: "harness/chat.rb",
38
+ role: :owned,
39
+ description: "The wiring script — yours from day one; lyman never updates it"
40
+ },
41
+ "claude_md" => {
42
+ source: "templates/CLAUDE.md",
43
+ dest: "CLAUDE.md",
44
+ role: :owned,
45
+ description: "Guidance for coding agents working in this project"
46
+ },
47
+ "gemfile" => {
48
+ source: "templates/Gemfile",
49
+ dest: "Gemfile",
50
+ role: :owned,
51
+ description: "Client dependencies: shifty (and ostruct for ruby >= 4)"
52
+ },
53
+ "gitignore" => {
54
+ source: "templates/gitignore",
55
+ dest: ".gitignore",
56
+ role: :owned,
57
+ description: "A minimal starter .gitignore (.lyman/ stays tracked on purpose)"
58
+ }
59
+ }.freeze
60
+
61
+ def self.fetch(name)
62
+ ARTIFACTS.fetch(name) do
63
+ valid = ARTIFACTS.keys.join(", ")
64
+ raise Thor::Error, "Unknown artifact #{name.inspect}. Valid artifacts: #{valid}"
65
+ end
66
+ end
67
+
68
+ # Commands accept an artifact name or a project-relative path — you
69
+ # shouldn't have to remember the token while looking at the file.
70
+ # Resolution order: registry name, then the path recorded in this
71
+ # project's manifest (authoritative for where the file actually is),
72
+ # then the registry's dest (for artifacts not yet planted).
73
+ def self.resolve(token, manifest: nil)
74
+ return token if ARTIFACTS.key?(token)
75
+
76
+ path = token.delete_prefix("./")
77
+ if manifest
78
+ name, _entry = manifest.artifacts.find { |_, entry| entry["path"] == path }
79
+ return name if name
80
+ end
81
+ name, _spec = ARTIFACTS.find { |_, spec| spec[:dest] == path }
82
+ return name if name
83
+
84
+ valid = ARTIFACTS.keys.join(", ")
85
+ raise Thor::Error, "Unknown artifact #{token.inspect}. " \
86
+ "Give an artifact name (#{valid}) or a planted path (e.g. lib/lyman/conversation.rb)."
87
+ end
88
+
89
+ def self.managed
90
+ ARTIFACTS.select { |_, spec| spec[:role] == :managed }
91
+ end
92
+
93
+ def self.source_path(spec, source_root: GEM_ROOT)
94
+ File.join(source_root, spec[:source])
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ module Lyman
2
+ module CLI
3
+ # The gem's own version — distinct from any artifact's `planted_at`.
4
+ # This is what the manifest's top-level `lyman:` key records.
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/lyman/cli.rb ADDED
@@ -0,0 +1,100 @@
1
+ require "thor"
2
+
3
+ require_relative "cli/version"
4
+ require_relative "cli/registry"
5
+ require_relative "cli/manifest"
6
+ require_relative "cli/planter"
7
+ require_relative "cli/commands/new"
8
+ require_relative "cli/commands/add"
9
+ require_relative "cli/commands/update"
10
+ require_relative "cli/commands/eject"
11
+ require_relative "cli/commands/diff"
12
+ require_relative "cli/commands/doctor"
13
+
14
+ module Lyman
15
+ module CLI
16
+ # The legible table of contents for every lyman command. Root wires
17
+ # Thor's argument parsing to a plain object per command
18
+ # (lib/lyman/cli/commands/*.rb) and nothing else — no command grows its
19
+ # body here.
20
+ #
21
+ # Command contract: every command class is built as
22
+ # `Commands::X.new(thor, source_root:).call(*args)`.
23
+ # - `thor` is this Root instance, passed through so a command can use
24
+ # Thor's UI helpers (`thor.yes?`, `thor.say`) without inheriting from
25
+ # Thor itself — commands are plain objects, not Thor subclasses.
26
+ # - `source_root:` is where artifacts are read from (the gem tree by
27
+ # default; overridable via LYMAN_SOURCE_ROOT so tests can point it at
28
+ # a fixture that simulates a newer lyman release).
29
+ # - `#call`'s positional args are whatever the command needs (an
30
+ # artifact/project name, or nothing). Project-root discovery
31
+ # (Manifest.find!) happens inside each command that needs it — `new`
32
+ # is the one command that doesn't have a project root yet.
33
+ class Root < Thor
34
+ def self.exit_on_failure?
35
+ true
36
+ end
37
+
38
+ desc "new NAME", "Scaffold a new lyman project at NAME"
39
+ def new(name)
40
+ Commands::New.new(self, source_root: source_root).call(name)
41
+ end
42
+
43
+ desc "add ARTIFACT", "Plant an artifact (by name or path) this project doesn't have yet"
44
+ method_option :force, type: :boolean, default: false,
45
+ desc: "Skip the confirmation prompt when re-adding an ejected artifact, " \
46
+ "or overwrite an untracked file at the destination"
47
+ def add(artifact)
48
+ Commands::Add.new(self, source_root: source_root).call(artifact, force: options[:force])
49
+ end
50
+
51
+ desc "update", "Refresh managed artifacts from the current lyman release"
52
+ def update
53
+ Commands::Update.new(self, source_root: source_root).call
54
+ end
55
+
56
+ desc "eject ARTIFACT", "Take ownership of a managed artifact (by name or path)"
57
+ def eject(artifact)
58
+ Commands::Eject.new(self, source_root: source_root).call(artifact)
59
+ end
60
+
61
+ desc "diff ARTIFACT", "Show local and upstream changes for an artifact (by name or path)"
62
+ def diff(artifact)
63
+ Commands::Diff.new(self, source_root: source_root).call(artifact)
64
+ end
65
+
66
+ desc "doctor", "Smoke-test that the planted pipeline still runs"
67
+ def doctor
68
+ Commands::Doctor.new(self, source_root: source_root).call
69
+ end
70
+
71
+ desc "list", "List every artifact lyman knows how to install"
72
+ def list
73
+ manifest_root = Manifest.find
74
+ manifest = Manifest.load(manifest_root) if manifest_root
75
+
76
+ Registry::ARTIFACTS.each do |name, spec|
77
+ status =
78
+ if manifest
79
+ entry = manifest.artifact(name)
80
+ entry ? entry["status"] : "not planted"
81
+ end
82
+
83
+ line = "#{name} (#{spec[:role]}) #{spec[:dest]}#{" — #{status}" if status} — #{spec[:description]}"
84
+ say line
85
+ end
86
+ end
87
+
88
+ desc "version", "Print the lyman gem version"
89
+ def version
90
+ say VERSION
91
+ end
92
+
93
+ private
94
+
95
+ def source_root
96
+ ENV.fetch("LYMAN_SOURCE_ROOT", Registry::GEM_ROOT)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,65 @@
1
+ module Lyman
2
+ # The item that flows through the spine and the circuit: the whole
3
+ # conversation so far, plus the control data workers consult to decide
4
+ # *whether* to act (never *which of several things* to do).
5
+ #
6
+ # Messages use string keys throughout, for clean round-tripping with
7
+ # OpenAI-compatible wire formats.
8
+ class Conversation
9
+ attr_reader :messages
10
+ attr_accessor :rounds, :max_rounds
11
+
12
+ def initialize(system_prompt: nil, max_rounds: 10)
13
+ @messages = []
14
+ @messages << {"role" => "system", "content" => system_prompt} if system_prompt
15
+ @rounds = 0
16
+ @max_rounds = max_rounds
17
+ @finished = false
18
+ end
19
+
20
+ def add_user_message(text)
21
+ @messages << {"role" => "user", "content" => text}
22
+ @finished = false
23
+ @rounds = 0
24
+ self
25
+ end
26
+
27
+ def add_assistant_message(message)
28
+ @messages << message
29
+ self
30
+ end
31
+
32
+ def add_tool_result(tool_call_id, content)
33
+ @messages << {
34
+ "role" => "tool",
35
+ "tool_call_id" => tool_call_id,
36
+ "content" => content
37
+ }
38
+ self
39
+ end
40
+
41
+ def pending_tool_calls
42
+ last = @messages.last
43
+ return [] unless last && last["role"] == "assistant"
44
+ last["tool_calls"] || []
45
+ end
46
+
47
+ def last_assistant_content
48
+ message = @messages.rfind { |m| m["role"] == "assistant" }
49
+ message && message["content"]
50
+ end
51
+
52
+ def finish!
53
+ @finished = true
54
+ self
55
+ end
56
+
57
+ def finished?
58
+ @finished
59
+ end
60
+
61
+ def runaway?
62
+ @rounds >= @max_rounds
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,154 @@
1
+ require "shifty"
2
+ require "net/http"
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Lyman
7
+ module Workers
8
+ extend Shifty::DSL
9
+
10
+ # Relay worker: sends the conversation to an OpenAI-compatible chat
11
+ # completions endpoint and appends the assistant's reply (which may
12
+ # include tool calls). Increments the conversation's round counter.
13
+ #
14
+ # Pass an on_delta callable to stream: it receives each content
15
+ # fragment as it arrives. The worker still returns the conversation
16
+ # with the fully-assembled message appended, so the circuit sees no
17
+ # difference between streamed and unstreamed rounds.
18
+ #
19
+ # Servers disagree about where thinking goes: some put <think> tags
20
+ # inline in the content, others (e.g. Ollama) stream a separate
21
+ # reasoning field. The worker normalizes the latter to the former,
22
+ # so on_delta always sees one convention: <think>...</think> inline.
23
+ # The raw reasoning text still lands on the message unaltered.
24
+ #
25
+ # This is the only part of lyman that knows HTTP exists.
26
+ def self.chat_completion(base_url:, model:, tools: nil, read_timeout: 300, on_delta: nil)
27
+ uri = URI("#{base_url.chomp("/")}/chat/completions")
28
+
29
+ relay_worker do |conversation|
30
+ payload = {"model" => model, "messages" => wire_messages(conversation.messages)}
31
+ payload["tools"] = tools if tools && !tools.empty?
32
+ payload["stream"] = true if on_delta
33
+
34
+ http = Net::HTTP.new(uri.host, uri.port)
35
+ http.use_ssl = (uri.scheme == "https")
36
+ http.read_timeout = read_timeout
37
+
38
+ message =
39
+ if on_delta
40
+ stream_message(http, uri, payload, on_delta)
41
+ else
42
+ fetch_message(http, uri, payload)
43
+ end
44
+
45
+ conversation.add_assistant_message(message)
46
+ conversation.rounds += 1
47
+ conversation
48
+ end
49
+ end
50
+
51
+ # The conversation keeps each message's reasoning (it's useful to
52
+ # observability), but it never rides back to the model: providers
53
+ # either ignore it, reject it outright (DeepSeek), or would burn
54
+ # context re-reading thoughts the model already finished thinking.
55
+ def self.wire_messages(messages)
56
+ messages.map do |message|
57
+ message.reject { |key, _| key == "reasoning" || key == "reasoning_content" }
58
+ end
59
+ end
60
+
61
+ def self.fetch_message(http, uri, payload)
62
+ response = http.post(
63
+ uri.path,
64
+ JSON.generate(payload),
65
+ "Content-Type" => "application/json"
66
+ )
67
+
68
+ unless response.is_a?(Net::HTTPSuccess)
69
+ raise "chat completion failed (#{response.code}): #{response.body}"
70
+ end
71
+
72
+ message = JSON.parse(response.body).dig("choices", 0, "message")
73
+ raise "chat completion response had no message: #{response.body}" unless message
74
+ message
75
+ end
76
+
77
+ def self.stream_message(http, uri, payload, on_delta)
78
+ request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
79
+ request.body = JSON.generate(payload)
80
+
81
+ message = {"role" => "assistant", "content" => nil}
82
+ tool_calls = {}
83
+ state = {thinking: false}
84
+ buffer = +""
85
+
86
+ http.request(request) do |response|
87
+ unless response.is_a?(Net::HTTPSuccess)
88
+ raise "chat completion failed (#{response.code}): #{response.read_body}"
89
+ end
90
+
91
+ response.read_body do |chunk|
92
+ buffer << chunk
93
+ # SSE events arrive as "data: {json}" lines, but chunks don't
94
+ # align with line boundaries.
95
+ while (newline = buffer.index("\n"))
96
+ line = buffer.slice!(0..newline).strip
97
+ next unless line.start_with?("data:")
98
+
99
+ data = line.delete_prefix("data:").strip
100
+ next if data == "[DONE]"
101
+
102
+ delta = JSON.parse(data).dig("choices", 0, "delta")
103
+ apply_delta(message, tool_calls, delta, on_delta, state) if delta
104
+ end
105
+ end
106
+ end
107
+
108
+ on_delta.call("</think>") if state[:thinking] # reasoning ran to stream end
109
+ unless tool_calls.empty?
110
+ message["tool_calls"] = tool_calls.keys.sort.map { |index| tool_calls[index] }
111
+ end
112
+ message
113
+ end
114
+
115
+ # Content deltas concatenate; tool-call deltas arrive as fragments
116
+ # keyed by index, with the arguments string dribbling in over many
117
+ # chunks. Reasoning-field deltas stream to on_delta wrapped in
118
+ # synthesized <think> tags (see chat_completion).
119
+ def self.apply_delta(message, tool_calls, delta, on_delta, state)
120
+ reasoning = delta["reasoning"] || delta["reasoning_content"]
121
+ if reasoning && !reasoning.empty?
122
+ unless state[:thinking]
123
+ state[:thinking] = true
124
+ on_delta.call("<think>")
125
+ end
126
+ message["reasoning"] = (message["reasoning"] || +"") + reasoning
127
+ on_delta.call(reasoning)
128
+ end
129
+
130
+ if (text = delta["content"]) && !text.empty?
131
+ if state[:thinking]
132
+ state[:thinking] = false
133
+ on_delta.call("</think>")
134
+ end
135
+ message["content"] = (message["content"] || +"") + text
136
+ on_delta.call(text)
137
+ end
138
+
139
+ (delta["tool_calls"] || []).each do |fragment|
140
+ entry = tool_calls[fragment["index"]] ||= {
141
+ "id" => nil,
142
+ "type" => "function",
143
+ "function" => {"name" => +"", "arguments" => +""}
144
+ }
145
+ entry["id"] = fragment["id"] if fragment["id"]
146
+ entry["type"] = fragment["type"] if fragment["type"]
147
+
148
+ function = fragment["function"] || {}
149
+ entry["function"]["name"] << function["name"] if function["name"]
150
+ entry["function"]["arguments"] << function["arguments"] if function["arguments"]
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,47 @@
1
+ require "shifty"
2
+ require "json"
3
+
4
+ module Lyman
5
+ module Workers
6
+ extend Shifty::DSL
7
+
8
+ # Relay worker: executes any tool calls pending on the conversation and
9
+ # appends their results. Acts only when the turn isn't already finished
10
+ # and the model has asked for tools; otherwise passes through.
11
+ #
12
+ # +handlers+ is a hash of tool name => callable taking a Hash of
13
+ # arguments and returning something stringable.
14
+ def self.tool_execution(handlers)
15
+ relay_worker do |conversation|
16
+ unless conversation.finished?
17
+ conversation.pending_tool_calls.each do |tool_call|
18
+ name = tool_call.dig("function", "name")
19
+ args = parse_arguments(tool_call.dig("function", "arguments"))
20
+ handler = handlers[name]
21
+
22
+ result =
23
+ if handler
24
+ begin
25
+ handler.call(args).to_s
26
+ rescue => e
27
+ "Tool #{name} raised #{e.class}: #{e.message}"
28
+ end
29
+ else
30
+ "Unknown tool: #{name}"
31
+ end
32
+
33
+ conversation.add_tool_result(tool_call["id"], result)
34
+ end
35
+ end
36
+ conversation
37
+ end
38
+ end
39
+
40
+ def self.parse_arguments(raw)
41
+ return raw if raw.is_a?(Hash)
42
+ JSON.parse(raw.to_s)
43
+ rescue JSON::ParserError
44
+ {}
45
+ end
46
+ end
47
+ end
data/lib/lyman.rb ADDED
@@ -0,0 +1,8 @@
1
+ require "shifty"
2
+
3
+ require_relative "lyman/conversation"
4
+ require_relative "lyman/workers/chat_completion"
5
+ require_relative "lyman/workers/tool_execution"
6
+
7
+ module Lyman
8
+ end
@@ -0,0 +1,57 @@
1
+ # CLAUDE.md
2
+
3
+ This project was scaffolded by [lyman](https://github.com/joelhelbling/lyman):
4
+ an agentic harness built on the [shifty](https://github.com/joelhelbling/shifty)
5
+ gem's pipeline-of-workers model. `harness/chat.rb` wires a model⇄tool loop
6
+ against any OpenAI-compatible chat completions endpoint (Ollama by default).
7
+
8
+ ## Running it
9
+
10
+ - `bundle install` — install dependencies (just `shifty` and `ostruct`; this
11
+ project has no runtime dependency on the `lyman` gem itself).
12
+ - `ruby harness/chat.rb` — run the interactive chat harness. Defaults to
13
+ Ollama at `http://localhost:11434/v1`; override with `LYMAN_MODEL` and
14
+ `LYMAN_BASE_URL` env vars.
15
+
16
+ ## The managed/owned boundary
17
+
18
+ Everything under the `Lyman::` namespace (`lib/lyman.rb`, `lib/lyman/`) is
19
+ **managed** by the lyman CLI: it was planted here, and `lyman update` can
20
+ refresh it from newer lyman releases. Treat it as a library to extend, not a
21
+ file to hand-edit — if you need to change one, run `lyman eject <name>` first,
22
+ which takes ownership explicitly rather than leaving it silently forked.
23
+ `.lyman/manifest.yml` is the record of what's managed, what's owned, and what
24
+ you've ejected; commit it.
25
+
26
+ `harness/chat.rb` and everything outside `Lyman::` is **owned** — yours from
27
+ day one, never touched by `lyman update`. Put your own workers in your own
28
+ namespace or directory, not inside `lib/lyman/`.
29
+
30
+ ## Four load-bearing facts
31
+
32
+ 1. **The nil-source footgun.** In shifty, a source returning `nil` ends the
33
+ stream permanently. If you're driving a pipeline off a queue, enqueue
34
+ before you shift — never pull a source worker while its queue is empty.
35
+
36
+ 2. **The runaway-turn guard.** The model⇄tool circuit is bounded by
37
+ `Conversation#runaway?` / `max_rounds`. Keep that guard intact when
38
+ rewiring the circuit; without it, a model that keeps calling tools never
39
+ lets a turn end.
40
+
41
+ 3. **Item-as-control discipline.** An item may tell a worker *whether* to
42
+ act, never *which of several things* to do. A worker that switches
43
+ between jobs based on item state is the anti-pattern to avoid
44
+ (multi-way dispatch) — split it into stages, or use a splitter, instead.
45
+
46
+ 4. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
47
+ the `Conversation` for observability, but `Workers.wire_messages` strips
48
+ it before anything goes out over the wire. Preserve that separation if
49
+ you touch message handling.
50
+
51
+ ## Other conventions
52
+
53
+ - `lyman doctor` smoke-tests the pipeline end to end against a stub
54
+ transport — run it after `lyman update` or whenever something feels off.
55
+ - `lyman list` shows what's managed, owned, or ejected in this project.
56
+ - Messages use string keys throughout, matching the OpenAI-compatible wire
57
+ format — no symbol-key message hashes.
data/templates/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "shifty"
4
+ gem "ostruct" # shifty dependency; no longer a default gem as of ruby 4.0
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .DS_Store
3
+
4
+ # .lyman/ is project state (the manifest + pristine copies), not build
5
+ # output — it belongs in git, not here.
@@ -0,0 +1,8 @@
1
+ require "shifty"
2
+
3
+ # Every planted module under lib/lyman/ loads here. `lyman add` drops new
4
+ # modules in place without this file ever needing an edit.
5
+ Dir[File.join(__dir__, "lyman", "**", "*.rb")].sort.each { |file| require file }
6
+
7
+ module Lyman
8
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lyman
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Joel Helbling
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: shifty
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ostruct
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ email:
55
+ - joel.helbling@gmail.com
56
+ executables:
57
+ - lyman
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE
62
+ - README.md
63
+ - docs/design/circuit-pattern.md
64
+ - docs/design/deployment.md
65
+ - docs/vision.md
66
+ - exe/lyman
67
+ - harness/chat.rb
68
+ - lib/lyman.rb
69
+ - lib/lyman/cli.rb
70
+ - lib/lyman/cli/commands/add.rb
71
+ - lib/lyman/cli/commands/diff.rb
72
+ - lib/lyman/cli/commands/doctor.rb
73
+ - lib/lyman/cli/commands/eject.rb
74
+ - lib/lyman/cli/commands/new.rb
75
+ - lib/lyman/cli/commands/update.rb
76
+ - lib/lyman/cli/doctor_check.rb
77
+ - lib/lyman/cli/manifest.rb
78
+ - lib/lyman/cli/planter.rb
79
+ - lib/lyman/cli/registry.rb
80
+ - lib/lyman/cli/version.rb
81
+ - lib/lyman/conversation.rb
82
+ - lib/lyman/workers/chat_completion.rb
83
+ - lib/lyman/workers/tool_execution.rb
84
+ - templates/CLAUDE.md
85
+ - templates/Gemfile
86
+ - templates/gitignore
87
+ - templates/lib_lyman.rb
88
+ homepage: https://github.com/joelhelbling/lyman
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ homepage_uri: https://github.com/joelhelbling/lyman
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '3.2'
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 4.0.10
108
+ specification_version: 4
109
+ summary: A composable agentic harness, delivered as code you own — a pure generator
110
+ in the shadcn/ui mold, built on shifty
111
+ test_files: []