Skip to content

Commit 516f73c

Browse files
committed
Unify project identification: use absolute directory path and only in CLI allow project name
1 parent c8114a1 commit 516f73c

8 files changed

Lines changed: 75 additions & 58 deletions

File tree

docs/cli.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ python -m finecode run [options] <action> [<action> ...] [payload] [--config.<ke
5252
| Option | Description |
5353
|---|---|
5454
| `--workdir=<path>` | Use `<path>` as the workspace root instead of `cwd` |
55-
| `--project=<name>` | Run only in this project. Repeatable for multiple projects. |
55+
| `--project=<name>` | Run only in this project (matched by `[project].name` from `pyproject.toml`). Repeatable for multiple projects. |
5656
| `--concurrently` | Run actions concurrently within each project |
5757
| `--shared-server` | Connect to the shared persistent WM Server instead of starting a dedicated one |
5858
| `--log-level=<level>` | Set log level: `TRACE`, `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`) |
@@ -127,7 +127,7 @@ See [Preparing Environments](guides/preparing-environments.md) for a full explan
127127
|---|---|
128128
| `--recreate` | Delete and recreate all venvs from scratch |
129129
| `--env-names=<name>` | Restrict handler dependency installation to the named env(s). Repeatable. See note below. |
130-
| `--project=<name>` | Restrict preparation to the named project(s). Repeatable. |
130+
| `--project=<name>` | Restrict preparation to the named project(s) (matched by `[project].name` from `pyproject.toml`). Repeatable. |
131131
| `--log-level=<level>` | Set log level: `TRACE`, `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`) |
132132
| `--debug` | Wait for a debugpy client on port 5680 before starting |
133133
| `--dev-env=<env>` | Override the detected dev environment. One of: `ai`, `ci`, `cli`, `ide`, `precommit` (default: auto-detected) |
@@ -149,7 +149,7 @@ Output is written to `<cwd>/finecode_config_dump/`.
149149

150150
| Option | Description |
151151
|---|---|
152-
| `--project=<name>` | **(Required)** Project to dump config for |
152+
| `--project=<name>` | **(Required)** Project to dump config for (matched by `[project].name` from `pyproject.toml`) |
153153
| `--log-level=<level>` | Set log level: `TRACE`, `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`) |
154154
| `--debug` | Wait for a debugpy client on port 5680 |
155155
| `--dev-env=<env>` | Override the detected dev environment. One of: `ai`, `ci`, `cli`, `ide`, `precommit` (default: auto-detected) |

docs/concepts.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ A **Source Artifact** is a unit of source code that build/publish-style actions
105105

106106
When a source artifact includes FineCode configuration — a `pyproject.toml` with a `[tool.finecode]` section — the Workspace Manager discovers it automatically under the workspace roots provided by the client. Some CLI flags and protocol fields still use the word “project” for compatibility.
107107

108+
### Soruce Artifact identification
109+
110+
The canonical external identifier for a source artifact is its **absolute directory path** (e.g. `/home/user/myrepo/my_package`). This is always unique, language-agnostic, and is what `list_projects` returns in the `path` field. All WM API consumers (LSP, MCP, JSON-RPC) use paths.
111+
112+
The human-readable **project name** is taken from the `[project].name` field in `pyproject.toml`. Names are unique within a workspace in practice (two packages with the same name would break dependency resolution), but paths are used in the API to eliminate any ambiguity. The CLI is the only interface that accepts names — it resolves them to paths before making API calls.
113+
108114
A source artifact may belong to a **workspace** — a set of related source artifacts, often a single directory root but sometimes multiple directories. FineCode handles multi-artifact workspaces transparently: running `python -m finecode run lint` from the workspace root runs lint in all source artifacts that define it.
109115

110116
## Workspace Manager and Extension Runner

