Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ Store them once per machine:

```
$ tfscli auth login
Server URL (e.g. https://tfs.company.com:8080/tfs): https://tfs.company.com:8080/tfs
Server URL (e.g. https://tfs.example.com:8080/tfs): https://tfs.example.com:8080/tfs
Collection (e.g. DefaultCollection): DefaultCollection
Personal access token:
Logged in to https://tfs.company.com:8080/tfs, collection DefaultCollection, as Jane Doe
Logged in to https://tfs.example.com:8080/tfs, collection DefaultCollection, as Jane Doe
```

The token is typed with echo turned off. The three values are checked against the server before anything is written: a rejected token, or a URL and collection that lead nowhere, is reported as an error and leaves nothing behind. The command takes no flags, refuses `--api-version`, and sends its check without `api-version` whatever the config file or `TFSCLI_API_VERSION` say.
Expand All @@ -106,7 +106,7 @@ The credential is written to `$XDG_DATA_HOME/tfscli/auth.json`, or `~/.local/sha
Where an interactive login is impossible, put the same JSON into `TFSCLI_AUTH`. When the variable is set, `auth.json` is not read:

```
TFSCLI_AUTH='{"url": "https://tfs.company.com:8080/tfs", "collection": "DefaultCollection", "pat": "…"}'
TFSCLI_AUTH='{"url": "https://tfs.example.com:8080/tfs", "collection": "DefaultCollection", "pat": "…"}'
```

All three fields are required. The URL, the collection, and the token always come from the same source, and no flag, separate environment variable, or config key can supply any of them, so a mistyped or injected server address cannot make tfscli send the token to another host.
Expand Down Expand Up @@ -323,8 +323,8 @@ An empty result reads `Work items: none` or `Relations: none`. The time of the r

```
$ tfscli wit work-items get -p MyProject 12345 --verbose
OPTIONS https://tfs.company.com:8080/tfs/DefaultCollection/_apis 200 41.2087ms
GET https://tfs.company.com:8080/tfs/DefaultCollection/MyProject/_apis/wit/workitems/12345?api-version=7.1 200 86.4512ms
OPTIONS https://tfs.example.com:8080/tfs/DefaultCollection/_apis 200 41.2087ms
GET https://tfs.example.com:8080/tfs/DefaultCollection/MyProject/_apis/wit/workitems/12345?api-version=7.1 200 86.4512ms
```

Lines starting with `[warn]` report a condition worth knowing, such as disabled certificate verification; lines starting with `[info]` report what tfscli did on its own, such as sending a request without a version:
Expand Down Expand Up @@ -374,7 +374,7 @@ $ tfscli wit work-items get -p MyProject 12345
Error [config]: The requested REST API version of 7.2 is out of range for this server. The latest REST API version this server supports is 7.1. (api-version "7.2" is set by TFSCLI_API_VERSION; remove it to let the server choose the version, or set one the server supports) (HTTP 400)

$ tfscli wit work-items get -p MyProject 12345
Error [network]: cannot reach https://tfs.company.com:8080
Error [network]: cannot reach https://tfs.example.com:8080
```

## Known limitations
Expand Down
2 changes: 1 addition & 1 deletion docs/archive/2026-10-01-tasks-login-api-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ supports. Also establish:
older servers, since `verify` in `internal/cli/auth.go` relies on it (see
TD-01 in `docs/tech-debt.md` for the anonymous case);
- whether `connectionData` also answers when the URL entered at login already
contains a collection, such as `https://tfs.company.com:8080/tfs/DefaultCollection`.
contains a collection, such as `https://tfs.example.com:8080/tfs/DefaultCollection`.
If it does, login accepts and stores that URL, every later command appends
the collection a second time, and the user receives a bare `not_found` with
no hint at the cause.
Expand Down
4 changes: 2 additions & 2 deletions docs/project-brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ An AI agent can call `tfscli`, read work item content, and use it meaningfully i

```json
{
"url": "https://tfs.company.com:8080/tfs",
"url": "https://tfs.example.com:8080/tfs",
"collection": "DefaultCollection",
"pat": "..."
}
Expand Down Expand Up @@ -119,7 +119,7 @@ Error [not_found]: work item 99999 not found in project MyProject (HTTP 404)
Error [forbidden]: no access to project MyProject (HTTP 403)
Error [server]: TFS returned HTTP 500
Error [config]: not logged in: no credential at ~/.local/share/tfscli/auth.json (run "tfscli auth login", or set TFSCLI_AUTH)
Error [network]: cannot reach https://tfs.company.com:8080
Error [network]: cannot reach https://tfs.example.com:8080
```

