simple_english 0.4.1 → 0.5.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 +13 -0
- data/lib/simple_english/cli.rb +25 -13
- data/lib/simple_english/client/daemon.rb +12 -3
- data/lib/simple_english/lint/finding.rb +16 -1
- data/lib/simple_english/model_context_protocol.rb +134 -0
- data/lib/simple_english/setup/config.rb +12 -1
- data/lib/simple_english/version.rb +1 -1
- data/lib/simple_english.rb +2 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8021741a0615e3d936a8a9bdabe7579fb1fa9641ce5061bf2d3a0e2bb24dd4d9
|
|
4
|
+
data.tar.gz: 87fb729f7e72ef9af5d3c63b305fdff9bea3b3aec034429ac52eaffb54639559
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz: '
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '058e1303247e2ca2aee046e1ff1c9341ff96fae26f4c4fcbb67410cd7652d0d806be163c0abb7c393ce81a725bc8b94a0a4faaeee09c89d8f04daf738a7dd677'
|
|
7
|
+
data.tar.gz: 57e8431a0c71e31a3f14d3ab6f776dfe10e0f49f60a279c7b0d4a53f7d432594a4d79af76700cc6fbc45b26767258eca3a957817cd8574f05fd64c4cff09f223
|
data/README.md
CHANGED
|
@@ -214,6 +214,19 @@ verifies. Closing the Vale gaps needs scripts or an external
|
|
|
214
214
|
tagger, and that erases Vale's main advantage: one binary with no
|
|
215
215
|
service behind it.
|
|
216
216
|
|
|
217
|
+
## Agent integrations
|
|
218
|
+
|
|
219
|
+
Pi, Claude Code, and Codex can lint the prose they write. The adapters
|
|
220
|
+
live under `integrations/`, unreleased. Try them from a checkout:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
claude --plugin-dir integrations/claude-code # the hook feeds findings back
|
|
224
|
+
pi install integrations/pi -l # the se_lint tool
|
|
225
|
+
se mcp # an MCP server, for any MCP client
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full story.
|
|
229
|
+
|
|
217
230
|
## Develop
|
|
218
231
|
|
|
219
232
|
To change the linter, add rules, or run the tests, read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
data/lib/simple_english/cli.rb
CHANGED
|
@@ -20,7 +20,11 @@ module SimpleEnglish
|
|
|
20
20
|
(first == "-" || !first.start_with?("-"))
|
|
21
21
|
given_args = ["lint", *given_args]
|
|
22
22
|
end
|
|
23
|
-
|
|
23
|
+
# Thor's own commands (help, tree) return junk like nil or a
|
|
24
|
+
# Hash, not an exit status. Contract: start always returns an
|
|
25
|
+
# Integer so every caller (bin/se, tests) can exit on it.
|
|
26
|
+
status = super
|
|
27
|
+
status.is_a?(Integer) ? status : 0
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
# The test entry point. It keeps the old module interface.
|
|
@@ -72,6 +76,12 @@ module SimpleEnglish
|
|
|
72
76
|
0
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
desc "mcp", "Run the MCP stdio server (one JSON-RPC message per line)"
|
|
80
|
+
def mcp
|
|
81
|
+
SimpleEnglish::ModelContextProtocol.run
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
75
85
|
desc "serve", "Run the lint daemon in the foreground"
|
|
76
86
|
method_option :port, type: :numeric, default: SimpleEnglish::Client::DEFAULT_PORT,
|
|
77
87
|
desc: "Port to listen on"
|
|
@@ -89,7 +99,7 @@ module SimpleEnglish
|
|
|
89
99
|
return 0
|
|
90
100
|
end
|
|
91
101
|
warn "error: detached daemon did not come up. " \
|
|
92
|
-
"Run
|
|
102
|
+
"Run `#{Client.runner} serve` and read its output."
|
|
93
103
|
return 2
|
|
94
104
|
end
|
|
95
105
|
log = options[:"dev-log"] ? File.open(options[:"dev-log"], "w") : $stderr
|
|
@@ -128,11 +138,22 @@ module SimpleEnglish
|
|
|
128
138
|
# "-" stays as-is for stdin. Directories expand to all lintable files.
|
|
129
139
|
# Glob output keeps a "./" prefix when the argument is ".". Strip it so
|
|
130
140
|
# paths and config ignore globs always see the same form.
|
|
141
|
+
# These trees hold third-party prose, not the user's. The tool lints
|
|
142
|
+
# what the user owns. Skipped only during directory expansion. An
|
|
143
|
+
# explicit path always lints.
|
|
144
|
+
# Dir.glob never returns dotfiles, so ".git" is inert today. It
|
|
145
|
+
# guards a future FNM_DOTMATCH from turning git internals back on.
|
|
146
|
+
# .gitignore is not read. Users put ignore: globs in the config for
|
|
147
|
+
# build output and generated files.
|
|
148
|
+
SKIPPED_DIRS = [".git", "node_modules", "vendor"].freeze
|
|
149
|
+
|
|
131
150
|
def self.expand_paths(argv)
|
|
132
151
|
extensions = (SimpleEnglish::Extractor::EXTENSION_LANGUAGES.keys.map { |e| e.delete_prefix(".") } + ["md"]).uniq.join(",")
|
|
133
152
|
argv.flat_map do |path|
|
|
134
153
|
if File.directory?(path)
|
|
135
|
-
Dir.glob(File.join(path, "**/*.{#{extensions}}"))
|
|
154
|
+
Dir.glob(File.join(path, "**/*.{#{extensions}}"))
|
|
155
|
+
.reject { |p| p.split(File::SEPARATOR).any? { |s| SKIPPED_DIRS.include?(s) } }
|
|
156
|
+
.sort
|
|
136
157
|
else
|
|
137
158
|
path
|
|
138
159
|
end
|
|
@@ -175,16 +196,7 @@ module SimpleEnglish
|
|
|
175
196
|
)
|
|
176
197
|
else
|
|
177
198
|
results.each do |path, finding|
|
|
178
|
-
|
|
179
|
-
if finding.column
|
|
180
|
-
location += ":#{finding.column}"
|
|
181
|
-
if finding.end_line && finding.end_column
|
|
182
|
-
finish = (finding.end_line == finding.line) ? finding.end_column :
|
|
183
|
-
"#{finding.end_line}:#{finding.end_column}"
|
|
184
|
-
location += "-#{finish}"
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
puts "#{location}: [#{finding.rule}] #{finding.message}"
|
|
199
|
+
puts finding.to_line(path)
|
|
188
200
|
end
|
|
189
201
|
end
|
|
190
202
|
end
|
|
@@ -19,6 +19,15 @@ module SimpleEnglish
|
|
|
19
19
|
DEFAULT_PORT = 8181
|
|
20
20
|
REQUEST_TIMEOUT = 30
|
|
21
21
|
|
|
22
|
+
# The executable that daemon advice tells you to run. Resolved
|
|
23
|
+
# from this file, so it names the code that is actually loaded:
|
|
24
|
+
# a checkout names its bin/se, a gem install names its bundled
|
|
25
|
+
# bin/se. $PROGRAM_NAME is wrong for library callers: their host
|
|
26
|
+
# script is not se.
|
|
27
|
+
def runner
|
|
28
|
+
File.expand_path("../../../bin/se", __dir__)
|
|
29
|
+
end
|
|
30
|
+
|
|
22
31
|
def url
|
|
23
32
|
ENV.fetch("SE_SERVER_URL") { "http://localhost:#{DEFAULT_PORT}" }
|
|
24
33
|
end
|
|
@@ -113,7 +122,7 @@ module SimpleEnglish
|
|
|
113
122
|
"It is not restarted automatically."
|
|
114
123
|
else
|
|
115
124
|
warn "se: daemon at #{url} is not a handshake-capable se daemon. " \
|
|
116
|
-
"Run
|
|
125
|
+
"Run `#{runner} serve` to replace it."
|
|
117
126
|
end
|
|
118
127
|
return true
|
|
119
128
|
end
|
|
@@ -151,7 +160,7 @@ module SimpleEnglish
|
|
|
151
160
|
if daemon
|
|
152
161
|
check_daemon(daemon)
|
|
153
162
|
else
|
|
154
|
-
warn "error: se daemon did not come up. Run
|
|
163
|
+
warn "error: se daemon did not come up. Run `#{runner} serve` and read its output."
|
|
155
164
|
false
|
|
156
165
|
end
|
|
157
166
|
end
|
|
@@ -171,7 +180,7 @@ module SimpleEnglish
|
|
|
171
180
|
return
|
|
172
181
|
end
|
|
173
182
|
fix = ENV["SE_SERVER_URL"] ? "It is not restarted automatically." :
|
|
174
|
-
"Run
|
|
183
|
+
"Run `#{runner} serve --detached` to restart it."
|
|
175
184
|
warn "se: daemon at #{url} runs se #{daemon["version"]}, different code than this install " \
|
|
176
185
|
"(#{VERSION}). #{fix} Linting against it meanwhile."
|
|
177
186
|
end
|
|
@@ -5,5 +5,20 @@
|
|
|
5
5
|
# daemon can have a start column without an end position.
|
|
6
6
|
|
|
7
7
|
module SimpleEnglish
|
|
8
|
-
Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message)
|
|
8
|
+
Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message) do
|
|
9
|
+
# One finding as a text line: `path:line:col-end: [rule] message`.
|
|
10
|
+
# The CLI text output, the MCP tool, and the hook feedback all share
|
|
11
|
+
# this format. Keep them in step by rendering through this method.
|
|
12
|
+
def to_line(path)
|
|
13
|
+
location = "#{path}:#{line}"
|
|
14
|
+
if column
|
|
15
|
+
location += ":#{column}"
|
|
16
|
+
if end_line && end_column
|
|
17
|
+
finish = (end_line == line) ? end_column : "#{end_line}:#{end_column}"
|
|
18
|
+
location += "-#{finish}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
"#{location}: [#{rule}] #{message}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
9
24
|
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# MCP JSON-RPC server for the lint engine. `respond` is pure: request
|
|
6
|
+
# Hash in, response Hash or nil (notifications) out. `run` drives it
|
|
7
|
+
# over stdio, one JSON-RPC message per line. Protocol facts:
|
|
8
|
+
# integrations/verification.md.
|
|
9
|
+
|
|
10
|
+
module SimpleEnglish
|
|
11
|
+
module ModelContextProtocol
|
|
12
|
+
SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"].freeze
|
|
13
|
+
LATEST_VERSION = "2025-06-18"
|
|
14
|
+
SERVER_NAME = "simple-english"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def run(io: $stdin, out: $stdout, linter: method(:default_linter))
|
|
19
|
+
# A piped stdout buffers until 4 KB or EOF. An MCP client keeps
|
|
20
|
+
# stdin open while it waits for the answer, so an unflushed
|
|
21
|
+
# response never arrives and the client drops the server.
|
|
22
|
+
out.sync = true
|
|
23
|
+
while (line = io.gets)
|
|
24
|
+
line = line.strip
|
|
25
|
+
next if line.empty?
|
|
26
|
+
begin
|
|
27
|
+
request = JSON.parse(line)
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
next unless request.is_a?(Hash)
|
|
32
|
+
response =
|
|
33
|
+
begin
|
|
34
|
+
respond(request, linter: linter)
|
|
35
|
+
rescue => e
|
|
36
|
+
# A broken tool call (unreadable file, daemon hiccup) must not
|
|
37
|
+
# kill the loop: answer it, then serve the next line.
|
|
38
|
+
request.key?("id") ? error(request["id"], -32603, "lint failed: #{e.class}: #{e.message}") : nil
|
|
39
|
+
end
|
|
40
|
+
out.puts JSON.generate(response) if response
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The CLI's lint pipeline as a callable: .simple-english.yml decides
|
|
45
|
+
# which paths and rules count, then the daemon lints the file.
|
|
46
|
+
def default_linter(path)
|
|
47
|
+
config = SimpleEnglish::Config.load
|
|
48
|
+
return [] if SimpleEnglish::Config.ignore?(config, path)
|
|
49
|
+
findings = SimpleEnglish.lint_file(path)
|
|
50
|
+
findings.nil? ? nil : SimpleEnglish::Config.filter(config, path, findings)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def respond(request, linter:)
|
|
54
|
+
return nil unless request.key?("id")
|
|
55
|
+
id = request.fetch("id")
|
|
56
|
+
method = request["method"]
|
|
57
|
+
return error(id, -32600, "Invalid Request") unless method.is_a?(String)
|
|
58
|
+
params = request["params"]
|
|
59
|
+
params = {} unless params.is_a?(Hash)
|
|
60
|
+
case method
|
|
61
|
+
when "initialize" then ok(id, initialize_result(params["protocolVersion"]))
|
|
62
|
+
when "tools/list" then ok(id, {"tools" => [lint_tool]})
|
|
63
|
+
when "tools/call" then tools_call(id, params, linter)
|
|
64
|
+
else error(id, -32601, "Method not found: #{method}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize_result(requested_version)
|
|
69
|
+
version = SUPPORTED_VERSIONS.include?(requested_version) ? requested_version : LATEST_VERSION
|
|
70
|
+
{
|
|
71
|
+
"protocolVersion" => version,
|
|
72
|
+
"capabilities" => {"tools" => {}},
|
|
73
|
+
"serverInfo" => {"name" => SERVER_NAME, "version" => SimpleEnglish::VERSION}
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def lint_tool
|
|
78
|
+
{
|
|
79
|
+
"name" => "lint",
|
|
80
|
+
"description" =>
|
|
81
|
+
"Lint Markdown prose and code comments against plain-English rules " \
|
|
82
|
+
"(short sentences, active voice, no jargon, no filler). " \
|
|
83
|
+
"Input: a file path. Returns one line per finding, or 'clean' when " \
|
|
84
|
+
"nothing is flagged. Fix the findings, then call again until clean.",
|
|
85
|
+
"inputSchema" => {
|
|
86
|
+
"type" => "object",
|
|
87
|
+
"properties" => {"path" => {"type" => "string", "description" => "File to lint"}},
|
|
88
|
+
"required" => ["path"]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tools_call(id, params, linter)
|
|
94
|
+
tool = params["name"]
|
|
95
|
+
return error(id, -32602, "Unknown tool: #{tool}") unless tool == "lint"
|
|
96
|
+
arguments = params["arguments"]
|
|
97
|
+
arguments = {} unless arguments.is_a?(Hash)
|
|
98
|
+
path = arguments["path"]
|
|
99
|
+
return ok(id, tool_error("the path argument is required.")) if path.nil? || path.to_s.empty?
|
|
100
|
+
return ok(id, tool_error("#{path} is a directory. Give one file.")) if File.directory?(path)
|
|
101
|
+
return ok(id, tool_error("file not found: #{path}")) unless File.exist?(path)
|
|
102
|
+
findings =
|
|
103
|
+
begin
|
|
104
|
+
linter.call(path)
|
|
105
|
+
rescue SimpleEnglish::Config::ConfigError => e
|
|
106
|
+
return ok(id, tool_error("invalid .simple-english.yml: #{e.message}"))
|
|
107
|
+
end
|
|
108
|
+
if findings.nil?
|
|
109
|
+
# A tool error, not a protocol error: clients show the text to the
|
|
110
|
+
# model, and this message is the one that tells it how to recover.
|
|
111
|
+
ok(id, tool_error("the se daemon did not answer. Run `se serve --detached` and retry."))
|
|
112
|
+
else
|
|
113
|
+
ok(id, {"content" => [{"type" => "text", "text" => render(path, findings)}], "isError" => false})
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def render(path, findings)
|
|
118
|
+
return "clean" if findings.empty?
|
|
119
|
+
findings.map { |finding| finding.to_line(path) }.join("\n")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def tool_error(text)
|
|
123
|
+
{"content" => [{"type" => "text", "text" => text}], "isError" => true}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ok(id, result)
|
|
127
|
+
{"jsonrpc" => "2.0", "id" => id, "result" => result}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def error(id, code, message)
|
|
131
|
+
{"jsonrpc" => "2.0", "id" => id, "error" => {"code" => code, "message" => message}}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -22,8 +22,19 @@ module SimpleEnglish
|
|
|
22
22
|
raise ConfigError, ".simple-english.yml: #{e.message}"
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Hooks pass absolute paths while ignore globs are written
|
|
26
|
+
# relative to the repo root (the CWD). Match both forms.
|
|
27
|
+
# realpath because CWD may be a symlink-resolved path while the
|
|
28
|
+
# caller's string still carries the symlink (TMPDIR on macOS).
|
|
25
29
|
def ignore?(config, path)
|
|
26
|
-
|
|
30
|
+
candidates = [path, relative_candidate(path)].compact
|
|
31
|
+
candidates.any? { |candidate| config[:ignore].any? { |pattern| matches?(pattern, candidate) } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def relative_candidate(path)
|
|
35
|
+
pwd = Dir.pwd + "/"
|
|
36
|
+
resolved = File.exist?(path) ? File.realpath(path) : File.expand_path(path)
|
|
37
|
+
resolved.start_with?(pwd) ? resolved.delete_prefix(pwd) : nil
|
|
27
38
|
end
|
|
28
39
|
|
|
29
40
|
# File.fnmatch has no globstar: `**` never crosses directories.
|
data/lib/simple_english.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "simple_english/client/language_tool"
|
|
|
21
21
|
require_relative "simple_english/client/daemon"
|
|
22
22
|
require_relative "simple_english/daemon/engine"
|
|
23
23
|
require_relative "simple_english/daemon/http"
|
|
24
|
+
require_relative "simple_english/model_context_protocol"
|
|
24
25
|
require_relative "simple_english/daemon/server"
|
|
25
26
|
|
|
26
27
|
module SimpleEnglish
|
|
@@ -49,7 +50,7 @@ module SimpleEnglish
|
|
|
49
50
|
return nil unless Client.ensure_up(install: Install.from_env)
|
|
50
51
|
result = Client.lint(text, language: language)
|
|
51
52
|
if result.nil?
|
|
52
|
-
warn "error: se daemon did not answer. Run
|
|
53
|
+
warn "error: se daemon did not answer. Run `#{Client.runner} serve` and read its output."
|
|
53
54
|
end
|
|
54
55
|
result
|
|
55
56
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: simple_english
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- TonyCTHsu
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: tree_sitter_language_pack
|
|
@@ -153,6 +153,7 @@ files:
|
|
|
153
153
|
- lib/simple_english/lint/segment.rb
|
|
154
154
|
- lib/simple_english/lint/span.rb
|
|
155
155
|
- lib/simple_english/lint/suppressions.rb
|
|
156
|
+
- lib/simple_english/model_context_protocol.rb
|
|
156
157
|
- lib/simple_english/setup/config.rb
|
|
157
158
|
- lib/simple_english/setup/fingerprint.rb
|
|
158
159
|
- lib/simple_english/setup/install.rb
|
|
@@ -165,7 +166,7 @@ licenses:
|
|
|
165
166
|
metadata:
|
|
166
167
|
homepage_uri: https://github.com/TonyCTHsu/simple-english
|
|
167
168
|
source_code_uri: https://github.com/TonyCTHsu/simple-english
|
|
168
|
-
changelog_uri: https://github.com/TonyCTHsu/simple-english/blob/v0.
|
|
169
|
+
changelog_uri: https://github.com/TonyCTHsu/simple-english/blob/v0.5.0/CHANGELOG.md
|
|
169
170
|
post_install_message:
|
|
170
171
|
rdoc_options: []
|
|
171
172
|
require_paths:
|