src/finecode/cli_app/commands/dump_config_cmd.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,24 @@ async def dump_config(
3131
client = ApiClient()
3232
await client.connect("127.0.0.1", port)
3333
try:
34-
result = await client.add_dir(workdir_path, projects=[project_name])
34+
result = await client.add_dir(workdir_path)
3535
projects = result.get("projects", [])
3636
project = next(
3737
(p for p in projects if p["name"] == project_name), None
3838
)
3939
if project is None:
4040
raise DumpFailed(f"Project '{project_name}' not found")
4141

42-
project_dir_path = pathlib.Path(project["path"])
42+
project_path = project["path"]
43+
project_dir_path = pathlib.Path(project_path)
4344
source_file_path = project_dir_path / "pyproject.toml"
4445
target_file_path = project_dir_path / "finecode_config_dump" / "pyproject.toml"
4546

4647
try:
47-
project_raw_config = await client.get_project_raw_config(project_name)
48+
project_raw_config = await client.get_project_raw_config(project_path)
4849
await client.run_action(
4950
action="dump_config",
50-
project=project_name,
51+
project=project_path,
5152
params={
5253
"source_file_path": str(source_file_path),
5354
"project_raw_config": project_raw_config,

src/finecode/cli_app/commands/prepare_envs_cmd.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,16 @@ async def _run(
106106
if p["path"] != workdir_str and p["status"] == "CONFIG_VALID"
107107
]
108108

109+
project_paths: list[str] | None = None
109110
if project_names is not None:
110111
unknown = [
111112
n for n in project_names if not any(p["name"] == n for p in projects)
112113
]
113114
if unknown:
114115
raise PrepareEnvsFailed(f"Unknown project(s): {unknown}")
115116
other_projects = [p for p in other_projects if p["name"] in project_names]
117+
# Resolve names to paths for all subsequent API calls (canonical identifier)
118+
project_paths = [p["path"] for p in projects if p["name"] in project_names]
116119

117120
logger.info(f"Found {len(projects)} project(s): {[p['name'] for p in projects]}")
118121

@@ -123,14 +126,14 @@ async def _check_or_remove_dw(project: dict) -> None:
123126
if recreate:
124127
logger.trace(f"Recreate env 'dev_workspace' in project '{project['name']}'")
125128
try:
126-
await client.remove_env(project["name"], "dev_workspace")
129+
await client.remove_env(project["path"], "dev_workspace")
127130
except ApiError as exc:
128131
raise PrepareEnvsFailed(
129132
f"Failed to remove env for '{project['name']}': {exc}"
130133
) from exc
131134
else:
132135
try:
133-
valid = await client.check_env(project["name"], "dev_workspace")
136+
valid = await client.check_env(project["path"], "dev_workspace")
134137
except ApiError as exc:
135138
raise PrepareEnvsFailed(
136139
f"Failed to check env for '{project['name']}': {exc}"
@@ -141,7 +144,7 @@ async def _check_or_remove_dw(project: dict) -> None:
141144
f"invalid, recreating it"
142145
)
143146
try:
144-
await client.remove_env(project["name"], "dev_workspace")
147+
await client.remove_env(project["path"], "dev_workspace")
145148
except ApiError as exc:
146149
raise PrepareEnvsFailed(
147150
f"Failed to remove invalid env for '{project['name']}': {exc}"
@@ -174,7 +177,7 @@ async def _check_or_remove_dw(project: dict) -> None:
174177
try:
175178
create_dw_result = await client.run_action(
176179
action="create_envs",
177-
project=current_project["name"],
180+
project=current_project["path"],
178181
# 'recreate' is handled for dev_workspace envs above, no need to pass here
179182
params={"envs": dw_envs},
180183
options=dw_options,
@@ -192,7 +195,7 @@ async def _check_or_remove_dw(project: dict) -> None:
192195
try:
193196
prepare_dw_result = await client.run_action(
194197
action="prepare_handler_envs",
195-
project=current_project["name"],
198+
project=current_project["path"],
196199
params={"envs": dw_envs},
197200
options=dw_options,
198201
)
@@ -228,7 +231,7 @@ async def _check_or_remove_dw(project: dict) -> None:
228231
try:
229232
create_result = await client.run_batch(
230233
actions=["create_envs"],
231-
projects=project_names,
234+
projects=project_paths,
232235
options=common_options,
233236
)
234237
except ApiError as exc:
@@ -239,7 +242,7 @@ async def _check_or_remove_dw(project: dict) -> None:
239242
try:
240243
runners_result = await client.run_batch(
241244
actions=["prepare_runner_envs"],
242-
projects=project_names,
245+
projects=project_paths,
243246
options=common_options,
244247
)
245248
except ApiError as exc:
@@ -251,7 +254,7 @@ async def _check_or_remove_dw(project: dict) -> None:
251254
try:
252255
batch_result = await client.run_batch(
253256
actions=["prepare_handler_envs"],
254-
projects=project_names,
257+
projects=project_paths,
255258
params=handler_params,
256259
options=common_options,
257260
)

src/finecode/cli_app/commands/run_cmd.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,21 @@ async def run_actions(
5656
"Warning: --config overrides are ignored in --shared-server mode. ",
5757
err=True,
5858
)
59-
await client.add_dir(
60-
workdir_path,
61-
projects=projects_names if own_server else None,
62-
)
59+
await client.add_dir(workdir_path)
60+
61+
# Resolve project names (CLI option) to paths (canonical API identifier).
62+
project_paths: list[str] | None = None
63+
if projects_names is not None:
64+
all_projects = await client.list_projects()
65+
unknown = [
66+
n for n in projects_names
67+
if not any(p["name"] == n for p in all_projects)
68+
]
69+
if unknown:
70+
raise RunFailed(f"Unknown project(s): {unknown}")
71+
project_paths = [
72+
p["path"] for p in all_projects if p["name"] in projects_names
73+
]
6374

6475
params_by_project: dict[str, dict[str, typing.Any]] = {}
6576
if map_payload_fields:
@@ -73,7 +84,7 @@ async def run_actions(
7384
try:
7485
batch_result = await client.run_batch(
7586
actions=actions,
76-
projects=projects_names,
87+
projects=project_paths,
7788
params=action_payload,
7889
params_by_project=params_by_project or None,
7990
options={

src/finecode/mcp_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def _register_action_tools(mcp: FastMCP, actions: list[dict]) -> None:
3333

3434
def _make_handler(action_name: str):
3535
async def handler(
36-
project: str,
36+
project: str, # absolute path to the project directory (e.g. /home/user/myrepo)
3737
file_paths: list[str] | None = None,
3838
) -> dict:
3939
return await _wm_client.run_action(

src/finecode/wm_server/config/read_configs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async def read_projects_in_dir(
5454
is_new_project = def_file.parent not in ws_context.ws_projects
5555
if is_new_project:
5656
new_project = domain.Project(
57-
name=def_file.parent.name,
57+
name=project_def.get("project", {}).get("name", def_file.parent.name),
5858
dir_path=def_file.parent,
5959
def_path=def_file,
6060
status=status,

src/finecode/wm_server/wm_server.py

Lines changed: 32 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ def _project_to_dict(project: domain.Project) -> dict:
145145
}
146146

147147

148+
def _find_project_by_path(
149+
ws_context: context.WorkspaceContext, project_path: str
150+
) -> domain.Project | None:
151+
"""Look up a project by its absolute directory path (canonical external identifier)."""
152+
return ws_context.ws_projects.get(pathlib.Path(project_path))
153+
154+
148155
# -- Implemented handlers --------------------------------------------------
149156

150157

@@ -158,22 +165,22 @@ async def _handle_list_projects(
158165
async def _handle_get_project_raw_config(
159166
params: dict | None, ws_context: context.WorkspaceContext
160167
) -> dict:
161-
"""Return the resolved raw config for a project by name.
168+
"""Return the resolved raw config for a project by path.
162169
163-
Params: ``{"project": "project_name"}``
170+
Params: ``{"project": "/abs/path/to/project"}``
164171
Result: ``{"rawConfig": {...}}``
165172
"""
166173
params = params or {}
167-
project_name = params.get("project")
168-
if not project_name:
174+
project_path = params.get("project")
175+
if not project_path:
169176
raise ValueError("project parameter is required")
170177

171-
for project_dir_path, project in ws_context.ws_projects.items():
172-
if project.name == project_name:
173-
raw_config = ws_context.ws_projects_raw_configs.get(project_dir_path, {})
174-
return {"rawConfig": raw_config}
178+
project = _find_project_by_path(ws_context, project_path)
179+
if project is None:
180+
raise ValueError(f"Project '{project_path}' not found")
175181

176-
raise ValueError(f"Project '{project_name}' not found")
182+
raw_config = ws_context.ws_projects_raw_configs.get(project.dir_path, {})
183+
return {"rawConfig": raw_config}
177184

178185

179186
async def _handle_find_project_for_file(
@@ -223,7 +230,7 @@ async def _handle_add_dir(
223230
When false, configs are read and actions collected without starting any
224231
runners. Useful when runner environments may not exist yet (e.g. before
225232
running prepare-envs).
226-
projects: list[str] | null - optional list of project names to initialize.
233+
projects: list[str] | null - optional list of project paths (absolute) to initialize.
227234
Projects not in this list are discovered but not config-initialized or
228235
started. Omit (or pass null) to initialize all projects.
229236
Calling add_dir again for the same dir with a different filter (or no
@@ -257,7 +264,7 @@ async def _handle_add_dir(
257264
]
258265

259266
if projects_filter is not None:
260-
projects_to_init = [p for p in projects_to_init if p.name in projects_filter]
267+
projects_to_init = [p for p in projects_to_init if str(p.dir_path) in projects_filter]
261268

262269
for project in projects_to_init:
263270
await read_configs.read_project_config(
@@ -350,19 +357,19 @@ async def _handle_remove_dir(
350357
async def _handle_list_actions(
351358
params: dict | None, ws_context: context.WorkspaceContext
352359
) -> dict:
353-
"""List available actions, optionally filtered by project name."""
360+
"""List available actions, optionally filtered by project path."""
354361
project_filter = (params or {}).get("project")
355362
actions = []
356363
for project in ws_context.ws_projects.values():
357-
if project_filter and project.name != project_filter:
364+
if project_filter and str(project.dir_path) != project_filter:
358365
continue
359366
if not isinstance(project, domain.CollectedProject):
360367
continue
361368
for action in project.actions:
362369
actions.append({
363370
"name": action.name,
364371
"source": action.source,
365-
"project": project.name,
372+
"project": str(project.dir_path),
366373
"handlers": [
367374
{"name": h.name, "source": h.source, "env": h.env}
368375
for h in action.handlers
@@ -386,12 +393,8 @@ async def _handle_run_action(
386393
if not project_name:
387394
raise ValueError("project parameter is required")
388395

389-
# Find the project
390-
project = None
391-
for proj in ws_context.ws_projects.values():
392-
if proj.name == project_name:
393-
project = proj
394-
break
396+
# Find the project by its absolute directory path (canonical external identifier)
397+
project = _find_project_by_path(ws_context, project_name)
395398

396399
if project is None:
397400
raise ValueError(f"Project '{project_name}' not found")
@@ -533,7 +536,7 @@ async def _handle_start_runners(
533536

534537
projects = list(ws_context.ws_projects.values())
535538
if project_names is not None:
536-
projects = [p for p in projects if p.name in project_names]
539+
projects = [p for p in projects if str(p.dir_path) in project_names]
537540

538541
try:
539542
await runner_manager.start_runners_with_presets(
@@ -551,7 +554,7 @@ async def _handle_runners_check_env(
551554
) -> dict:
552555
"""Check whether an environment is valid for a given project.
553556
554-
Params: ``{"project": "project_name", "envName": "dev_workspace"}``
557+
Params: ``{"project": "/abs/path/to/project", "envName": "dev_workspace"}``
555558
Result: ``{"valid": bool}``
556559
"""
557560
from finecode.wm_server.runner import runner_manager
@@ -563,9 +566,7 @@ async def _handle_runners_check_env(
563566
if not project_name or not env_name:
564567
raise ValueError("project and envName are required")
565568

566-
project = next(
567-
(p for p in ws_context.ws_projects.values() if p.name == project_name), None
568-
)
569+
project = _find_project_by_path(ws_context, project_name)
569570
if project is None:
570571
raise ValueError(f"Project '{project_name}' not found")
571572

@@ -582,7 +583,7 @@ async def _handle_runners_remove_env(
582583
583584
Stops the runner if running, then deletes the environment directory.
584585
585-
Params: ``{"project": "project_name", "envName": "dev_workspace"}``
586+
Params: ``{"project": "/abs/path/to/project", "envName": "dev_workspace"}``
586587
Result: ``{}``
587588
"""
588589
from finecode.wm_server.runner import runner_manager
@@ -594,9 +595,7 @@ async def _handle_runners_remove_env(
594595
if not project_name or not env_name:
595596
raise ValueError("project and envName are required")
596597

597-
project = next(
598-
(p for p in ws_context.ws_projects.values() if p.name == project_name), None
599-
)
598+
project = _find_project_by_path(ws_context, project_name)
600599
if project is None:
601600
raise ValueError(f"Project '{project_name}' not found")
602601

@@ -747,7 +746,7 @@ async def _handle_run_batch(
747746
748747
Params:
749748
actions: list[str] - action names to run
750-
projects: list[str] | None - project names to filter; absent/null means all projects
749+
projects: list[str] | None - project paths (absolute) to filter; absent/null means all projects
751750
params: dict - action payload shared across all projects
752751
params_by_project: dict[str, dict] - per-project payload overrides keyed by project path string
753752
options:
@@ -786,13 +785,10 @@ async def _handle_run_batch(
786785
# Build actions_by_project (path -> [action_names])
787786
if project_names is not None:
788787
actions_by_project: dict[pathlib.Path, list[str]] = {}
789-
for project_name in project_names:
790-
project = next(
791-
(p for p in ws_context.ws_projects.values() if p.name == project_name),
792-
None,
793-
)
788+
for project_path_str in project_names:
789+
project = _find_project_by_path(ws_context, project_path_str)
794790
if project is None:
795-
raise ValueError(f"Project '{project_name}' not found")
791+
raise ValueError(f"Project '{project_path_str}' not found")
796792
actions_by_project[project.dir_path] = list(actions)
797793
else:
798794
actions_by_project = run_service.find_projects_with_actions(ws_context, actions)

0 commit comments

Comments
 (0)