pikuri-workspace 0.0.3

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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Pikuri
6
+ class Tool
7
+ # Defines which paths the agent can see and write to. Subclass and
8
+ # implement {#cwd}, {#resolve_for_read}, and {#resolve_for_write}.
9
+ # Returned Pathnames are absolute, post-symlink-resolution.
10
+ #
11
+ # == Read-set vs. write-set
12
+ #
13
+ # The two resolve methods exist because a future workspace may admit
14
+ # paths for reading that it refuses for writing — e.g. a multi-root
15
+ # workspace where +~/.claude/skills+ is readable but only the project
16
+ # CWD is writable. For v1's {Cwd} the two methods are the same: read-
17
+ # set and write-set are both the CWD subtree.
18
+ #
19
+ # == Existence is not the workspace's concern
20
+ #
21
+ # +resolve_for_read('foo.rb')+ succeeds (returns a +Pathname+) even if
22
+ # +foo.rb+ doesn't exist; the caller ({Tool::Read}) errors with
23
+ # file-not-found when it tries to open it. +resolve_for_write+
24
+ # tolerates entirely non-existent paths (Write can create
25
+ # +lib/new/dir/foo.rb+ even when +lib/new/+ doesn't exist) — the
26
+ # caller is responsible for any +mkdir_p+ before writing. This split
27
+ # keeps the workspace narrowly responsible for *containment*, not for
28
+ # filesystem-state checks.
29
+ #
30
+ # == Future extensions (out of scope for v1)
31
+ #
32
+ # * Multi-root workspace: +~/.claude/skills+ readable, CWD writable.
33
+ # * +Tool::Workspace::ALLOW_ALL+: no restriction, planned for Docker /
34
+ # dev-container mode.
35
+ class Workspace
36
+ # Raised for any path that resolves outside the workspace. Recoverable
37
+ # at the tool layer — tools rescue this and emit +"Error: ..."+
38
+ # observations so the LLM can self-correct on the next turn.
39
+ class Error < StandardError; end
40
+
41
+ # Project anchor: where shells run, where modifications are made.
42
+ # For {Cwd} this is the constructor's +root:+ (after +realpath+);
43
+ # a future +ALLOW_ALL+ would return the user's invocation
44
+ # directory.
45
+ #
46
+ # @return [Pathname]
47
+ # @raise [NotImplementedError] in the abstract base
48
+ def cwd
49
+ raise NotImplementedError, "#{self.class}#cwd must be implemented"
50
+ end
51
+
52
+ # Resolve a user-supplied path against the workspace's read-set.
53
+ # Returned Pathname is absolute and may not exist on disk; the
54
+ # caller validates existence separately.
55
+ #
56
+ # @param path [String] user-supplied path, absolute or relative
57
+ # @return [Pathname]
58
+ # @raise [Error] if the resolved path falls outside the read-set
59
+ # @raise [NotImplementedError] in the abstract base
60
+ def resolve_for_read(path)
61
+ raise NotImplementedError, "#{self.class}#resolve_for_read must be implemented"
62
+ end
63
+
64
+ # Resolve a user-supplied path against the workspace's write-set.
65
+ # Same shape as {#resolve_for_read}; semantically distinct so a
66
+ # future workspace can permit reading paths it refuses to write.
67
+ #
68
+ # @param path [String]
69
+ # @return [Pathname]
70
+ # @raise [Error] if the resolved path falls outside the write-set
71
+ # @raise [NotImplementedError] in the abstract base
72
+ def resolve_for_write(path)
73
+ raise NotImplementedError, "#{self.class}#resolve_for_write must be implemented"
74
+ end
75
+
76
+ # Locks read+write to a single subtree (the +root:+ passed at
77
+ # construction, with symlinks resolved). +cwd+ equals the root.
78
+ #
79
+ # == Containment algorithm
80
+ #
81
+ # +#resolve+ walks up the input path to its deepest existing
82
+ # ancestor, +realpath+'s that ancestor (resolving any symlinks in
83
+ # the existing portion), then verifies the resolved anchor is
84
+ # +@root+ or a descendant. Four cases the algorithm must handle:
85
+ #
86
+ # 1. +lib/foo.rb+ (exists) → +existing+ = full path, +anchor+ in
87
+ # root → returns the realpath'd file.
88
+ # 2. +lib/new/dir/foo.rb+ (intermediates missing) → walks up to
89
+ # +@root/lib+, +anchor+ in root → returns the intended new path
90
+ # (caller +mkdir_p+s the parent before writing).
91
+ # 3. +lib/../../etc/passwd+ (+..+ escape) → +cleanpath+ collapses
92
+ # +..+ syntactically, walks land outside +@root+ → +Error+.
93
+ # 4. +link/foo.rb+ where +link → /etc+ (symlink escape) → walks to
94
+ # +link+ (which exists), +realpath+ resolves through the
95
+ # symlink to +/etc+, outside +@root+ → +Error+.
96
+ #
97
+ # Pure lexical normalization (+cleanpath+ + prefix check) catches
98
+ # cases 1–3 but misses case 4. The walk-up step adds the
99
+ # +realpath+ pass on the existing prefix, closing that gap.
100
+ class Cwd < Workspace
101
+ # @param root [String, Pathname] absolute (or working-directory-
102
+ # relative) path to anchor the workspace at. +realpath+'d once
103
+ # so subsequent comparisons happen in canonical form.
104
+ # @raise [Errno::ENOENT] if +root+ does not exist; surfaces
105
+ # immediately so misconfigured callers fail loudly.
106
+ def initialize(root:)
107
+ @root = Pathname.new(root).realpath
108
+ end
109
+
110
+ # @return [Pathname]
111
+ def cwd
112
+ @root
113
+ end
114
+
115
+ # @param path [String]
116
+ # @return [Pathname]
117
+ # @raise [Error]
118
+ def resolve_for_read(path)
119
+ resolve(path)
120
+ end
121
+
122
+ # @param path [String]
123
+ # @return [Pathname]
124
+ # @raise [Error]
125
+ def resolve_for_write(path)
126
+ resolve(path)
127
+ end
128
+
129
+ private
130
+
131
+ # See class header for the algorithm rationale.
132
+ def resolve(path)
133
+ pn = Pathname.new(path)
134
+ pn = @root + pn unless pn.absolute?
135
+ cleaned = pn.cleanpath
136
+
137
+ existing = cleaned
138
+ existing = existing.parent until existing.exist? || existing.parent == existing
139
+ anchor = existing.realpath
140
+
141
+ unless anchor == @root || anchor.to_s.start_with?(@root.to_s + File::SEPARATOR)
142
+ raise Error, "path '#{path}' is outside the workspace '#{@root}'"
143
+ end
144
+
145
+ anchor + cleaned.relative_path_from(existing)
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Pikuri
6
+ class Tool
7
+ # The +write+ tool, expressed as a {Tool} subclass: instantiating
8
+ # +Tool::Write.new(workspace: ws, confirmer: c)+ produces a tool whose
9
+ # {Tool#to_ruby_llm_tool} wiring is identical to any bundled tool's,
10
+ # so ruby_llm sees nothing special about it. Same shape as
11
+ # {Tool::SubAgent} and {Tool::Read} — workspace and confirmer are
12
+ # captured by the +execute+ closure at construction.
13
+ #
14
+ # == Policy
15
+ #
16
+ # Three branches based on the on-disk state of +path+:
17
+ #
18
+ # 1. *New file* — write, no prompt.
19
+ # 2. *Existing file, identical content* — return an +"Error: ..."+
20
+ # no-op observation *before* invoking the confirmer; don't ask the
21
+ # user to approve a write that wouldn't change the file. Comparison
22
+ # is byte-strict, in BINARY encoding (trailing-newline-only
23
+ # differences trigger the confirm path; encoding tags can't make
24
+ # equal bytes compare unequal).
25
+ # 3. *Existing file, content differs* — confirm with
26
+ # +"OK to overwrite <path>: <old> → <new> bytes?"+ via {Confirmer};
27
+ # on yes, write. On no, return a decline-Error observation.
28
+ #
29
+ # == Why ask-on-overwrite (Edit doesn't)
30
+ #
31
+ # Edit's +old_string+ argument is an implicit read-check: the model
32
+ # can't write a correct +old_string+ without having read the file, so
33
+ # blast radius is bounded by what the model actually knows about file
34
+ # state. Write has no such check, so a hallucinated 500-line +content+
35
+ # could clobber unread work. The confirmation prompt guards exactly
36
+ # the gap Edit's argument shape already covers.
37
+ #
38
+ # == Side effects
39
+ #
40
+ # Parent directories are created (+FileUtils.mkdir_p+) before the
41
+ # write — matches the +git add lib/new/dir/foo.rb+ mental model and
42
+ # mirrors opencode's and pi's behavior. Edge case: +mkdir_p+ succeeds
43
+ # but the write fails; an empty directory is left behind. Accepted
44
+ # for v1 — users have version control.
45
+ #
46
+ # No atomic temp-file+rename. Plain +File.write+, same as opencode and
47
+ # pi. The crash-safety story is "the user has git".
48
+ class Write < Tool
49
+ # Description shown to the LLM. Follows the opencode-shape (summary
50
+ # + +Usage:+ bullets) prescribed by the project's tool-description
51
+ # convention. Per-parameter constraints live in the parameter
52
+ # descriptions; the +Usage:+ bullets are for "when do I pick this?
53
+ # how does it chain with other tools?".
54
+ #
55
+ # @return [String]
56
+ DESCRIPTION = <<~DESC
57
+ Write a file to the workspace, creating parent directories as needed.
58
+
59
+ Usage:
60
+ - Use for new files or full-file rewrites; for partial changes use `edit` instead.
61
+ - Overwriting an existing file requires user confirmation; identical content is rejected as a no-op error — if you see that error, re-read the file rather than trying again.
62
+ - Parent directories are created automatically (mkdir -p).
63
+ - Writes the exact bytes supplied: no trailing-newline normalization, no encoding conversion.
64
+ - Paths outside the workspace are refused.
65
+ DESC
66
+
67
+ # @param workspace [Tool::Workspace] captured for path resolution;
68
+ # all writes route through +workspace.resolve_for_write+.
69
+ # @param confirmer [Tool::Confirmer] consulted before any overwrite
70
+ # of an existing file with non-identical content.
71
+ # @return [Write]
72
+ def initialize(workspace:, confirmer:)
73
+ super(
74
+ name: 'write',
75
+ description: DESCRIPTION,
76
+ parameters: Parameters.build { |p|
77
+ p.required_string :path,
78
+ 'Path to the file to write. Relative paths ' \
79
+ 'resolve against the workspace root, e.g. ' \
80
+ '"lib/foo.rb".'
81
+ p.required_string :content,
82
+ 'Full contents to write to the file, e.g. ' \
83
+ '"class Foo\nend\n".'
84
+ },
85
+ execute: ->(path:, content:) {
86
+ Write.write(workspace: workspace, confirmer: confirmer, path: path, content: content)
87
+ }
88
+ )
89
+ end
90
+
91
+ # Resolve +path+ against +workspace+, apply the three-branch policy
92
+ # (new / identical / differs), and return either a success
93
+ # observation or an +"Error: ..."+ observation.
94
+ #
95
+ # @param workspace [Tool::Workspace]
96
+ # @param confirmer [Tool::Confirmer]
97
+ # @param path [String] raw path as supplied by the LLM
98
+ # @param content [String] bytes to write
99
+ # @return [String] tool observation
100
+ def self.write(workspace:, confirmer:, path:, content:)
101
+ resolved = workspace.resolve_for_write(path)
102
+
103
+ if resolved.exist?
104
+ return "Error: #{path} is a directory" if resolved.directory?
105
+
106
+ existing = read_for_compare(resolved, path)
107
+
108
+ if existing == content.b
109
+ return "Error: #{path} already contains exactly this content — " \
110
+ 'no write needed. If you intended a change, re-read the ' \
111
+ 'file and try again.'
112
+ end
113
+
114
+ prompt = "OK to overwrite #{path}: #{existing.bytesize} → #{content.bytesize} bytes? (y/n)"
115
+ return "Error: user declined the write to #{path}." unless confirmer.confirm?(prompt: prompt)
116
+
117
+ write_bytes(resolved, content)
118
+ "Updated #{path} (#{existing.bytesize} → #{content.bytesize} bytes)"
119
+ else
120
+ write_bytes(resolved, content)
121
+ "Created #{path} (#{content.bytesize} bytes)"
122
+ end
123
+ rescue Tool::Workspace::Error, Error => e
124
+ "Error: #{e.message}"
125
+ rescue Errno::EACCES => e
126
+ "Error: cannot write #{path}: #{e.message}"
127
+ end
128
+
129
+ # Internal-only signal for LLM-actionable preconditions that Write
130
+ # detects before any filesystem mutation — today, "existing file is
131
+ # unreadable so the identical-content check can't run". Mirrors
132
+ # {Tool::Workspace::Error} in shape: caught by {.write}'s outer
133
+ # rescue and rendered as a +"Error: ..."+ observation. The class
134
+ # is +private_constant+ to keep it from leaking out as a public
135
+ # exception type; callers should never +rescue+ this directly.
136
+ class Error < StandardError; end
137
+ private_constant :Error
138
+
139
+ # Read the existing file in BINARY mode so the equality check is
140
+ # purely byte-wise. Raises {Error} on +Errno::EACCES+ so {.write}'s
141
+ # outer rescue can render a specific "cannot read for overwrite
142
+ # check" message — without this conversion the bottom +Errno::EACCES+
143
+ # rescue would mislabel the read-side problem as "cannot write".
144
+ #
145
+ # @param resolved [Pathname]
146
+ # @param path [String] original path for the error message
147
+ # @return [String]
148
+ # @raise [Error] when the file exists but isn't readable
149
+ def self.read_for_compare(resolved, path)
150
+ resolved.binread
151
+ rescue Errno::EACCES => e
152
+ raise Error, "cannot read #{path} for overwrite check: #{e.message}"
153
+ end
154
+ private_class_method :read_for_compare
155
+
156
+ # +mkdir_p+ the parent, then write. Split out so both the create-
157
+ # and overwrite-branches can reuse it without duplicating the
158
+ # +mkdir_p+ call.
159
+ #
160
+ # @param resolved [Pathname]
161
+ # @param content [String]
162
+ # @return [void]
163
+ def self.write_bytes(resolved, content)
164
+ FileUtils.mkdir_p(resolved.dirname)
165
+ resolved.write(content)
166
+ end
167
+ private_class_method :write_bytes
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pikuri-core'
4
+
5
+ # Entry file for the pikuri-workspace gem. After
6
+ # +require 'pikuri-workspace'+, +Pikuri::Tool::Workspace+,
7
+ # +Pikuri::Tool::Confirmer+, and the five file tools (+Read+,
8
+ # +Write+, +Edit+, +Grep+, +Glob+) are all defined.
9
+ #
10
+ # The Zeitwerk loader is mounted under +Pikuri::Tool+ (rather than
11
+ # rooted at this gem's +lib/+) because +Pikuri::Tool+ is a class
12
+ # owned by pikuri-core; without the explicit +namespace:+ argument,
13
+ # Zeitwerk would try to redefine +Pikuri::Tool+ as a module from the
14
+ # implicit-namespace inference, which would conflict. Mounting at
15
+ # the existing class side-steps the conflict and lets our nested
16
+ # files (+lib/pikuri/tool/workspace.rb+ → +Pikuri::Tool::Workspace+,
17
+ # etc.) autoload naturally.
18
+ module Pikuri
19
+ class Tool
20
+ LOADER = Zeitwerk::Loader.new
21
+ LOADER.tag = 'pikuri-workspace'
22
+ LOADER.push_dir(File.expand_path('pikuri/tool', __dir__), namespace: Pikuri::Tool)
23
+ LOADER.ignore(File.expand_path('pikuri-workspace.rb', __dir__))
24
+ LOADER.setup
25
+ LOADER.eager_load
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pikuri-workspace
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Martin Vysny
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pikuri-core
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 0.0.3
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 0.0.3
27
+ description: |
28
+ pikuri-workspace adds "operate on a directory tree" to pikuri-core
29
+ agents: the +Pikuri::Tool::Workspace+ seam (abstract base +
30
+ +Workspace::Cwd+) that scopes filesystem access to a chosen root,
31
+ the +Pikuri::Tool::Confirmer+ seam (+AUTO_APPROVE+ + +TERMINAL+)
32
+ for user-state mutations, and five tools wired to those seams:
33
+ +Pikuri::Tool::Read+, +Pikuri::Tool::Write+, +Pikuri::Tool::Edit+,
34
+ +Pikuri::Tool::Grep+, and +Pikuri::Tool::Glob+. Self-contained —
35
+ no shell execution; +Pikuri::Tool::Bash+ ships in pikuri-code on
36
+ top of these.
37
+ email:
38
+ - martin@vysny.me
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - README.md
44
+ - lib/pikuri-workspace.rb
45
+ - lib/pikuri/tool/confirmer.rb
46
+ - lib/pikuri/tool/edit.rb
47
+ - lib/pikuri/tool/glob.rb
48
+ - lib/pikuri/tool/grep.rb
49
+ - lib/pikuri/tool/read.rb
50
+ - lib/pikuri/tool/workspace.rb
51
+ - lib/pikuri/tool/write.rb
52
+ homepage: https://codeberg.org/mvysny/pikuri
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ source_code_uri: https://codeberg.org/mvysny/pikuri/src/branch/master
57
+ changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
58
+ bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
59
+ rubygems_mfa_required: 'true'
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.3'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.5.22
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: Filesystem tools (Read/Write/Edit/Grep/Glob) + Workspace + Confirmer seams
79
+ for pikuri.
80
+ test_files: []