## Key Dependencies
Expand Down
12 changes: 6 additions & 6 deletions internal/apiclient/apiclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,9 +458,9 @@ func TestNewRejectsBadURL(t *testing.T) {
name string
url string
}{
{"unparseable", "http://tfs.company.com:pot/tfs"},
{"missing scheme", "tfs.company.com:8080/tfs"},
{"unsupported scheme", "ftp://tfs.company.com/tfs"},
{"unparseable", "http://tfs.example.com:pot/tfs"},
{"missing scheme", "tfs.example.com:8080/tfs"},
{"unsupported scheme", "ftp://tfs.example.com/tfs"},
}

for _, tt := range tests {
Expand All @@ -479,7 +479,7 @@ func TestNewRejectsBadURL(t *testing.T) {
func TestNewWarnsWhenVerificationDisabled(t *testing.T) {
logger := &recordingLogger{}
if _, err := New(&config.Config{
URL: "https://tfs.company.com:8080/tfs",
URL: "https://tfs.example.com:8080/tfs",
Collection: "DefaultCollection",
PAT: testPAT,
APIVersion: "7.2",
Expand All @@ -496,7 +496,7 @@ func TestNewWarnsWhenVerificationDisabled(t *testing.T) {
func TestNewStaysSilentWhenVerificationIsOn(t *testing.T) {
logger := &recordingLogger{}
if _, err := New(&config.Config{
URL: "https://tfs.company.com:8080/tfs",
URL: "https://tfs.example.com:8080/tfs",
Collection: "DefaultCollection",
PAT: testPAT,
APIVersion: "7.2",
Expand Down Expand Up @@ -526,7 +526,7 @@ func TestNewRejectsBadCABundle(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := New(&config.Config{
URL: "https://tfs.company.com:8080/tfs",
URL: "https://tfs.example.com:8080/tfs",
Collection: "DefaultCollection",
PAT: testPAT,
APIVersion: "7.2",
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func (g *globals) login(ctx context.Context) error {
// promptAuth asks for the URL, the collection, and the token. Prompts go to
// stderr so that stdout carries only the result.
func (g *globals) promptAuth() (*config.Auth, error) {
_, _ = fmt.Fprint(g.stderr, "Server URL (e.g. https://tfs.company.com:8080/tfs): ")
_, _ = fmt.Fprint(g.stderr, "Server URL (e.g. https://tfs.example.com:8080/tfs): ")
raw, err := g.stdin.ReadLine()
if err != nil {
return nil, inputError("the server URL", err)
Expand Down Expand Up @@ -211,7 +211,7 @@ func refuseUserinfo(raw string) error {
Category: tfserr.Config,
Message: "the server URL contains a user name or password; tfscli authenticates only with " +
"the personal access token it asks for next. Run tfscli auth login again and enter the URL " +
"without them, e.g. https://tfs.company.com/tfs",
"without them, e.g. https://tfs.example.com/tfs",
}
}

Expand Down
10 changes: 5 additions & 5 deletions internal/cli/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,20 +338,20 @@ func TestAuthLoginFailuresWriteNothing(t *testing.T) {
},
{
name: "url without a scheme",
in: func(string) prompter { return typed("tfs.company.com/tfs", "DefaultCollection", "secret-token") },
want: "Error [config]: url \"tfs.company.com/tfs\" must start with http:// or https://\n",
in: func(string) prompter { return typed("tfs.example.com/tfs", "DefaultCollection", "secret-token") },
want: "Error [config]: url \"tfs.example.com/tfs\" must start with http:// or https://\n",
},
{
name: "url with a user name and password",
in: func(string) prompter {
return typed("https://user:hunter2@tfs.company.com/tfs", "DefaultCollection", "secret-token")
return typed("https://user:hunter2@tfs.example.com/tfs", "DefaultCollection", "secret-token")
},
want: "Error [config]: the server URL contains a user name or password; tfscli authenticates only with the personal access token it asks for next. Run tfscli auth login again and enter the URL without them, e.g. https://tfs.company.com/tfs\n",
want: "Error [config]: the server URL contains a user name or password; tfscli authenticates only with the personal access token it asks for next. Run tfscli auth login again and enter the URL without them, e.g. https://tfs.example.com/tfs\n",
},
{
name: "url with a user name only",
in: func(string) prompter {
return typed("https://user@tfs.company.com/tfs", "DefaultCollection", "secret-token")
return typed("https://user@tfs.example.com/tfs", "DefaultCollection", "secret-token")
},
want: "Error [config]: the server URL contains a user name or password;",
},
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const workItemResponse = `{
"System.AssignedTo": {
"displayName": "Jane Doe",
"uniqueName": "COMPANY\\j.doe",
"imageUrl": "https://tfs.company.com:8080/DefaultCollection/_api/_common/identityImage?id=2f1a8c3e"
"imageUrl": "https://tfs.example.com:8080/DefaultCollection/_api/_common/identityImage?id=2f1a8c3e"
},
"System.CreatedDate": "2026-06-14T09:12:33.117Z",
"System.Description": "<div>Customers who receive a <b>partial</b> refund never get the confirmation email.</div>"
Expand Down
10 changes: 5 additions & 5 deletions internal/cli/wiql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ const wiqlResponse = `{
"queryResultType": "workItemLink",
"asOf": "2026-10-04T10:15:00.483Z",
"columns": [
{"referenceName": "System.Id", "name": "ID", "url": "https://tfs.company.com/DefaultCollection/_apis/wit/fields/System.Id"},
{"referenceName": "System.Title", "name": "Title", "url": "https://tfs.company.com/DefaultCollection/_apis/wit/fields/System.Title"}
{"referenceName": "System.Id", "name": "ID", "url": "https://tfs.example.com/DefaultCollection/_apis/wit/fields/System.Id"},
{"referenceName": "System.Title", "name": "Title", "url": "https://tfs.example.com/DefaultCollection/_apis/wit/fields/System.Title"}
],
"workItemRelations": [
{"rel": null, "source": null, "target": {"id": 297, "url": "https://tfs.company.com/DefaultCollection/_apis/wit/workItems/297"}},
{"rel": "System.LinkTypes.Hierarchy-Forward", "source": {"id": 297, "url": "https://tfs.company.com/DefaultCollection/_apis/wit/workItems/297"}, "target": {"id": 299, "url": "https://tfs.company.com/DefaultCollection/_apis/wit/workItems/299"}},
{"rel": "System.LinkTypes.Hierarchy-Forward", "source": {"id": 297, "url": "https://tfs.company.com/DefaultCollection/_apis/wit/workItems/297"}, "target": {"id": 300, "url": "https://tfs.company.com/DefaultCollection/_apis/wit/workItems/300"}}
{"rel": null, "source": null, "target": {"id": 297, "url": "https://tfs.example.com/DefaultCollection/_apis/wit/workItems/297"}},
{"rel": "System.LinkTypes.Hierarchy-Forward", "source": {"id": 297, "url": "https://tfs.example.com/DefaultCollection/_apis/wit/workItems/297"}, "target": {"id": 299, "url": "https://tfs.example.com/DefaultCollection/_apis/wit/workItems/299"}},
{"rel": "System.LinkTypes.Hierarchy-Forward", "source": {"id": 297, "url": "https://tfs.example.com/DefaultCollection/_apis/wit/workItems/297"}, "target": {"id": 300, "url": "https://tfs.example.com/DefaultCollection/_apis/wit/workItems/300"}}
]
}`

Expand Down
4 changes: 2 additions & 2 deletions internal/htmlmd/htmlmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ func TestConvert(t *testing.T) {
},
{
name: "link",
html: `<p>See <a href="https://tfs.company.com/wi/12345">work item 12345</a>.</p>`,
want: "See [work item 12345](https://tfs.company.com/wi/12345).",
html: `<p>See <a href="https://tfs.example.com/wi/12345">work item 12345</a>.</p>`,
want: "See [work item 12345](https://tfs.example.com/wi/12345).",
},
{
name: "paragraphs are separated by a blank line",
Expand Down
14 changes: 7 additions & 7 deletions internal/log/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ func TestWriterLoggerLogRequest(t *testing.T) {
var sb strings.Builder
l := New(&sb)

l.LogRequest("GET", "https://tfs.company.com:8080/DefaultCollection/MyProject/_apis/wit/workitems/12345?api-version=7.2", 200, 245*time.Millisecond)
l.LogRequest("GET", "https://tfs.example.com:8080/DefaultCollection/MyProject/_apis/wit/workitems/12345?api-version=7.2", 200, 245*time.Millisecond)

want := "GET https://tfs.company.com:8080/DefaultCollection/MyProject/_apis/wit/workitems/12345?api-version=7.2 200 245ms\n"
want := "GET https://tfs.example.com:8080/DefaultCollection/MyProject/_apis/wit/workitems/12345?api-version=7.2 200 245ms\n"
if got := sb.String(); got != want {
t.Errorf("LogRequest() wrote %q, want %q", got, want)
}
Expand All @@ -23,18 +23,18 @@ func TestWriterLoggerLogRequestOneLinePerCall(t *testing.T) {
var sb strings.Builder
l := New(&sb)

l.LogRequest("GET", "https://tfs.company.com/coll/_apis/a", 200, time.Second)
l.LogRequest("GET", "https://tfs.company.com/coll/_apis/b", 404, 30*time.Millisecond)
l.LogRequest("GET", "https://tfs.example.com/coll/_apis/a", 200, time.Second)
l.LogRequest("GET", "https://tfs.example.com/coll/_apis/b", 404, 30*time.Millisecond)

got := sb.String()
lines := strings.Split(strings.TrimSuffix(got, "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("LogRequest() x2 wrote %d lines, want 2: %q", len(lines), got)
}
if want := "GET https://tfs.company.com/coll/_apis/a 200 1s"; lines[0] != want {
if want := "GET https://tfs.example.com/coll/_apis/a 200 1s"; lines[0] != want {
t.Errorf("first line = %q, want %q", lines[0], want)
}
if want := "GET https://tfs.company.com/coll/_apis/b 404 30ms"; lines[1] != want {
if want := "GET https://tfs.example.com/coll/_apis/b 404 30ms"; lines[1] != want {
t.Errorf("second line = %q, want %q", lines[1], want)
}
}
Expand Down Expand Up @@ -72,7 +72,7 @@ func TestNoopDoesNotPanic(t *testing.T) {
// safe to make.
l := Noop()

l.LogRequest("GET", "https://tfs.company.com/coll/_apis/a", 200, time.Second)
l.LogRequest("GET", "https://tfs.example.com/coll/_apis/a", 200, time.Second)
l.Warn("TLS verification disabled")
l.Info("api-version not negotiated")
}
2 changes: 1 addition & 1 deletion internal/tfserr/tfserr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestPrintWithoutHTTPStatus(t *testing.T) {
{Forbidden, "no access to project MyProject", "Error [forbidden]: no access to project MyProject\n"},
{Server, "TFS returned an invalid response", "Error [server]: TFS returned an invalid response\n"},
{Config, "not logged in: no credential at ~/.local/share/tfscli/auth.json", "Error [config]: not logged in: no credential at ~/.local/share/tfscli/auth.json\n"},
{Network, "cannot reach https://tfs.company.com:8080", "Error [network]: cannot reach https://tfs.company.com:8080\n"},
{Network, "cannot reach https://tfs.example.com:8080", "Error [network]: cannot reach https://tfs.example.com:8080\n"},
}
for _, tt := range tests {
t.Run(string(tt.category), func(t *testing.T) {
Expand Down
20 changes: 10 additions & 10 deletions internal/workitem/testdata/bug.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,28 @@
"System.Reason": "New",
"System.AssignedTo": {
"displayName": "Jane Doe",
"url": "https://tfs.company.com:8080/DefaultCollection/_apis/Identities/2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"url": "https://tfs.example.com:8080/DefaultCollection/_apis/Identities/2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"id": "2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"uniqueName": "COMPANY\\j.doe",
"imageUrl": "https://tfs.company.com:8080/DefaultCollection/_api/_common/identityImage?id=2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"imageUrl": "https://tfs.example.com:8080/DefaultCollection/_api/_common/identityImage?id=2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"descriptor": "win.Uy0xLTUtMjEtMTIzNDU2Nzg5MC0xMjM0NTY3ODkwLTEyMzQ1Njc4OTAtMTAwMQ"
},
"System.CreatedDate": "2026-06-14T09:12:33.117Z",
"System.CreatedBy": {
"displayName": "John Smith",
"url": "https://tfs.company.com:8080/DefaultCollection/_apis/Identities/8c7b6a59-4d3e-2f1a-9b8c-7d6e5f4a3b21",
"url": "https://tfs.example.com:8080/DefaultCollection/_apis/Identities/8c7b6a59-4d3e-2f1a-9b8c-7d6e5f4a3b21",
"id": "8c7b6a59-4d3e-2f1a-9b8c-7d6e5f4a3b21",
"uniqueName": "COMPANY\\j.smith",
"imageUrl": "https://tfs.company.com:8080/DefaultCollection/_api/_common/identityImage?id=8c7b6a59-4d3e-2f1a-9b8c-7d6e5f4a3b21",
"imageUrl": "https://tfs.example.com:8080/DefaultCollection/_api/_common/identityImage?id=8c7b6a59-4d3e-2f1a-9b8c-7d6e5f4a3b21",
"descriptor": "win.Uy0xLTUtMjEtMTIzNDU2Nzg5MC0xMjM0NTY3ODkwLTEyMzQ1Njc4OTAtMTAwMg"
},
"System.ChangedDate": "2026-07-01T15:48:02Z",
"System.ChangedBy": {
"displayName": "Jane Doe",
"url": "https://tfs.company.com:8080/DefaultCollection/_apis/Identities/2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"url": "https://tfs.example.com:8080/DefaultCollection/_apis/Identities/2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"id": "2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"uniqueName": "COMPANY\\j.doe",
"imageUrl": "https://tfs.company.com:8080/DefaultCollection/_api/_common/identityImage?id=2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"imageUrl": "https://tfs.example.com:8080/DefaultCollection/_api/_common/identityImage?id=2f1a8c3e-0b9d-4f77-9a1c-6d5e4b3a2109",
"descriptor": "win.Uy0xLTUtMjEtMTIzNDU2Nzg5MC0xMjM0NTY3ODkwLTEyMzQ1Njc4OTAtMTAwMQ"
},
"System.CommentCount": 3,
Expand All @@ -44,16 +44,16 @@
"Microsoft.VSTS.CMMI.Blocked": "No",
"Custom.RequiresRegression": true,
"System.Description": "<div>Customers who receive a <b>partial</b> refund never get the confirmation email.</div>",
"Microsoft.VSTS.TCM.ReproSteps": "<div><ol><li>Open order <a href=\"https://shop.company.com/orders/8891\">8891</a></li><li>Refund 500 of 1200</li><li>Check the mailbox</li></ol></div>",
"Microsoft.VSTS.TCM.ReproSteps": "<div><ol><li>Open order <a href=\"https://shop.example.com/orders/8891\">8891</a></li><li>Refund 500 of 1200</li><li>Check the mailbox</li></ol></div>",
"Microsoft.VSTS.TCM.SystemInfo": "<div>Server: PROD-APP-02<br/>Build: 4.18.2231</div>"
},
"_links": {
"self": {
"href": "https://tfs.company.com:8080/DefaultCollection/_apis/wit/workItems/12345"
"href": "https://tfs.example.com:8080/DefaultCollection/_apis/wit/workItems/12345"
},
"workItemType": {
"href": "https://tfs.company.com:8080/DefaultCollection/8a1f0e2d-5c4b-4a39-8e7d-6f5a4b3c2d10/_apis/wit/workItemTypes/Bug"
"href": "https://tfs.example.com:8080/DefaultCollection/8a1f0e2d-5c4b-4a39-8e7d-6f5a4b3c2d10/_apis/wit/workItemTypes/Bug"
}
},
"url": "https://tfs.company.com:8080/DefaultCollection/_apis/wit/workItems/12345"
"url": "https://tfs.example.com:8080/DefaultCollection/_apis/wit/workItems/12345"
}
2 changes: 1 addition & 1 deletion skills/tfscli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ Error [config]: list takes no arguments; pass the work item ids with --ids, e.g.
Error [config]: --team needs a project, and project is not set (pass -p, set TFSCLI_PROJECT, or add "project" to the config file)
Error [config]: The requested REST API version of 7.2 is out of range for this server. The latest REST API version this server supports is 7.1. (api-version "7.2" is set by TFSCLI_API_VERSION; remove it to let the server choose the version, or set one the server supports) (HTTP 400)
Error [auth]: the server did not accept the PAT (it may be invalid, expired, or revoked; if it is valid, IIS Basic Authentication may be enabled on the server, which only an administrator can turn off) (HTTP 401)
Error [network]: cannot reach https://tfs.company.com:8080
Error [network]: cannot reach https://tfs.example.com:8080
```

When the project is missing, ask the user for it. Do not guess a project name
Expand Down
Loading